Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
65eed09
fix: unblock imported playlist loading
Priveetee Jul 16, 2026
44c4559
fix: isolate server gradle jobs
Priveetee Jul 16, 2026
4c4ba49
fix: initialize isolated gradle homes
Priveetee Jul 16, 2026
188b756
fix: prevent takeout metadata stalls
Priveetee Jul 16, 2026
c5ae63a
fix: decouple takeout commit from extraction
Priveetee Jul 16, 2026
fa9b4e5
fix: preserve authoritative live sabr metadata
Priveetee Jul 17, 2026
0563352
feat: normalize self-initializing live media
Priveetee Jul 17, 2026
ac0277d
feat: track dynamic live playback state
Priveetee Jul 17, 2026
3ca4a17
test: cover dynamic live playback state
Priveetee Jul 17, 2026
e6c18ec
feat: expose dynamic live playback windows
Priveetee Jul 17, 2026
20c24a4
test: cover dynamic live playback windows
Priveetee Jul 17, 2026
05559cb
feat: keep live sabr demand active
Priveetee Jul 17, 2026
99621ce
feat: route live playback through sabr only
Priveetee Jul 17, 2026
b9bd8aa
test: add live sabr codec protocol probe
Priveetee Jul 17, 2026
70e343f
feat: expose video membership status
Priveetee Jul 17, 2026
5eaee91
feat: classify restricted video access
Priveetee Jul 17, 2026
bbf601a
fix: pin live SABR extractor
Priveetee Jul 17, 2026
102ba2c
fix: stabilize live SABR buffering
Priveetee Jul 17, 2026
47ac970
fix: advance asymmetric live windows
Priveetee Jul 17, 2026
34d1528
fix: bound live SABR demand responses
Priveetee Jul 17, 2026
b08debc
chore: pin combined SABR extractor hotfix
Priveetee Jul 18, 2026
4727e43
fix: refresh rejected SABR player contexts
Priveetee Jul 18, 2026
ec7af93
fix: include livestreams in subscription feed
Priveetee Jul 18, 2026
8de54f9
fix: route YouTube traffic through configured proxy
Priveetee Jul 18, 2026
aab76b4
fix: track live segment heads independently
Priveetee Jul 18, 2026
4639b79
fix: preserve missing live track demands
Priveetee Jul 18, 2026
003e1d9
fix: continue live tracks from served segments
Priveetee Jul 18, 2026
5fd03fa
fix: fill live buffer before playback
Priveetee Jul 18, 2026
c7d14ac
test: isolate granular SABR route demands
Priveetee Jul 18, 2026
89039cb
fix: add live buffer safety margin
Priveetee Jul 18, 2026
c191ad4
fix: start live playback from available segment
Priveetee Jul 18, 2026
3e0c773
fix: resolve skipped live media segment
Priveetee Jul 18, 2026
527fab6
test: align live demand rebase timing
Priveetee Jul 18, 2026
fc320fc
fix: preserve future live segment demand
Priveetee Jul 18, 2026
4dacce5
fix: wait for incomplete live head segment
Priveetee Jul 18, 2026
6ca356a
fix: track complete live media edge
Priveetee Jul 18, 2026
3679812
fix: follow complete live media edge
Priveetee Jul 18, 2026
9768b1b
fix: advance live media completion anchor
Priveetee Jul 18, 2026
74db7c9
fix: wait for in-flight live media completion
Priveetee Jul 18, 2026
653bdfb
fix: rewind historical live segment demand
Priveetee Jul 18, 2026
5753e82
fix: align live startup with available segment
Priveetee Jul 18, 2026
065a87e
fix: target historical live segment requests
Priveetee Jul 18, 2026
b85902d
fix: align live start with warmed media
Priveetee Jul 18, 2026
535f69f
fix: skip unavailable live media gap
Priveetee Jul 19, 2026
04edcdd
fix: serve rebased live media segment
Priveetee Jul 19, 2026
6d7ee60
fix: extend live window after segment gap
Priveetee Jul 19, 2026
b7b32c4
fix: continue live playback across media gaps
Priveetee Jul 19, 2026
215b539
fix: recover live playback across timeline gaps
Priveetee Jul 19, 2026
ac3a029
fix: recover stalled live segment demand
Priveetee Jul 19, 2026
4683da2
fix: isolate live playback session ranges
Priveetee Jul 19, 2026
d231842
fix: interrupt completed live SABR demands
Priveetee Jul 19, 2026
bbfd2fb
chore: pin PipePipeExtractor upstream
Priveetee Jul 19, 2026
c306672
fix: recover upstream SABR player context
Priveetee Jul 19, 2026
d5c5c80
fix: align upstream SABR playback state
Priveetee Jul 19, 2026
a0c748b
test: cover upstream SABR player context
Priveetee Jul 19, 2026
688e9f5
test: cover upstream SABR playback
Priveetee Jul 19, 2026
2f0a03c
chore: reconcile server main and dev
Priveetee Jul 19, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ DATABASE_PASSWORD=typetype
DRAGONFLY_URL=redis://dragonfly:6379
DOWNLOADER_SERVICE_URL=http://typetype-downloader:18093
YOUTUBE_SESSION_ENCRYPTION_KEY=replace-with-at-least-32-random-characters
YOUTUBE_OUTBOUND_PROXY_URL=
YOUTUBE_REMOTE_LOGIN_SERVICE_URL=http://typetype-token:8081
YOUTUBE_REMOTE_LOGIN_CALLBACK_BASE_URL=http://typetype-server:8080
YOUTUBE_REMOTE_LOGIN_INTERNAL_TOKEN=replace-with-shared-internal-token
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ jobs:
steps:
- uses: actions/checkout@v6

- name: Isolate Gradle user home
run: echo "GRADLE_USER_HOME=${RUNNER_TEMP}/gradle-user-home" >> "$GITHUB_ENV"

- uses: actions/setup-java@v5
with:
java-version: "25"
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/coverage.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ jobs:
steps:
- uses: actions/checkout@v6

- name: Isolate Gradle user home
run: echo "GRADLE_USER_HOME=${RUNNER_TEMP}/gradle-user-home" >> "$GITHUB_ENV"

- name: Set up JDK 25
uses: actions/setup-java@v5
with:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/openapi.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ jobs:
runs-on: ${{ github.event_name == 'pull_request' && 'ubuntu-24.04' || fromJSON('["self-hosted","Linux","X64","arko"]') }}
steps:
- uses: actions/checkout@v6
- name: Isolate Gradle user home
run: echo "GRADLE_USER_HOME=${RUNNER_TEMP}/gradle-user-home" >> "$GITHUB_ENV"
- uses: actions/setup-java@v5
with:
java-version: "25"
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ dependencies {
implementation("io.ktor:ktor-server-call-logging-jvm")
implementation("io.ktor:ktor-server-rate-limit-jvm")
implementation("ch.qos.logback:logback-classic:1.5.38")
implementation("com.github.Priveetee.PipePipeExtractor:extractor:ec9ba0dd6b0521d83ac3905f1ee980c33930140d")
implementation("com.github.InfinityLoop1308.PipePipeExtractor:extractor:00db0e1d9b553d941f3009eb79e492d95eaf442d")
compileOnly("com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751")
implementation("org.json:json:20260522")
implementation("com.squareup.okhttp3:okhttp:5.4.0")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package org.schabi.newpipe.extractor.services.youtube.sabr;

public final class TypeTypeYoutubeSabrInfoFactory {
private TypeTypeYoutubeSabrInfoFactory() {
}

public static YoutubeSabrInfo withPlaybackUrlAndClientVersion(
final YoutubeSabrInfo info,
final String serverAbrStreamingUrl,
final String clientVersion) {
return new YoutubeSabrInfo(
info.getProfile(),
info.getVideoId(),
info.getCpn(),
clientVersion,
info.getVisitorData(),
serverAbrStreamingUrl,
info.getVideoPlaybackUstreamerConfig(),
info.getFormats());
}
}
13 changes: 11 additions & 2 deletions src/main/kotlin/dev/typetype/server/Application.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.typetype.server
import dev.typetype.server.cache.DragonflyService
import dev.typetype.server.db.DatabaseFactory
import dev.typetype.server.downloader.YoutubeProxySelector
import dev.typetype.server.services.ActiveSessionService
import dev.typetype.server.services.AuthService
import dev.typetype.server.services.AdminSettingsService
Expand Down Expand Up @@ -50,8 +51,16 @@ fun Application.module() {
val cacheUrl = System.getenv("DRAGONFLY_URL") ?: "redis://localhost:6379"
val cache = DragonflyService(cacheUrl)
val subtitleServiceUrl = System.getenv("SUBTITLE_SERVICE_URL") ?: "http://typetype-token:8081"
NewPipeInitializer.init(subtitleServiceUrl)
val svc = ServiceRegistry(cache, subtitleServiceUrl, youtubeSessionEncryptionKey, jwtSecret, adminSettingsService)
val youtubeProxySelector = YoutubeProxySelector.fromUrl(System.getenv("YOUTUBE_OUTBOUND_PROXY_URL"))
NewPipeInitializer.init(subtitleServiceUrl, youtubeProxySelector)
val svc = ServiceRegistry(
cache,
subtitleServiceUrl,
youtubeSessionEncryptionKey,
jwtSecret,
adminSettingsService,
youtubeProxySelector,
)
val youtubeRemoteBrowserConfig = YoutubeRemoteBrowserConfig.fromEnvironment(subtitleServiceUrl)
val youtubeRemoteLoginReadinessService = YoutubeRemoteLoginReadinessService(
youtubeRemoteBrowserConfig,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,17 +51,21 @@ import dev.typetype.server.services.YoutubeSessionStreamService
import okhttp3.ConnectionPool
import okhttp3.Dispatcher
import okhttp3.OkHttpClient
import java.net.ProxySelector
import java.util.concurrent.TimeUnit

internal class ExtractionServiceRegistry(
cache: DragonflyService,
subtitleServiceUrl: String,
youtubeSessionEncryptionKey: String?,
hlsManifestUrlSigner: ((String) -> String)? = null,
youtubeProxySelector: ProxySelector? = null,
) {
private val youtubeSessionSecret = youtubeSessionEncryptionKey?.trim()
?.takeIf { it.length >= MIN_YOUTUBE_SESSION_SECRET_LENGTH }
val httpClient = OkHttpClient()
val httpClient = OkHttpClient.Builder()
.apply { youtubeProxySelector?.let(::proxySelector) }
.build()
val proxyHttpClient: OkHttpClient = httpClient.newBuilder()
.dispatcher(proxyDispatcher())
.connectionPool(ConnectionPool(64, 5, TimeUnit.MINUTES))
Expand Down
5 changes: 4 additions & 1 deletion src/main/kotlin/dev/typetype/server/ServiceRegistry.kt
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@ import dev.typetype.server.services.UserVideoMetadataRepairService
import dev.typetype.server.services.VideoMetadataResolver
import dev.typetype.server.services.WatchLaterService
import dev.typetype.server.services.YoutubeTakeoutFactory
import java.net.ProxySelector
internal class ServiceRegistry(
cache: DragonflyService,
subtitleServiceUrl: String,
youtubeSessionEncryptionKey: String?,
jwtSecret: String,
adminSettingsService: AdminSettingsService,
youtubeProxySelector: ProxySelector? = null,
) {
init {
SubscriptionFeedCacheInvalidation.configure(SubscriptionFeedCacheInvalidator(cache))
Expand All @@ -53,6 +55,7 @@ internal class ServiceRegistry(
subtitleServiceUrl,
youtubeSessionEncryptionKey,
publicHlsManifestTokenService::createPath,
youtubeProxySelector,
)
val youtubeSessionService = extraction.youtubeSessionService
val youtubeSessionStreamService = extraction.youtubeSessionStreamService
Expand Down Expand Up @@ -103,7 +106,7 @@ internal class ServiceRegistry(
val accessControlService = AccessControlService(settingsService, allowedChannelsService, allowedPlaylistsService, adminSettingsService)
val blockedService = BlockedService()
val bugReportService = BugReportService()
val youtubeTakeoutImportService = YoutubeTakeoutFactory.create(subscriptionsService, playlistService, historyService, favoritesService, watchLaterService, streamService)
val youtubeTakeoutImportService = YoutubeTakeoutFactory.create(subscriptionsService, playlistService, historyService, favoritesService, watchLaterService)
val recommendationPoolResolverDependencies = HomeRecommendationPoolResolverDependencies(
subscriptionsService = subscriptionsService,
subscriptionFeedService = subscriptionFeedService,
Expand Down
16 changes: 11 additions & 5 deletions src/main/kotlin/dev/typetype/server/downloader/OkHttpDownloader.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import org.schabi.newpipe.extractor.downloader.Response
import org.schabi.newpipe.extractor.downloader.StreamingResponse
import org.schabi.newpipe.extractor.exceptions.ReCaptchaException
import org.schabi.newpipe.extractor.localization.Localization
import java.net.ProxySelector
import java.util.concurrent.TimeUnit

class OkHttpDownloader private constructor(
Expand All @@ -20,13 +21,18 @@ class OkHttpDownloader private constructor(
) : Downloader() {

companion object {
fun instance(): OkHttpDownloader = create(STREAMING_READ_TIMEOUT_MS)

internal fun create(streamingReadTimeoutMs: Long): OkHttpDownloader {
val client = OkHttpClient.Builder()
fun instance(proxySelector: ProxySelector? = null): OkHttpDownloader =
create(STREAMING_READ_TIMEOUT_MS, proxySelector)

internal fun create(
streamingReadTimeoutMs: Long,
proxySelector: ProxySelector? = null,
): OkHttpDownloader {
val clientBuilder = OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
proxySelector?.let(clientBuilder::proxySelector)
val client = clientBuilder.build()
val streamingClient = client.newBuilder()
.readTimeout(streamingReadTimeoutMs, TimeUnit.MILLISECONDS)
.build()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package dev.typetype.server.downloader

import java.io.IOException
import java.net.InetSocketAddress
import java.net.Proxy
import java.net.ProxySelector
import java.net.SocketAddress
import java.net.URI

internal class YoutubeProxySelector private constructor(
private val proxy: Proxy,
) : ProxySelector() {
override fun select(uri: URI): List<Proxy> =
if (isYoutubeHost(uri.host)) listOf(proxy, Proxy.NO_PROXY) else DIRECT

override fun connectFailed(uri: URI, socketAddress: SocketAddress, exception: IOException) = Unit

companion object {
fun fromUrl(proxyUrl: String?): ProxySelector? {
val value = proxyUrl?.trim()?.takeIf(String::isNotEmpty) ?: return null
val uri = runCatching { URI(value) }
.getOrElse { throw IllegalArgumentException("Invalid YouTube outbound proxy URL", it) }
require(uri.scheme.equals("http", ignoreCase = true)) {
"YOUTUBE_OUTBOUND_PROXY_URL must use the http scheme"
}
require(!uri.host.isNullOrBlank()) {
"YOUTUBE_OUTBOUND_PROXY_URL must include a host"
}
require(uri.userInfo == null && uri.path.isNullOrEmpty() && uri.query == null && uri.fragment == null) {
"YOUTUBE_OUTBOUND_PROXY_URL must contain only a scheme, host, and port"
}
val port = uri.port.takeIf { it >= 0 } ?: DEFAULT_HTTP_PORT
val address = InetSocketAddress.createUnresolved(uri.host, port)
return YoutubeProxySelector(Proxy(Proxy.Type.HTTP, address))
}

private fun isYoutubeHost(host: String?): Boolean {
val normalizedHost = host?.lowercase() ?: return false
return YOUTUBE_DOMAINS.any { domain ->
normalizedHost == domain || normalizedHost.endsWith(".$domain")
}
}

private val DIRECT = listOf(Proxy.NO_PROXY)
private val YOUTUBE_DOMAINS = setOf(
"youtube.com",
"youtube-nocookie.com",
"youtu.be",
"googlevideo.com",
"ytimg.com",
"ggpht.com",
"googleusercontent.com",
"googleapis.com",
)
private const val DEFAULT_HTTP_PORT = 80
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@ package dev.typetype.server.models

sealed class ExtractionResult<out T> {
data class Success<T>(val data: T) : ExtractionResult<T>()
data class Failure(val message: String) : ExtractionResult<Nothing>()
data class BadRequest(val message: String) : ExtractionResult<Nothing>()
data class Failure(val message: String, val code: String = "error") : ExtractionResult<Nothing>()
data class BadRequest(val message: String, val code: String = "error") : ExtractionResult<Nothing>()
}
1 change: 1 addition & 0 deletions src/main/kotlin/dev/typetype/server/models/VideoItem.kt
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,5 @@ data class VideoItem(
val isLive: Boolean = false,
val isPostLive: Boolean = false,
val isLiveContent: Boolean = false,
val requiresMembership: Boolean = false,
)
5 changes: 3 additions & 2 deletions src/main/kotlin/dev/typetype/server/routes/FavoritesRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ import io.ktor.server.routing.post
fun Route.favoritesRoutes(favoritesService: FavoritesService, authService: AuthService, metadataRepairService: UserVideoMetadataRepairService? = null) {
get("/favorites") {
call.withJwtAuth(authService) { userId ->
metadataRepairService?.repairFavorites(userId)
call.respond(favoritesService.getAll(userId))
val items = favoritesService.getAll(userId)
metadataRepairService?.scheduleFavorites(call.application, userId)
call.respond(items)
}
}
get("/favorites/{videoUrl...}") {
Expand Down
3 changes: 1 addition & 2 deletions src/main/kotlin/dev/typetype/server/routes/PlaylistRoutes.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import io.ktor.server.routing.put
fun Route.playlistRoutes(playlistService: PlaylistService, authService: AuthService, metadataRepairService: UserVideoMetadataRepairService? = null) {
get("/playlists") {
call.withJwtAuth(authService) { userId ->
metadataRepairService?.repairPlaylists(userId)
call.respond(playlistService.getAll(userId))
}
}
Expand All @@ -35,8 +34,8 @@ fun Route.playlistRoutes(playlistService: PlaylistService, authService: AuthServ
get("/playlists/{id}") {
call.withJwtAuth(authService) { userId ->
val id = call.parameters["id"] ?: return@withJwtAuth call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing id"))
metadataRepairService?.repairPlaylists(userId)
val playlist = playlistService.getById(userId, id) ?: return@withJwtAuth call.respond(HttpStatusCode.NotFound, ErrorResponse("Not found"))
metadataRepairService?.schedulePlaylists(call.application, userId)
call.respond(playlist)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ internal class SabrPlaybackHandler(
video = video,
startTimeMs = startTimeMs,
audioOnly = request.audioOnly,
isLive = request.isLive,
)
preparation.holder.setActiveTracks(videoActive = !request.audioOnly, audioActive = true)
respondPrepared(call, preparation.holder, videoId, preparation.startTimeMs, preparation.ready)
Expand Down Expand Up @@ -144,6 +145,7 @@ internal class SabrPlaybackHandler(
startTimeMs = body?.startTimeMs ?: request.queryParameters["startTimeMs"]?.toLongOrNull(),
playerTimeMs = body?.playerTimeMs ?: request.queryParameters["playerTimeMs"]?.toLongOrNull(),
audioOnly = body?.audioOnly ?: request.queryParameters["audioOnly"]?.toBooleanStrictOrNull() ?: false,
isLive = body?.isLive ?: request.queryParameters["isLive"]?.toBooleanStrictOrNull() ?: false,
)
}

Expand Down
28 changes: 28 additions & 0 deletions src/main/kotlin/dev/typetype/server/routes/SabrPlaybackModels.kt
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package dev.typetype.server.routes

import kotlinx.serialization.Serializable
import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest

@Serializable
internal data class SabrPlaybackRequest(
Expand All @@ -10,6 +11,19 @@ internal data class SabrPlaybackRequest(
val startTimeMs: Long? = null,
val playerTimeMs: Long? = null,
val audioOnly: Boolean = false,
val isLive: Boolean = false,
)

@Serializable
internal data class SabrLivePlaybackResponse(
val active: Boolean,
val postLiveDvr: Boolean,
val headSequence: Long,
val headTimeMs: Long,
val seekableStartMs: Long,
val seekableEndMs: Long,
val atLiveEdge: Boolean,
val targetLatencyMs: Long,
)

@Serializable
Expand All @@ -25,6 +39,7 @@ internal data class SabrPlaybackResponse(
val ready: Boolean,
val status: String,
val retryAfterMs: Long? = null,
val live: SabrLivePlaybackResponse? = null,
)

@Serializable
Expand All @@ -48,6 +63,7 @@ internal data class SabrPlaybackStateResponse(
val pendingSegmentDemand: String? = null,
val terminalError: String? = null,
val diagnosticTrace: String? = null,
val live: SabrLivePlaybackResponse? = null,
)

@Serializable
Expand Down Expand Up @@ -91,6 +107,7 @@ internal data class SabrPlaybackPositionResponse(
val readerHeadMs: Long,
val readerTailMs: Long,
val bufferedEdgeMs: Long,
val live: SabrLivePlaybackResponse? = null,
)

@Serializable
Expand All @@ -113,6 +130,7 @@ internal data class SabrPlaybackPrefetchResponse(
val terminalError: String? = null,
val recoveryAction: String? = null,
val retryVideoItags: List<Int> = emptyList(),
val live: SabrLivePlaybackResponse? = null,
)

@Serializable
Expand All @@ -125,6 +143,8 @@ internal data class SabrPlaybackWindowReadyResponse(
val endOfStream: Boolean,
val audio: SabrPlaybackWindowTrack,
val video: SabrPlaybackWindowTrack? = null,
val startTimeMs: Long = 0L,
val live: SabrLivePlaybackResponse? = null,
)

@Serializable
Expand Down Expand Up @@ -159,4 +179,12 @@ internal data class SabrPlaybackWindowPreparingResponse(
val terminalError: String? = null,
val recoveryAction: String? = null,
val retryVideoItags: List<Int> = emptyList(),
val live: SabrLivePlaybackResponse? = null,
)

internal data class SabrPlaybackWindowBuildResult(
val response: SabrPlaybackWindowReadyResponse,
val blockedBy: String?,
val blockedRequests: List<SabrSegmentRequest>,
val isReady: Boolean,
)
Loading