From 5bc66b33216aa6c140a69920715b70e9d1f51385 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 30 Jul 2026 13:22:51 +0200 Subject: [PATCH 01/33] chore: prepare server 1.3.1 beta --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index de136bf3..6d84d772 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ org.gradle.jvmargs=-Xmx2g -XX:+UseG1GC kotlin.code.style=official -appVersion=1.2.3 +appVersion=1.3.1 systemProp.sun.net.client.defaultReadTimeout=180000 systemProp.sun.net.client.defaultConnectTimeout=60000 From 75fad74d785a9321a02f475aef1719e40e6959f7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 30 Jul 2026 18:46:03 +0200 Subject: [PATCH 02/33] fix: release replaced SABR sessions --- .../services/SabrInFlightDemandTracker.kt | 7 ++ .../services/SabrPlaybackDiagnostics.kt | 4 + .../server/services/SabrPumpLauncher.kt | 4 +- .../server/services/SabrSessionFactory.kt | 39 ++++++++++ .../server/services/SabrSessionRegistry.kt | 52 ++++++++++--- .../server/services/SabrSessionResources.kt | 8 ++ .../server/services/SabrSessionStore.kt | 25 ++----- .../server/services/SabrPumpLauncherTest.kt | 8 +- .../services/SabrSessionRegistryTest.kt | 74 ++++++++++++++++++- 9 files changed, 184 insertions(+), 37 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/server/services/SabrSessionFactory.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/SabrSessionResources.kt diff --git a/src/main/kotlin/dev/typetype/server/services/SabrInFlightDemandTracker.kt b/src/main/kotlin/dev/typetype/server/services/SabrInFlightDemandTracker.kt index c52ac13d..97c9b780 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrInFlightDemandTracker.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrInFlightDemandTracker.kt @@ -48,6 +48,10 @@ internal object SabrInFlightDemandTracker { return demands.remove(holder.sessionToken, demand) } + fun clear(holder: SabrSessionHolder): Unit { + demands.remove(holder.sessionToken) + } + fun clearAll(): Unit = demands.clear() } @@ -62,3 +66,6 @@ internal fun SabrSessionHolder.inFlightSegmentDemand(): SabrInFlightDemand? = internal fun SabrSessionHolder.finishInFlightSegmentDemand(identity: String): Boolean = SabrInFlightDemandTracker.finish(this, identity) + +internal fun SabrSessionHolder.clearInFlightSegmentDemand(): Unit = + SabrInFlightDemandTracker.clear(this) diff --git a/src/main/kotlin/dev/typetype/server/services/SabrPlaybackDiagnostics.kt b/src/main/kotlin/dev/typetype/server/services/SabrPlaybackDiagnostics.kt index e6fc5b0f..35064731 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrPlaybackDiagnostics.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrPlaybackDiagnostics.kt @@ -19,6 +19,10 @@ internal object SabrPlaybackDiagnostics { if (!segment.header.isInitSegment) blockers.remove(holder.sessionToken) } + fun clear(holder: SabrSessionHolder): Unit { + blockers.remove(holder.sessionToken) + } + fun blocker(holder: SabrSessionHolder): String? = blockers[holder.sessionToken] private fun SabrSegmentRequest.trackName(): String = if (format.isAudio) "audio" else "video" diff --git a/src/main/kotlin/dev/typetype/server/services/SabrPumpLauncher.kt b/src/main/kotlin/dev/typetype/server/services/SabrPumpLauncher.kt index 4ba51b19..645968ef 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrPumpLauncher.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrPumpLauncher.kt @@ -21,10 +21,10 @@ internal fun CoroutineScope.launchSabrPump( launch { val owner = currentCoroutineContext().job val watchdogJob = launch { - if (watchdog.monitor({ registry.contains(holder.key) }, holder)) owner.cancel() + if (watchdog.monitor({ registry.contains(holder) }, holder)) owner.cancel() } try { - pump.pumpLoop({ registry.contains(holder.key) }, holder, intervalMs) + pump.pumpLoop({ registry.contains(holder) }, holder, intervalMs) } finally { watchdogJob.cancel() holder.markPumpStopped() diff --git a/src/main/kotlin/dev/typetype/server/services/SabrSessionFactory.kt b/src/main/kotlin/dev/typetype/server/services/SabrSessionFactory.kt new file mode 100644 index 00000000..3269b6dc --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/SabrSessionFactory.kt @@ -0,0 +1,39 @@ +package dev.typetype.server.services + +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession +import java.time.Instant + +internal class SabrSessionFactory( + private val tokenClient: TypetypeTokenSabrTokenClient, +) { + fun create( + key: SabrSessionKey, + info: YoutubeSabrInfo, + audioFormat: YoutubeSabrFormat, + videoFormat: YoutubeSabrFormat, + sessionToken: String, + initialToken: SabrTokenBundle?, + initialGeneration: Long, + ): SabrSessionHolder { + val provider = TypetypeTokenSabrPoTokenProvider(tokenClient, initialToken) + val sessionInfo = if (key.sourceId == null) info else SabrSessionIdentity.fresh(info) + val session = YoutubeSabrSession(sessionInfo, audioFormat, videoFormat, provider) + session.streamState.setPlayerTimeMs(key.startTimeMs) + runCatching { provider.getPoToken(sessionInfo, session.streamState) } + .getOrNull() + ?.let { session.streamState.setPoToken(it) } + return SabrSessionHolder( + session, + sessionInfo, + audioFormat, + videoFormat, + sessionToken, + key, + Instant.now(), + initialToken, + initialGeneration = initialGeneration, + ).also { it.setPlayerTimeMs(key.startTimeMs) } + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/SabrSessionRegistry.kt b/src/main/kotlin/dev/typetype/server/services/SabrSessionRegistry.kt index 913fecc9..34ef8db2 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrSessionRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrSessionRegistry.kt @@ -6,6 +6,7 @@ import java.util.concurrent.ConcurrentHashMap internal class SabrSessionRegistry { private val sessions = ConcurrentHashMap() private val sessionsByToken = ConcurrentHashMap() + private val mutationLock = Any() fun get(key: SabrSessionKey): SabrSessionHolder? { val holder = sessions[key] @@ -20,17 +21,26 @@ internal class SabrSessionRegistry { return null } - fun put(key: SabrSessionKey, holder: SabrSessionHolder) { - sessions[key] = holder - sessionsByToken[holder.sessionToken] = holder + fun put(key: SabrSessionKey, holder: SabrSessionHolder): SabrSessionHolder { + val active = synchronized(mutationLock) { + sessions[key]?.also { it.touch() } ?: holder.also { + sessions[key] = it + sessionsByToken[it.sessionToken] = it + } + } + if (active !== holder) holder.releaseResources() + return active } - fun contains(key: SabrSessionKey): Boolean = sessions.containsKey(key) + fun contains(holder: SabrSessionHolder): Boolean = sessions[holder.key] === holder fun remove(holder: SabrSessionHolder): Unit { - sessions.remove(holder.key, holder) - sessionsByToken.remove(holder.sessionToken, holder) - holder.clearSegmentDemands() + val removed = synchronized(mutationLock) { + if (!sessions.remove(holder.key, holder)) return@synchronized false + sessionsByToken.remove(holder.sessionToken, holder) + true + } + if (removed) holder.releaseResources() } fun lookupByItag(videoId: String, userId: String, itag: Int): SabrSessionHolder? { @@ -75,6 +85,17 @@ internal class SabrSessionRegistry { } } + fun trimToCapacity(maxSessions: Int, protected: SabrSessionHolder) { + while (sessions.size > maxSessions) { + val oldest = sessions.entries + .asSequence() + .filterNot { it.value === protected } + .minByOrNull { it.value.lastRequestAt } + ?: return + remove(oldest.key) + } + } + fun evictIdle(cutoff: Instant) { val stale = sessions.entries .filter { it.value.lastRequestAt.isBefore(cutoff) } @@ -83,15 +104,22 @@ internal class SabrSessionRegistry { } fun clear() { - sessions.clear() - sessionsByToken.clear() + val holders = synchronized(mutationLock) { + sessions.values.toSet().also { + sessions.clear() + sessionsByToken.clear() + } + } SabrSegmentDemandTracker.clearAll() + holders.forEach(SabrSessionHolder::releaseResources) } private fun remove(key: SabrSessionKey) { - sessions.remove(key)?.let { holder -> - sessionsByToken.remove(holder.sessionToken, holder) - holder.clearSegmentDemands() + val holder = synchronized(mutationLock) { + sessions.remove(key)?.also { + sessionsByToken.remove(it.sessionToken, it) + } } + holder?.releaseResources() } } diff --git a/src/main/kotlin/dev/typetype/server/services/SabrSessionResources.kt b/src/main/kotlin/dev/typetype/server/services/SabrSessionResources.kt new file mode 100644 index 00000000..30cf98c8 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/SabrSessionResources.kt @@ -0,0 +1,8 @@ +package dev.typetype.server.services + +internal fun SabrSessionHolder.releaseResources() { + clearSegmentDemands() + clearInFlightSegmentDemand() + SabrPlaybackDiagnostics.clear(this) + session.clearCache() +} diff --git a/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt b/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt index c4dd1f57..b55e49bd 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt @@ -13,7 +13,6 @@ import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo -import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession import java.time.Duration import java.time.Instant @@ -33,6 +32,7 @@ internal class SabrSessionStore( } private val warmer = SabrPlaybackWarmer() private val infoFetcher = SabrInfoFetcher(tokenClient, sessionClient, sharedCache = initCache) + private val sessionFactory = SabrSessionFactory(tokenClient) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val idleCheckJob: Job = scope.launch { idleEvictionLoop() } @@ -69,27 +69,18 @@ internal class SabrSessionStore( ) registry.getReusable(key)?.let { return it } registry.ensureCapacity(maxSessions) - val provider = TypetypeTokenSabrPoTokenProvider(tokenClient, initialToken) - val sessionInfo = if (isolatedSourceId == null) info else SabrSessionIdentity.fresh(info) - val session = YoutubeSabrSession(sessionInfo, audioFormat, videoFormat, provider) - val normalizedStartTimeMs = startTimeMs.coerceAtLeast(0L) - session.streamState.setPlayerTimeMs(normalizedStartTimeMs) - runCatching { provider.getPoToken(sessionInfo, session.streamState) } - .getOrNull() - ?.let { session.streamState.setPoToken(it) } - val holder = SabrSessionHolder( - session, - sessionInfo, + val holder = sessionFactory.create( + key, + info, audioFormat, videoFormat, sessionToken, - key, - Instant.now(), initialToken, - initialGeneration = initialGeneration, + initialGeneration, ) - holder.setPlayerTimeMs(normalizedStartTimeMs) - registry.put(key, holder) + val active = registry.put(key, holder) + if (active !== holder) return active + registry.trimToCapacity(maxSessions, holder) if (startPump) startPump(holder) return holder } diff --git a/src/test/kotlin/dev/typetype/server/services/SabrPumpLauncherTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrPumpLauncherTest.kt index a5bb7293..a4b8a889 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrPumpLauncherTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrPumpLauncherTest.kt @@ -35,7 +35,7 @@ class SabrPumpLauncherTest { every { holder.session.getCachedSegment(any()) } returns null every { holder.session.streamState.getBufferedEndMs(holder.audioFormat) } returns 0L every { holder.session.streamState.getBufferedEndMs(holder.videoFormat) } answers { companionBufferedEndMs } - every { registry.contains(holder.key) } returns true + every { registry.contains(holder) } returns true holder.requestSegmentDemand(request, registeredAtMs = 0L) coEvery { pump.pumpLoop(any(), holder, 100L) } coAnswers { awaitCancellation() } val watchdog = SabrDemandWatchdog( @@ -76,7 +76,7 @@ class SabrPumpLauncherTest { var targetBufferedEndMs = 0L every { holder.session.getCachedSegment(any()) } returns null every { holder.session.streamState.getBufferedEndMs(holder.audioFormat) } answers { targetBufferedEndMs } - every { registry.contains(holder.key) } returns true + every { registry.contains(holder) } returns true holder.requestSegmentDemand(request, registeredAtMs = 0L) coEvery { pump.pumpLoop(any(), holder, 100L) } coAnswers { awaitCancellation() } val watchdog = SabrDemandWatchdog( @@ -110,7 +110,7 @@ class SabrPumpLauncherTest { val first = SabrSegmentRequest.media(holder.audioFormat, 50) val second = SabrSegmentRequest.media(holder.audioFormat, 51) every { holder.session.getCachedSegment(any()) } returns null - every { registry.contains(holder.key) } returns true + every { registry.contains(holder) } returns true holder.requestSegmentDemand(first, registeredAtMs = 0L) coEvery { pump.pumpLoop(any(), holder, 100L) } coAnswers { awaitCancellation() } val watchdog = SabrDemandWatchdog( @@ -194,7 +194,7 @@ class SabrPumpLauncherTest { val registry = mockk() val holder = holder() holder.recordNetworkFailure(null) - every { registry.contains(holder.key) } returns true + every { registry.contains(holder) } returns true coEvery { pump.pumpLoop(any(), holder, 100L) } returns Unit launchSabrPump(pump, registry, holder, 100L) diff --git a/src/test/kotlin/dev/typetype/server/services/SabrSessionRegistryTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrSessionRegistryTest.kt index 3923489a..16c88046 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrSessionRegistryTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrSessionRegistryTest.kt @@ -2,14 +2,17 @@ package dev.typetype.server.services import io.mockk.every import io.mockk.mockk +import io.mockk.verify import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState import java.time.Instant +import java.util.concurrent.CountDownLatch class SabrSessionRegistryTest { @Test @@ -86,20 +89,87 @@ class SabrSessionRegistryTest { assertNull(registry.lookupByToken("token-network")) } + @Test + fun `duplicate registration keeps original and closes candidate`() { + val registry = SabrSessionRegistry() + val key = key("same") + val original = holder("same", Instant.EPOCH, "original-token") + val candidate = holder("same", Instant.EPOCH.plusSeconds(1), "candidate-token") + + registry.put(key, original) + val active = registry.put(key, candidate) + + assertSame(original, active) + assertSame(original, registry.get(key)) + assertSame(original, registry.lookupByToken(original.sessionToken)) + assertNull(registry.lookupByToken(candidate.sessionToken)) + verify(exactly = 1) { candidate.session.clearCache() } + verify(exactly = 0) { original.session.clearCache() } + } + + @Test + fun `concurrent duplicate registrations retain one session and close all losers`() { + val registry = SabrSessionRegistry() + val contenders = (0 until 16).map { + holder("same", Instant.EPOCH.plusMillis(it.toLong()), "token-$it") + } + val ready = CountDownLatch(contenders.size) + val start = CountDownLatch(1) + val threads = contenders.map { contender -> + Thread { + ready.countDown() + start.await() + registry.put(contender.key, contender) + }.apply { start() } + } + + ready.await() + start.countDown() + threads.forEach(Thread::join) + + val active = registry.get(key("same")) + assertTrue(contenders.any { it === active }) + contenders.forEach { contender -> + verify(exactly = if (contender === active) 0 else 1) { contender.session.clearCache() } + if (contender === active) { + assertSame(active, registry.lookupByToken(contender.sessionToken)) + } else { + assertNull(registry.lookupByToken(contender.sessionToken)) + } + } + } + + @Test + fun `removal closes extractor session`() { + val registry = SabrSessionRegistry() + val holder = holder("removed", Instant.EPOCH) + registry.put(holder.key, holder) + + registry.remove(holder) + + assertNull(registry.get(holder.key)) + verify(exactly = 1) { holder.session.clearCache() } + } + private fun key(id: String): SabrSessionKey = SabrSessionKey(id, "user", 140, null, 137, 0L) - private fun holder(videoId: String, lastRequestAt: Instant): SabrSessionHolder { + private fun holder( + videoId: String, + lastRequestAt: Instant, + sessionToken: String = "token-$videoId", + ): SabrSessionHolder { val key = key(videoId) val session = mockk() val state = mockk() every { session.streamState } returns state + every { session.clearCache() } returns Unit every { state.setActiveTrackTypes(true, true) } returns Unit return SabrSessionHolder( session = session, info = info(videoId), audioFormat = format(140, isAudio = true), videoFormat = format(137, isAudio = false), - sessionToken = "token-$videoId", + sessionToken = sessionToken, key = key, lastRequestAt = lastRequestAt, ) From 1c8bcbfd74b74058ad6a102e2b1150fc4ed33cd0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 30 Jul 2026 18:46:18 +0200 Subject: [PATCH 03/33] perf: bound SABR memory caches --- .../server/services/BoundedExpiringCache.kt | 97 +++++++++++++++++++ .../server/services/CachedSabrSegment.kt | 37 ++++--- .../server/services/SabrInfoFetcher.kt | 2 + .../server/services/SabrInfoRepository.kt | 18 +++- .../server/services/SabrInitializationData.kt | 17 +++- .../server/services/SabrPreparedInfoCache.kt | 26 +++-- .../server/services/SabrSessionStore.kt | 2 + .../services/BoundedExpiringCacheTest.kt | 59 +++++++++++ .../services/SabrPreparedInfoCacheTest.kt | 28 ++++++ .../server/services/SabrSegmentCacheTest.kt | 5 +- 10 files changed, 254 insertions(+), 37 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/server/services/BoundedExpiringCache.kt create mode 100644 src/test/kotlin/dev/typetype/server/services/BoundedExpiringCacheTest.kt diff --git a/src/main/kotlin/dev/typetype/server/services/BoundedExpiringCache.kt b/src/main/kotlin/dev/typetype/server/services/BoundedExpiringCache.kt new file mode 100644 index 00000000..cba0a255 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/BoundedExpiringCache.kt @@ -0,0 +1,97 @@ +package dev.typetype.server.services + +import java.time.Duration +import java.util.LinkedHashMap + +internal class BoundedExpiringCache( + private val maxEntries: Int, + private val maxWeight: Long = Long.MAX_VALUE, + ttl: Duration, + private val weigher: (V) -> Long = { 1L }, + private val clock: () -> Long = System::currentTimeMillis, +) { + private val ttlMs = ttl.toMillis() + private val entries = LinkedHashMap>(maxEntries.coerceAtMost(64), 0.75f, true) + private var weight = 0L + + init { + require(maxEntries > 0) { "maxEntries must be positive" } + require(maxWeight > 0L) { "maxWeight must be positive" } + require(!ttl.isNegative && !ttl.isZero) { "ttl must be positive" } + } + + @Synchronized + fun get(key: K): V? { + evictExpired(clock()) + return entries[key]?.value + } + + @Synchronized + fun put(key: K, value: V) { + val now = clock() + evictExpired(now) + removeEntry(key) + val entryWeight = weigher(value).coerceAtLeast(0L) + if (entryWeight > maxWeight) return + entries[key] = Entry(value, expiresAt(now), entryWeight) + weight += entryWeight + trim() + } + + @Synchronized + fun remove(key: K): V? = removeEntry(key)?.value + + @Synchronized + fun removeIf(predicate: (K) -> Boolean) { + val iterator = entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (!predicate(entry.key)) continue + weight -= entry.value.weight + iterator.remove() + } + } + + @Synchronized + fun evictExpired() { + evictExpired(clock()) + } + + @Synchronized + fun clear() { + entries.clear() + weight = 0L + } + + @Synchronized + internal fun size(): Int = entries.size + + @Synchronized + internal fun weight(): Long = weight + + private fun expiresAt(now: Long): Long = + if (Long.MAX_VALUE - now < ttlMs) Long.MAX_VALUE else now + ttlMs + + private fun evictExpired(now: Long) { + val iterator = entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next().value + if (entry.expiresAtMs > now) continue + weight -= entry.weight + iterator.remove() + } + } + + private fun trim() { + val iterator = entries.iterator() + while ((entries.size > maxEntries || weight > maxWeight) && iterator.hasNext()) { + weight -= iterator.next().value.weight + iterator.remove() + } + } + + private fun removeEntry(key: K): Entry? = + entries.remove(key)?.also { weight -= it.weight } + + private data class Entry(val value: V, val expiresAtMs: Long, val weight: Long) +} diff --git a/src/main/kotlin/dev/typetype/server/services/CachedSabrSegment.kt b/src/main/kotlin/dev/typetype/server/services/CachedSabrSegment.kt index 205a02a0..c92342dc 100644 --- a/src/main/kotlin/dev/typetype/server/services/CachedSabrSegment.kt +++ b/src/main/kotlin/dev/typetype/server/services/CachedSabrSegment.kt @@ -1,25 +1,39 @@ package dev.typetype.server.services -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment import java.util.Base64 -@Serializable -internal data class CachedSabrSegment( +internal class CachedSabrSegment( val itag: Int, val sequence: Int, val init: Boolean, val startMs: Long, val durationMs: Long, val mimeType: String, - val bytesBase64: String, - val byteLength: Int = -1, + val bytes: ByteArray, ) { - @Transient - private var decodedBytes: ByteArray? = null - val bytes: ByteArray get() = decodedBytes ?: Base64.getDecoder().decode(bytesBase64).also { decodedBytes = it } - val length: Int get() = byteLength.takeIf { it >= 0 } ?: bytes.size + constructor( + itag: Int, + sequence: Int, + init: Boolean, + startMs: Long, + durationMs: Long, + mimeType: String, + bytesBase64: String, + byteLength: Int = -1, + ) : this( + itag, + sequence, + init, + startMs, + durationMs, + mimeType, + Base64.getDecoder().decode(bytesBase64).also { + require(byteLength < 0 || byteLength == it.size) { "byteLength does not match decoded bytes" } + }, + ) + + val length: Int get() = bytes.size } internal fun SabrMediaSegment.toCachedSabrSegment( @@ -32,6 +46,5 @@ internal fun SabrMediaSegment.toCachedSabrSegment( startMs = header.startMs, durationMs = header.durationMs, mimeType = mimeType, - bytesBase64 = Base64.getEncoder().encodeToString(bytes), - byteLength = bytes.size, + bytes = bytes, ) diff --git a/src/main/kotlin/dev/typetype/server/services/SabrInfoFetcher.kt b/src/main/kotlin/dev/typetype/server/services/SabrInfoFetcher.kt index c9c5b71c..b8a84929 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrInfoFetcher.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrInfoFetcher.kt @@ -68,6 +68,8 @@ internal class SabrInfoFetcher( fun initializationFormat(videoId: String, target: YoutubeSabrFormat): YoutubeSabrFormat? = repository.initializationFormat(videoId, target) + fun evictExpired(): Unit = repository.evictExpired() + private suspend fun fetchPlayable(videoId: String, startTimeMs: Long): SabrPreparedInfo? = fetchInfoOnce(videoId, startTimeMs)?.let { repository.putPrepared(videoId, startTimeMs, it) } diff --git a/src/main/kotlin/dev/typetype/server/services/SabrInfoRepository.kt b/src/main/kotlin/dev/typetype/server/services/SabrInfoRepository.kt index 68c143ce..452aafe5 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrInfoRepository.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrInfoRepository.kt @@ -3,7 +3,7 @@ package dev.typetype.server.services import dev.typetype.server.cache.CacheService import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo -import java.util.concurrent.ConcurrentHashMap +import java.time.Duration internal class SabrInfoRepository( infoCache: SabrPreparedInfoCache, @@ -11,7 +11,10 @@ internal class SabrInfoRepository( ) { private val preparedInfos = infoCache private val sharedInfos = SabrInfoSharedCache(sharedCache) - private val initializationInfos = ConcurrentHashMap() + private val initializationInfos = BoundedExpiringCache( + maxEntries = 256, + ttl = Duration.ofHours(6), + ) fun local(videoId: String, startTimeMs: Long): SabrPreparedInfo? { val cachedAtStart = preparedInfos.get(videoId, startTimeMs) @@ -23,7 +26,7 @@ internal class SabrInfoRepository( } suspend fun shared(videoId: String, token: SabrTokenBundle): SabrPreparedInfo? { - sharedInfos.getInitialization(videoId)?.let { initializationInfos[videoId] = it } + sharedInfos.getInitialization(videoId)?.let { initializationInfos.put(videoId, it) } val info = sharedInfos.getPlayback(videoId) ?: return null if (info.visitorData != token.visitorData) return null return putPrepared(videoId, startTimeMs = 0L, SabrPreparedInfo(info, token), share = false) @@ -31,7 +34,7 @@ internal class SabrInfoRepository( suspend fun rememberInitialization(videoId: String, info: YoutubeSabrInfo): Unit { if (!SabrPreparedInfo(info, null).hasAudioAndVideoFormats()) return - initializationInfos[videoId] = info + initializationInfos.put(videoId, info) sharedInfos.putInitialization(videoId, info) } @@ -52,7 +55,12 @@ internal class SabrInfoRepository( } fun initializationFormat(videoId: String, target: YoutubeSabrFormat): YoutubeSabrFormat? = - initializationInfos[videoId]?.formats?.firstOrNull { + initializationInfos.get(videoId)?.formats?.firstOrNull { it.itag == target.itag && it.audioTrackId == target.audioTrackId && it.xtags == target.xtags } + + fun evictExpired() { + preparedInfos.evictExpired() + initializationInfos.evictExpired() + } } diff --git a/src/main/kotlin/dev/typetype/server/services/SabrInitializationData.kt b/src/main/kotlin/dev/typetype/server/services/SabrInitializationData.kt index 966e84d0..95b1d8cc 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrInitializationData.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrInitializationData.kt @@ -6,14 +6,19 @@ import org.schabi.newpipe.extractor.localization.Localization import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat import java.security.MessageDigest +import java.time.Duration import java.util.Base64 import java.util.Collections import java.util.WeakHashMap -import java.util.concurrent.ConcurrentHashMap internal object SabrInitializationData { private const val CACHE_TTL_SECONDS = 21_600L - private val memoryCache = ConcurrentHashMap() + private val memoryCache = BoundedExpiringCache( + maxEntries = 512, + maxWeight = 32L * 1024L * 1024L, + ttl = Duration.ofSeconds(CACHE_TTL_SECONDS), + weigher = { it.size.toLong() }, + ) private val formatCache = Collections.synchronizedMap(WeakHashMap()) suspend fun ingest( @@ -28,12 +33,12 @@ internal object SabrInitializationData { suspend fun fetch(videoId: String, format: YoutubeSabrFormat, cache: CacheService? = null): ByteArray? { val key = cacheKey(videoId, format) formatCache[format]?.let { return it } - memoryCache[key]?.let { + memoryCache.get(key)?.let { formatCache[format] = it return it } cache?.getBytes(key)?.let { bytes -> - memoryCache[key] = bytes + memoryCache.put(key, bytes) formatCache[format] = bytes return bytes } @@ -47,7 +52,7 @@ internal object SabrInitializationData { cache: CacheService? = null, ): Unit { val key = cacheKey(videoId, format) - memoryCache[key] = bytes + memoryCache.put(key, bytes) formatCache[format] = bytes cache?.setBytes(key, bytes, CACHE_TTL_SECONDS) } @@ -57,6 +62,8 @@ internal object SabrInitializationData { return holder.session.streamState.ingestInitializationData(format, bytes) } + fun evictExpired(): Unit = memoryCache.evictExpired() + suspend fun bootstrap( holder: SabrSessionHolder, format: YoutubeSabrFormat, diff --git a/src/main/kotlin/dev/typetype/server/services/SabrPreparedInfoCache.kt b/src/main/kotlin/dev/typetype/server/services/SabrPreparedInfoCache.kt index cec89e05..b7f160ff 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrPreparedInfoCache.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrPreparedInfoCache.kt @@ -1,22 +1,20 @@ package dev.typetype.server.services import java.time.Duration -import java.time.Instant -import java.util.concurrent.ConcurrentHashMap internal class SabrPreparedInfoCache( private val ttl: Duration = Duration.ofMinutes(10), + maxEntries: Int = 256, + clock: () -> Long = System::currentTimeMillis, ) { - private val items = ConcurrentHashMap() + private val items = BoundedExpiringCache( + maxEntries = maxEntries, + ttl = ttl, + clock = clock, + ) fun get(videoId: String, startTimeMs: Long): SabrPreparedInfo? { - val key = Key(videoId, startBucket(startTimeMs)) - val entry = items[key] ?: return null - if (entry.createdAt.plus(ttl).isBefore(Instant.now())) { - items.remove(key, entry) - return null - } - return entry.value + return items.get(Key(videoId, startBucket(startTimeMs))) } fun remove(videoId: String, startTimeMs: Long): Unit { @@ -24,20 +22,20 @@ internal class SabrPreparedInfoCache( } fun remove(videoId: String): Unit { - items.keys.removeIf { it.videoId == videoId } + items.removeIf { it.videoId == videoId } } fun put(videoId: String, startTimeMs: Long, value: SabrPreparedInfo): SabrPreparedInfo { - items[Key(videoId, startBucket(startTimeMs))] = Entry(value, Instant.now()) + items.put(Key(videoId, startBucket(startTimeMs)), value) return value } + fun evictExpired(): Unit = items.evictExpired() + private fun startBucket(startTimeMs: Long): Long = startTimeMs.coerceAtLeast(0L) / START_BUCKET_MS private data class Key(val videoId: String, val startBucket: Long) - private data class Entry(val value: SabrPreparedInfo, val createdAt: Instant) - private companion object { const val START_BUCKET_MS = 30_000L } diff --git a/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt b/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt index b55e49bd..83a9be20 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt @@ -206,6 +206,8 @@ internal class SabrSessionStore( while (true) { delay(15_000) registry.evictIdle(Instant.now().minus(idleEviction)) + infoFetcher.evictExpired() + SabrInitializationData.evictExpired() } } } diff --git a/src/test/kotlin/dev/typetype/server/services/BoundedExpiringCacheTest.kt b/src/test/kotlin/dev/typetype/server/services/BoundedExpiringCacheTest.kt new file mode 100644 index 00000000..bf593670 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/BoundedExpiringCacheTest.kt @@ -0,0 +1,59 @@ +package dev.typetype.server.services + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import java.time.Duration + +class BoundedExpiringCacheTest { + @Test + fun `least recently used entry is removed at capacity`() { + val cache = BoundedExpiringCache( + maxEntries = 2, + ttl = Duration.ofMinutes(1), + ) + cache.put("first", "1") + cache.put("second", "2") + cache.get("first") + + cache.put("third", "3") + + assertEquals("1", cache.get("first")) + assertNull(cache.get("second")) + assertEquals("3", cache.get("third")) + } + + @Test + fun `weight limit removes oldest entries`() { + val cache = BoundedExpiringCache( + maxEntries = 10, + maxWeight = 5, + ttl = Duration.ofMinutes(1), + weigher = { it.length.toLong() }, + ) + cache.put("first", "123") + + cache.put("second", "456") + + assertNull(cache.get("first")) + assertEquals("456", cache.get("second")) + assertEquals(3, cache.weight()) + } + + @Test + fun `new writes purge expired entries without reading their keys`() { + var now = 0L + val cache = BoundedExpiringCache( + maxEntries = 10, + ttl = Duration.ofMillis(10), + clock = { now }, + ) + cache.put("expired", "old") + now = 10L + + cache.put("current", "new") + + assertNull(cache.get("expired")) + assertEquals(1, cache.size()) + } +} diff --git a/src/test/kotlin/dev/typetype/server/services/SabrPreparedInfoCacheTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrPreparedInfoCacheTest.kt index 84e17fd8..bdad3b61 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrPreparedInfoCacheTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrPreparedInfoCacheTest.kt @@ -14,6 +14,7 @@ import org.junit.jupiter.api.Test import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrClientProfile import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo +import java.time.Duration class SabrPreparedInfoCacheTest { @Test @@ -55,6 +56,33 @@ class SabrPreparedInfoCacheTest { assertFalse(cache.get("video", 340_000L)?.hasAudioAndVideoFormats() == true) } + @Test + fun `cache evicts expired entries without reading their keys`() { + var now = 0L + val cache = SabrPreparedInfoCache( + ttl = Duration.ofMillis(10), + clock = { now }, + ) + cache.put("video", 0L, preparedInfo(listOf(format(true), format(false)))) + now = 10L + + cache.evictExpired() + + assertFalse(cache.get("video", 0L)?.hasAudioAndVideoFormats() == true) + } + + @Test + fun `cache limits retained preparation windows`() { + val cache = SabrPreparedInfoCache(maxEntries = 1) + val prepared = preparedInfo(listOf(format(true), format(false))) + cache.put("first", 0L, prepared) + + cache.put("second", 0L, prepared) + + assertFalse(cache.get("first", 0L)?.hasAudioAndVideoFormats() == true) + assertSame(prepared, cache.get("second", 0L)) + } + @Test fun `info fetcher keeps extracted sabr info for initialization fallback`() = runTest { val audio = format(isAudio = true) diff --git a/src/test/kotlin/dev/typetype/server/services/SabrSegmentCacheTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrSegmentCacheTest.kt index 9cbc431d..2be01656 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrSegmentCacheTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrSegmentCacheTest.kt @@ -4,6 +4,7 @@ import io.mockk.every import io.mockk.mockk import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Test import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaHeader import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaSegment @@ -21,7 +22,8 @@ class SabrSegmentCacheTest { val audio = format(140, isAudio = true) val video = format(137, isAudio = false) val holder = holder(audio, video) - val segment = segment(itag = 140, sequence = 4, bytes = byteArrayOf(1, 2, 3)) + val bytes = byteArrayOf(1, 2, 3) + val segment = segment(itag = 140, sequence = 4, bytes = bytes) val request = SabrSegmentRequest.media(audio, 4) segmentCache.put(holder, segment) @@ -34,6 +36,7 @@ class SabrSegmentCacheTest { assertEquals(9985L, cached.durationMs) assertEquals("audio/mp4", cached.mimeType) assertArrayEquals(byteArrayOf(1, 2, 3), cached.bytes) + assertSame(bytes, cached.bytes) assertEquals(4, holder.observedMediaSegment(audio)?.header?.sequenceNumber) } From a856b08e4214e751d18db2e12ed366cf998c9970 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 31 Jul 2026 11:04:36 +0200 Subject: [PATCH 04/33] fix: invalidate rejected SABR playback context --- .../dev/typetype/server/routes/SabrPlaybackRecovery.kt | 1 + .../typetype/server/routes/SabrPlaybackRecoveryTest.kt | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt b/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt index 28751024..02ea6d42 100644 --- a/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt +++ b/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt @@ -18,6 +18,7 @@ internal class SabrPlaybackRecovery(private val sessionStore: SabrSessionStore) return RETRY_FRESH_SESSION } if (failure.contains("protected no-media")) { + sessionStore.invalidatePlaybackInfo(holder.key.videoId) return RETRY_FRESH_SESSION } if (failure.contains("SABR demand stalled")) { diff --git a/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt b/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt index 694e9e28..9654cf05 100644 --- a/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt +++ b/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt @@ -90,12 +90,16 @@ class SabrPlaybackRecoveryTest { } @Test - fun `protected no-media keeps the selected format on a fresh session`() = runTest { + fun `protected no-media invalidates playback info and keeps the selected format`() = runTest { val holder = mockk() - val recovery = SabrPlaybackRecovery(mockk()) + val store = mockk() every { holder.terminalFailure() } returns "video:299:12 status=3 protected no-media" + every { holder.key } returns SabrSessionKey("video", "user", 140, null, 299, 0L) + coEvery { store.invalidatePlaybackInfo("video") } returns Unit + val recovery = SabrPlaybackRecovery(store) assertEquals("retry_fresh_session", recovery.action(holder)) assertEquals(emptyList(), recovery.retryVideoItags()) + coVerify(exactly = 1) { store.invalidatePlaybackInfo("video") } } } From 0b42a14a24f350170870efb310871ebb1fdf884b Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 31 Jul 2026 11:19:30 +0200 Subject: [PATCH 05/33] fix: refresh rejected SABR token context --- .../server/routes/SabrPlaybackRecovery.kt | 2 +- .../server/services/SabrInfoFetcher.kt | 7 +++ .../services/SabrProtectedContextRecovery.kt | 16 ++++++ .../server/services/SabrSessionStore.kt | 6 +++ .../server/routes/SabrPlaybackRecoveryTest.kt | 4 +- .../SabrProtectedContextRecoveryTest.kt | 49 +++++++++++++++++++ 6 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/server/services/SabrProtectedContextRecovery.kt create mode 100644 src/test/kotlin/dev/typetype/server/services/SabrProtectedContextRecoveryTest.kt diff --git a/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt b/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt index 02ea6d42..8f7f312f 100644 --- a/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt +++ b/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt @@ -18,7 +18,7 @@ internal class SabrPlaybackRecovery(private val sessionStore: SabrSessionStore) return RETRY_FRESH_SESSION } if (failure.contains("protected no-media")) { - sessionStore.invalidatePlaybackInfo(holder.key.videoId) + sessionStore.recoverProtectedPlaybackInfo(holder) return RETRY_FRESH_SESSION } if (failure.contains("SABR demand stalled")) { diff --git a/src/main/kotlin/dev/typetype/server/services/SabrInfoFetcher.kt b/src/main/kotlin/dev/typetype/server/services/SabrInfoFetcher.kt index b8a84929..6e5e5e74 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrInfoFetcher.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrInfoFetcher.kt @@ -17,6 +17,7 @@ internal class SabrInfoFetcher( private val playerInfoProbe: SabrPlayerInfoProbe = PipePipeSabrPlayerInfoProbe, ) { private val repository = SabrInfoRepository(infoCache, sharedCache) + private val protectedContextRecovery = SabrProtectedContextRecovery(tokenClient) suspend fun fetchInfo( videoId: String, @@ -65,6 +66,12 @@ internal class SabrInfoFetcher( suspend fun invalidatePlayback(videoId: String): Unit = repository.invalidatePlayback(videoId) + suspend fun recoverProtectedPlayback(videoId: String, rejectedVisitorData: String?): Unit = + withContext(Dispatchers.IO) { + protectedContextRecovery.refreshIfRejected(videoId, rejectedVisitorData) + repository.invalidatePlayback(videoId) + } + fun initializationFormat(videoId: String, target: YoutubeSabrFormat): YoutubeSabrFormat? = repository.initializationFormat(videoId, target) diff --git a/src/main/kotlin/dev/typetype/server/services/SabrProtectedContextRecovery.kt b/src/main/kotlin/dev/typetype/server/services/SabrProtectedContextRecovery.kt new file mode 100644 index 00000000..47058543 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/SabrProtectedContextRecovery.kt @@ -0,0 +1,16 @@ +package dev.typetype.server.services + +internal class SabrProtectedContextRecovery( + private val tokenClient: TypetypeTokenSabrTokenClient, +) { + fun refreshIfRejected(videoId: String, rejectedVisitorData: String?) { + val currentToken = tokenClient.fetch(videoId) + if ( + currentToken == null || + rejectedVisitorData.isNullOrBlank() || + currentToken.visitorData == rejectedVisitorData + ) { + tokenClient.fetch(videoId, forceRefresh = true) + } + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt b/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt index 83a9be20..231a7675 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt @@ -150,6 +150,12 @@ internal class SabrSessionStore( internal suspend fun invalidatePlaybackInfo(videoId: String): Unit = infoFetcher.invalidatePlayback(videoId) + internal suspend fun recoverProtectedPlaybackInfo(holder: SabrSessionHolder): Unit = + infoFetcher.recoverProtectedPlayback( + holder.key.videoId, + holder.playerContextToken?.visitorData ?: holder.info.visitorData, + ) + internal fun refreshVideoPoToken(videoId: String): SabrTokenBundle? = tokenClient.fetch(videoId, refreshVideo = true) diff --git a/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt b/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt index 9654cf05..181e1aa8 100644 --- a/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt +++ b/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt @@ -95,11 +95,11 @@ class SabrPlaybackRecoveryTest { val store = mockk() every { holder.terminalFailure() } returns "video:299:12 status=3 protected no-media" every { holder.key } returns SabrSessionKey("video", "user", 140, null, 299, 0L) - coEvery { store.invalidatePlaybackInfo("video") } returns Unit + coEvery { store.recoverProtectedPlaybackInfo(holder) } returns Unit val recovery = SabrPlaybackRecovery(store) assertEquals("retry_fresh_session", recovery.action(holder)) assertEquals(emptyList(), recovery.retryVideoItags()) - coVerify(exactly = 1) { store.invalidatePlaybackInfo("video") } + coVerify(exactly = 1) { store.recoverProtectedPlaybackInfo(holder) } } } diff --git a/src/test/kotlin/dev/typetype/server/services/SabrProtectedContextRecoveryTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrProtectedContextRecoveryTest.kt new file mode 100644 index 00000000..0552eddf --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/SabrProtectedContextRecoveryTest.kt @@ -0,0 +1,49 @@ +package dev.typetype.server.services + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class SabrProtectedContextRecoveryTest { + @Test + fun `refreshes the active rejected context`() { + val tokenClient = mockk() + every { tokenClient.fetch("video") } returns token("rejected") + every { tokenClient.fetch("video", forceRefresh = true) } returns token("fresh") + + SabrProtectedContextRecovery(tokenClient).refreshIfRejected("video", "rejected") + + verify(exactly = 1) { tokenClient.fetch("video", forceRefresh = true) } + } + + @Test + fun `keeps a context already refreshed by another recovery`() { + val tokenClient = mockk() + every { tokenClient.fetch("video") } returns token("fresh") + + SabrProtectedContextRecovery(tokenClient).refreshIfRejected("video", "rejected") + + verify(exactly = 0) { tokenClient.fetch("video", forceRefresh = true) } + } + + @Test + fun `refreshes when the current context cannot be read`() { + val tokenClient = mockk() + every { tokenClient.fetch("video") } returns null + every { tokenClient.fetch("video", forceRefresh = true) } returns token("fresh") + + SabrProtectedContextRecovery(tokenClient).refreshIfRejected("video", "rejected") + + verify(exactly = 1) { tokenClient.fetch("video", forceRefresh = true) } + } + + private fun token(visitorData: String): SabrTokenBundle = SabrTokenBundle( + videoId = "video", + visitorBoundPoToken = "player-$visitorData", + visitorBoundPoTokenBytes = byteArrayOf(1), + visitorData = visitorData, + videoBoundPoToken = "video-$visitorData", + videoBoundPoTokenBytes = byteArrayOf(2), + ) +} From 91be9b32914caa274551720dffb9a3292773e3fc Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 09:22:07 +0200 Subject: [PATCH 06/33] chore: align PipePipeExtractor token providers --- .../services/TypetypeTokenSabrPoTokenProvider.kt | 15 ++------------- .../TypetypeTokenYoutubeSessionPoTokenProvider.kt | 2 ++ .../TypetypeYoutubeSessionPoTokenProvider.kt | 2 ++ ...etypeTokenYoutubeSessionPoTokenProviderTest.kt | 10 +++++----- .../YoutubeAuthenticatedExtractionProbeTest.kt | 2 ++ .../services/SabrSessionPlayerContextTest.kt | 2 ++ .../services/TypetypeTokenSabrTokenClientTest.kt | 10 +++++----- .../TypetypeYoutubeSessionPoTokenProviderTest.kt | 2 ++ 8 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/dev/typetype/server/services/TypetypeTokenSabrPoTokenProvider.kt b/src/main/kotlin/dev/typetype/server/services/TypetypeTokenSabrPoTokenProvider.kt index 78527ecc..9495bcc1 100644 --- a/src/main/kotlin/dev/typetype/server/services/TypetypeTokenSabrPoTokenProvider.kt +++ b/src/main/kotlin/dev/typetype/server/services/TypetypeTokenSabrPoTokenProvider.kt @@ -11,19 +11,8 @@ internal class TypetypeTokenSabrPoTokenProvider( ) : SabrPoTokenProvider { constructor(tokenServiceUrl: String) : this(TypetypeTokenSabrTokenClient(tokenServiceUrl)) - override fun getPoToken(info: YoutubeSabrInfo, streamState: YoutubeSabrStreamState): ByteArray? = - fetch(info, forceRefresh = false) - - override fun getPoToken( - info: YoutubeSabrInfo, - streamState: YoutubeSabrStreamState, - forceRefresh: Boolean, - ): ByteArray? = fetch(info, forceRefresh) - - private fun fetch(info: YoutubeSabrInfo, forceRefresh: Boolean): ByteArray? { - val token = if (!forceRefresh && initialToken?.videoId == info.videoId) initialToken else { - tokenClient.fetch(info.videoId, refreshVideo = forceRefresh) - } + override fun getPoToken(info: YoutubeSabrInfo, streamState: YoutubeSabrStreamState): ByteArray? { + val token = if (initialToken?.videoId == info.videoId) initialToken else tokenClient.fetch(info.videoId) return token?.streamingPoTokenBytesFor(info) ?: token?.let { throw SabrRecoverableException(SABR_TOKEN_BINDING_FAILURE) } } diff --git a/src/main/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionPoTokenProvider.kt b/src/main/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionPoTokenProvider.kt index f0fbad22..abb8ca67 100644 --- a/src/main/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionPoTokenProvider.kt +++ b/src/main/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionPoTokenProvider.kt @@ -28,6 +28,8 @@ internal class TypetypeTokenYoutubeSessionPoTokenProvider( override fun getSessionPoToken( clientName: String, + clientVersion: String, + userAgent: String?, localization: Localization, contentCountry: ContentCountry, loggedIn: Boolean, diff --git a/src/main/kotlin/dev/typetype/server/services/TypetypeYoutubeSessionPoTokenProvider.kt b/src/main/kotlin/dev/typetype/server/services/TypetypeYoutubeSessionPoTokenProvider.kt index f4f28006..72bff27b 100644 --- a/src/main/kotlin/dev/typetype/server/services/TypetypeYoutubeSessionPoTokenProvider.kt +++ b/src/main/kotlin/dev/typetype/server/services/TypetypeYoutubeSessionPoTokenProvider.kt @@ -21,6 +21,8 @@ internal object TypetypeYoutubeSessionPoTokenProvider : YoutubeSessionPoTokenPro override fun getSessionPoToken( clientName: String, + clientVersion: String, + userAgent: String?, localization: Localization, contentCountry: ContentCountry, loggedIn: Boolean, diff --git a/src/test/kotlin/dev/typetype/server/TypetypeTokenYoutubeSessionPoTokenProviderTest.kt b/src/test/kotlin/dev/typetype/server/TypetypeTokenYoutubeSessionPoTokenProviderTest.kt index b97ce165..1d9638f9 100644 --- a/src/test/kotlin/dev/typetype/server/TypetypeTokenYoutubeSessionPoTokenProviderTest.kt +++ b/src/test/kotlin/dev/typetype/server/TypetypeTokenYoutubeSessionPoTokenProviderTest.kt @@ -24,7 +24,7 @@ class TypetypeTokenYoutubeSessionPoTokenProviderTest { visitorDataFetcher = { _, _ -> calls += 1; "unused" }, ) - val result = provider.getSessionPoToken("WEB", localization, country, false) + val result = provider.getSessionPoToken("WEB", "1.0", "test-user-agent", localization, country, false) assertNull(result) assertEquals(0, calls) @@ -40,8 +40,8 @@ class TypetypeTokenYoutubeSessionPoTokenProviderTest { visitorDataFetcher = { _, _ -> visitorCalls += 1; "visitor-one" }, ) - val first = provider.getSessionPoToken("TV", localization, country, true) - val second = provider.getSessionPoToken("WEB", localization, country, true) + val first = provider.getSessionPoToken("TV", "1.0", "test-user-agent", localization, country, true) + val second = provider.getSessionPoToken("WEB", "2.0", "test-user-agent", localization, country, true) assertEquals("visitor-one", first?.visitorData) assertEquals("token-for-visitor-one", first?.poToken) @@ -58,10 +58,10 @@ class TypetypeTokenYoutubeSessionPoTokenProviderTest { visitorDataFetcher = { _, _ -> "visitor-${++index}" }, ) ServiceList.YouTube.setTokens("SID=one; SAPISID=one") - val first = provider.getSessionPoToken("WEB", localization, country, true) + val first = provider.getSessionPoToken("WEB", "1.0", "test-user-agent", localization, country, true) ServiceList.YouTube.setTokens("SID=two; SAPISID=two") - val second = provider.getSessionPoToken("WEB", localization, country, true) + val second = provider.getSessionPoToken("WEB", "1.0", "test-user-agent", localization, country, true) assertEquals("visitor-1", first?.visitorData) assertEquals("visitor-2", second?.visitorData) diff --git a/src/test/kotlin/dev/typetype/server/YoutubeAuthenticatedExtractionProbeTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeAuthenticatedExtractionProbeTest.kt index ffe357b0..4fb7c168 100644 --- a/src/test/kotlin/dev/typetype/server/YoutubeAuthenticatedExtractionProbeTest.kt +++ b/src/test/kotlin/dev/typetype/server/YoutubeAuthenticatedExtractionProbeTest.kt @@ -59,6 +59,8 @@ class YoutubeAuthenticatedExtractionProbeTest { val result = YoutubeSessionTokenScope.withCredentials(credentials) { val token = NewPipe.getYoutubeSessionPoTokenProvider()?.getSessionPoToken( "TV", + "1.0", + "test-user-agent", localization, contentCountry, true, diff --git a/src/test/kotlin/dev/typetype/server/services/SabrSessionPlayerContextTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrSessionPlayerContextTest.kt index 3f7d2f86..b95c4f07 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrSessionPlayerContextTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrSessionPlayerContextTest.kt @@ -57,6 +57,8 @@ class SabrSessionPlayerContextTest { private fun currentToken() = TypetypeYoutubeSessionPoTokenProvider.getSessionPoToken( "MWEB", + "2.20260801.00.00", + "test-user-agent", Localization("en", "US"), ContentCountry("US"), false, diff --git a/src/test/kotlin/dev/typetype/server/services/TypetypeTokenSabrTokenClientTest.kt b/src/test/kotlin/dev/typetype/server/services/TypetypeTokenSabrTokenClientTest.kt index a713f17d..54b3a704 100644 --- a/src/test/kotlin/dev/typetype/server/services/TypetypeTokenSabrTokenClientTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/TypetypeTokenSabrTokenClientTest.kt @@ -48,18 +48,18 @@ class TypetypeTokenSabrTokenClientTest { } @Test - fun providerForceRefreshUsesVideoRefreshOnly(): Unit { + fun providerFetchesVideoTokenWithoutRefresh(): Unit { val recorder = PotokenRequestRecorder() val client = TypetypeTokenSabrTokenClient("https://token.example", recorder.client) val provider = TypetypeTokenSabrPoTokenProvider(client) - val token = provider.getPoToken(info("visitor"), mockk(), true) + val token = provider.getPoToken(info("visitor"), mockk()) assertNotNull(token) assertArrayEquals(byteArrayOf(2), token) val url = recorder.urls.single() assertEquals("video", url.queryParameter("videoId")) assertNull(url.queryParameter("refresh")) - assertEquals("true", url.queryParameter("refreshVideo")) + assertNull(url.queryParameter("refreshVideo")) } @Test @@ -70,13 +70,13 @@ class TypetypeTokenSabrTokenClientTest { ) val error = assertThrows(SabrRecoverableException::class.java) { - provider.getPoToken(info("visitor"), mockk(), true) + provider.getPoToken(info("visitor"), mockk()) } assertEquals(SABR_TOKEN_BINDING_FAILURE, error.message) val url = recorder.urls.single() assertNull(url.queryParameter("refresh")) - assertEquals("true", url.queryParameter("refreshVideo")) + assertNull(url.queryParameter("refreshVideo")) } private fun info(expectedVisitorData: String): YoutubeSabrInfo = mockk { diff --git a/src/test/kotlin/dev/typetype/server/services/TypetypeYoutubeSessionPoTokenProviderTest.kt b/src/test/kotlin/dev/typetype/server/services/TypetypeYoutubeSessionPoTokenProviderTest.kt index 01a8bf23..7d80673c 100644 --- a/src/test/kotlin/dev/typetype/server/services/TypetypeYoutubeSessionPoTokenProviderTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/TypetypeYoutubeSessionPoTokenProviderTest.kt @@ -42,6 +42,8 @@ class TypetypeYoutubeSessionPoTokenProviderTest { private fun currentToken() = TypetypeYoutubeSessionPoTokenProvider.getSessionPoToken( "MWEB", + "2.20260801.00.00", + "test-user-agent", Localization("en", "US"), ContentCountry("US"), false, From e197f9204c7d9857966e3ddf073a1f18d8e2deed Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 09:39:28 +0200 Subject: [PATCH 07/33] fix: proxy YouTube subtitle tracks --- openapi.yaml | 3 + openapi/components/streams.yaml | 11 ++- openapi/paths/proxy.yaml | 49 +++++++++++ .../server/ApplicationStreamRoutes.kt | 2 +- .../dev/typetype/server/ServiceRegistry.kt | 1 + .../dev/typetype/server/routes/ProxyRoutes.kt | 11 ++- .../server/routes/YouTubeSubtitleResponse.kt | 51 +++++++++++ .../server/services/YouTubeSubtitleService.kt | 86 +++++++++++++++++++ 8 files changed, 211 insertions(+), 3 deletions(-) create mode 100644 openapi/paths/proxy.yaml create mode 100644 src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt diff --git a/openapi.yaml b/openapi.yaml index ab456f14..2cebeb7e 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -25,6 +25,7 @@ paths: /streams/bilibili: { $ref: ./openapi/paths/streams.yaml#/BiliBiliStreams } /streams/audio-only: { $ref: ./openapi/paths/streams.yaml#/AudioOnly } /streams/audio-only/source: { $ref: ./openapi/paths/streams.yaml#/AudioOnlySource } + /proxy: { $ref: ./openapi/paths/proxy.yaml#/Proxy } /sabr/download/{videoId}: { $ref: ./openapi/paths/sabr-download.yaml#/SabrDownload } /sabr/playback/{sessionId}/position: { $ref: ./openapi/paths/sabr-playback.yaml#/SabrPlaybackPosition } /sabr/playback/{sessionId}/window: { $ref: ./openapi/paths/sabr-playback.yaml#/SabrPlaybackWindow } @@ -100,6 +101,8 @@ components: $ref: ./openapi/components/streams.yaml#/StreamResponse AudioOnlyStreamResponse: $ref: ./openapi/components/streams.yaml#/AudioOnlyStreamResponse + SubtitleItem: + $ref: ./openapi/components/streams.yaml#/SubtitleItem SabrPlaybackPositionRequest: $ref: ./openapi/components/sabr-playback.yaml#/SabrPlaybackPositionRequest SabrPlaybackWindowRequest: diff --git a/openapi/components/streams.yaml b/openapi/components/streams.yaml index a7bbe17e..4ef4c5e2 100644 --- a/openapi/components/streams.yaml +++ b/openapi/components/streams.yaml @@ -48,6 +48,15 @@ AudioOnlyStreamResponse: bitrate: { type: integer, nullable: true } contentLength: { type: integer, format: int64, nullable: true } duration: { type: integer, format: int64, nullable: true } +SubtitleItem: + type: object + required: [url, mimeType, languageTag, displayLanguageName, isAutoGenerated] + properties: + url: { type: string, format: uri } + mimeType: { type: string } + languageTag: { type: string } + displayLanguageName: { type: string } + isAutoGenerated: { type: boolean } StreamResponse: type: object required: [id, title, uploaderName, uploaderUrl, thumbnailUrl, description, duration, viewCount, uploadDate, uploaded, streamType, isLive, isPostLive, isLiveContent, hasLiveManifest, videoStreams, audioStreams, videoOnlyStreams, subtitles, relatedStreams] @@ -77,6 +86,6 @@ StreamResponse: videoStreams: { type: array, items: { $ref: '#/VideoStreamItem' } } audioStreams: { type: array, items: { $ref: '#/AudioStreamItem' } } videoOnlyStreams: { type: array, items: { $ref: '#/VideoStreamItem' } } - subtitles: { type: array, items: { type: object }, default: [] } + subtitles: { type: array, items: { $ref: '#/SubtitleItem' }, default: [] } relatedStreams: { type: array, items: { $ref: './media.yaml#/VideoItem' } } sponsorBlockSegments: { type: array, items: { type: object }, default: [] } diff --git a/openapi/paths/proxy.yaml b/openapi/paths/proxy.yaml new file mode 100644 index 00000000..4276bd6e --- /dev/null +++ b/openapi/paths/proxy.yaml @@ -0,0 +1,49 @@ +Proxy: + get: + tags: [extraction] + summary: Retrieve proxied media or YouTube subtitle content + description: >- + YouTube timed-text URLs are refreshed through the Token service and returned as same-origin + WebVTT. Other supported URLs retain the generic streaming proxy behavior. + parameters: + - name: url + in: query + required: true + schema: { type: string, format: uri } + - name: Range + in: header + required: false + schema: { type: string } + responses: + '200': + description: Complete proxied content. + headers: + X-Request-ID: + $ref: ../components/common.yaml#/RequestIdHeader + content: + text/vtt: + schema: { type: string } + application/octet-stream: + schema: { type: string, format: binary } + '206': + description: Partial generic proxy response. + headers: + X-Request-ID: + $ref: ../components/common.yaml#/RequestIdHeader + Content-Range: + schema: { type: string } + Accept-Ranges: + schema: { type: string } + content: + application/octet-stream: + schema: { type: string, format: binary } + '400': + $ref: ../components/common.yaml#/JsonError + '404': + $ref: ../components/common.yaml#/JsonError + '422': + $ref: ../components/common.yaml#/JsonError + '429': + $ref: ../components/common.yaml#/JsonError + '502': + $ref: ../components/common.yaml#/JsonError diff --git a/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt b/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt index b965446a..dc8fc948 100644 --- a/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt @@ -56,7 +56,7 @@ internal fun Route.installStreamRoutes( internal fun Route.installProxyRoutes(svc: ServiceRegistry) { rateLimit(PROXY_ZONE) { - proxyRoutes(svc.proxyService) + proxyRoutes(svc.proxyService, svc.youtubeSubtitleService) audioOnlySourceRoutes( streamService = svc.streamService, proxyService = svc.proxyService, diff --git a/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt b/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt index 0ec40261..3e918502 100644 --- a/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt @@ -69,6 +69,7 @@ internal class ServiceRegistry( val podcastService = extraction.podcastService val publicPlaylistService = extraction.publicPlaylistService val proxyService = extraction.proxyService + val youtubeSubtitleService = extraction.youtubeSubtitleService val nicoVideoProxyService = extraction.nicoVideoProxyService val manifestService = extraction.manifestService val nativeManifestService = extraction.nativeManifestService diff --git a/src/main/kotlin/dev/typetype/server/routes/ProxyRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/ProxyRoutes.kt index ad61981e..cef0ecef 100644 --- a/src/main/kotlin/dev/typetype/server/routes/ProxyRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/ProxyRoutes.kt @@ -2,16 +2,25 @@ package dev.typetype.server.routes import dev.typetype.server.models.ErrorResponse import dev.typetype.server.services.ProxyService +import dev.typetype.server.services.YouTubeSubtitleService +import dev.typetype.server.services.isYouTubeTimedTextUrl import io.ktor.http.HttpStatusCode import io.ktor.server.response.respond import io.ktor.server.routing.Route import io.ktor.server.routing.get -fun Route.proxyRoutes(proxyService: ProxyService) { +internal fun Route.proxyRoutes( + proxyService: ProxyService, + youtubeSubtitleService: YouTubeSubtitleService? = null, +) { get("/proxy") { val url = call.request.queryParameters["url"] ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing 'url' parameter")) + if (youtubeSubtitleService != null && isYouTubeTimedTextUrl(url)) { + return@get call.respondYouTubeSubtitle(youtubeSubtitleService.fetchSubtitleContent(url)) + } + val rangeHeader = call.request.headers["Range"] val domandBid = call.request.queryParameters["domand_bid"] diff --git a/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt b/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt new file mode 100644 index 00000000..47a403f6 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt @@ -0,0 +1,51 @@ +package dev.typetype.server.routes + +import dev.typetype.server.models.ErrorResponse +import dev.typetype.server.services.YouTubeSubtitleContentResult +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.ApplicationCall +import io.ktor.server.response.respond +import io.ktor.server.response.respondBytes + +internal suspend fun ApplicationCall.respondYouTubeSubtitle(result: YouTubeSubtitleContentResult) { + when (result) { + is YouTubeSubtitleContentResult.Ready -> { + response.headers.append("Cache-Control", "private, max-age=300", safeOnly = false) + respondBytes(result.content, ContentType.parse("text/vtt; charset=utf-8")) + } + YouTubeSubtitleContentResult.InvalidRequest -> respondSubtitleError( + HttpStatusCode.BadRequest, + "Invalid YouTube subtitle URL", + "subtitle_request_invalid", + ) + YouTubeSubtitleContentResult.NotFound -> respondSubtitleError( + HttpStatusCode.NotFound, + "Subtitle track not found", + "subtitle_track_not_found", + ) + YouTubeSubtitleContentResult.Throttled -> respondSubtitleError( + HttpStatusCode.TooManyRequests, + "YouTube temporarily throttled subtitle retrieval", + "subtitle_upstream_throttled", + ) + YouTubeSubtitleContentResult.InvalidPayload -> respondSubtitleError( + HttpStatusCode.BadGateway, + "YouTube returned invalid subtitle content", + "subtitle_payload_invalid", + ) + YouTubeSubtitleContentResult.Unavailable -> respondSubtitleError( + HttpStatusCode.BadGateway, + "YouTube subtitle retrieval failed", + "subtitle_upstream_unavailable", + ) + } +} + +private suspend fun ApplicationCall.respondSubtitleError( + status: HttpStatusCode, + message: String, + code: String, +) { + respond(status, ErrorResponse(message, code)) +} diff --git a/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleService.kt b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleService.kt index 6cb1998b..a8a1a97e 100644 --- a/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleService.kt +++ b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleService.kt @@ -5,8 +5,11 @@ import dev.typetype.server.cache.CacheJson import dev.typetype.server.currentRequestId import dev.typetype.server.models.SubtitleItem import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.serialization.Serializable import okhttp3.Call import okhttp3.Callback +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -52,9 +55,92 @@ internal class YouTubeSubtitleService(private val httpClient: OkHttpClient, priv }) } } + + suspend fun fetchSubtitleContent(url: String): YouTubeSubtitleContentResult { + val tokenUrl = baseUrl.toHttpUrl().newBuilder() + .addPathSegments("subtitles/content") + .addQueryParameter("url", url) + .build() + return execute(Request.Builder().url(tokenUrl).build()) { response -> + if (response.isSuccessful) { + val body = response.body + val declaredSize = body.contentLength() + if (declaredSize > MAX_SUBTITLE_BYTES) return@execute YouTubeSubtitleContentResult.InvalidPayload + val bytes = body.byteStream().readNBytes(MAX_SUBTITLE_BYTES + 1) + if (bytes.size > MAX_SUBTITLE_BYTES || !bytes.isWebVtt()) { + YouTubeSubtitleContentResult.InvalidPayload + } else { + YouTubeSubtitleContentResult.Ready(bytes) + } + } else { + val error = runCatching { + CacheJson.decodeFromString(response.body.string()) + }.getOrNull() + when (error?.code) { + "subtitle_request_invalid" -> YouTubeSubtitleContentResult.InvalidRequest + "subtitle_track_not_found" -> YouTubeSubtitleContentResult.NotFound + "subtitle_upstream_throttled" -> YouTubeSubtitleContentResult.Throttled + "subtitle_payload_invalid" -> YouTubeSubtitleContentResult.InvalidPayload + else -> YouTubeSubtitleContentResult.Unavailable + } + } + } + } + + private suspend fun execute( + request: Request, + readResponse: (Response) -> YouTubeSubtitleContentResult, + ): YouTubeSubtitleContentResult { + val requestId = currentRequestId() + val observedRequest = request.newBuilder() + .apply { requestId?.let { header(REQUEST_ID_HEADER, it) } } + .build() + return suspendCancellableCoroutine { continuation -> + val call = httpClient.newCall(observedRequest) + continuation.invokeOnCancellation { call.cancel() } + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) continuation.resume(YouTubeSubtitleContentResult.Unavailable) + } + + override fun onResponse(call: Call, response: Response) { + val result = response.use { + runCatching { readResponse(it) }.getOrDefault(YouTubeSubtitleContentResult.Unavailable) + } + if (continuation.isActive) continuation.resume(result) + } + }) + } + } + + private companion object { + const val MAX_SUBTITLE_BYTES = 5 * 1024 * 1024 + } } internal sealed interface YouTubeSubtitleInventoryResult { data class Ready(val tracks: List) : YouTubeSubtitleInventoryResult data object Unavailable : YouTubeSubtitleInventoryResult } + +internal sealed interface YouTubeSubtitleContentResult { + data class Ready(val content: ByteArray) : YouTubeSubtitleContentResult + data object InvalidRequest : YouTubeSubtitleContentResult + data object NotFound : YouTubeSubtitleContentResult + data object Throttled : YouTubeSubtitleContentResult + data object InvalidPayload : YouTubeSubtitleContentResult + data object Unavailable : YouTubeSubtitleContentResult +} + +internal fun isYouTubeTimedTextUrl(rawUrl: String): Boolean { + val url = rawUrl.toHttpUrlOrNull() ?: return false + return url.isHttps && + (url.host == "youtube.com" || url.host.endsWith(".youtube.com")) && + url.encodedPath == "/api/timedtext" +} + +private fun ByteArray.isWebVtt(): Boolean = + toString(Charsets.UTF_8).trimStart().startsWith("WEBVTT") + +@Serializable +private data class TokenSubtitleError(val code: String? = null) From 7a49e979bffbd429ded23615dd3f7d7c046ebc98 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 09:39:36 +0200 Subject: [PATCH 08/33] test: cover YouTube subtitle proxying --- .../server/YouTubeSubtitleProxyRoutesTest.kt | 99 +++++++++++++++++++ .../services/YouTubeSubtitleServiceTest.kt | 43 +++++++- 2 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt diff --git a/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt b/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt new file mode 100644 index 00000000..b382783f --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt @@ -0,0 +1,99 @@ +package dev.typetype.server + +import dev.typetype.server.routes.proxyRoutes +import dev.typetype.server.services.ProxyService +import dev.typetype.server.services.YouTubeSubtitleService +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.serialization.json.Json +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class YouTubeSubtitleProxyRoutesTest { + private val proxyService: ProxyService = mockk(relaxed = true) + + @Test + fun `YouTube subtitle proxy returns same-origin WebVTT`() = testApplication { + val service = subtitleService("WEBVTT\n\n00:00.000 --> 00:01.000\nHello", "text/vtt") + application { + install(ContentNegotiation) { json() } + routing { proxyRoutes(proxyService, service) } + } + + val response = client.get("/proxy") { + parameter("url", "https://www.youtube.com/api/timedtext?v=video&lang=en") + } + + assertEquals(HttpStatusCode.OK, response.status) + assertTrue(response.headers[HttpHeaders.ContentType]?.startsWith("text/vtt") == true) + assertEquals("private, max-age=300", response.headers[HttpHeaders.CacheControl]) + assertTrue(response.bodyAsText().startsWith("WEBVTT")) + coVerify(exactly = 0) { proxyService.pipe(any(), any(), any()) } + } + + @Test + fun `YouTube subtitle throttle returns a typed request id error`() = testApplication { + var tokenRequest: Request? = null + val service = subtitleService( + """{"error":"throttled","code":"subtitle_upstream_throttled"}""", + code = 429, + ) { tokenRequest = it } + application { + installRequestObservability() + install(ContentNegotiation) { json(Json { encodeDefaults = true }) } + routing { proxyRoutes(proxyService, service) } + } + + val response = client.get("/proxy") { + header(REQUEST_ID_HEADER, "subtitle-request-123") + parameter("url", "https://www.youtube.com/api/timedtext?v=video&lang=en") + } + + assertEquals(HttpStatusCode.TooManyRequests, response.status) + assertEquals("subtitle-request-123", response.headers[REQUEST_ID_HEADER]) + assertEquals("subtitle-request-123", tokenRequest?.header(REQUEST_ID_HEADER)) + val body = response.bodyAsText() + assertTrue(body.contains("\"code\":\"subtitle_upstream_throttled\"")) + assertTrue(body.contains("\"requestId\":\"subtitle-request-123\"")) + coVerify(exactly = 0) { proxyService.pipe(any(), any(), any()) } + } + + private fun subtitleService( + body: String, + contentType: String = "application/json", + code: Int = 200, + observeRequest: (Request) -> Unit = {}, + ): YouTubeSubtitleService { + val client = OkHttpClient.Builder() + .addInterceptor { chain -> + observeRequest(chain.request()) + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("test") + .body(body.toResponseBody(contentType.toMediaType())) + .build() + } + .build() + return YouTubeSubtitleService(client, "http://token") + } +} diff --git a/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleServiceTest.kt b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleServiceTest.kt index 66a31a45..cce3fb18 100644 --- a/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleServiceTest.kt @@ -4,9 +4,11 @@ import kotlinx.coroutines.test.runTest import okhttp3.MediaType.Companion.toMediaType import okhttp3.OkHttpClient import okhttp3.Protocol +import okhttp3.Request import okhttp3.Response import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class YouTubeSubtitleServiceTest { @@ -40,15 +42,50 @@ class YouTubeSubtitleServiceTest { ) } - private fun service(body: String): YouTubeSubtitleService { + @Test + fun `subtitle content URL is encoded and returned as WebVTT`() = runTest { + val rawUrl = "https://www.youtube.com/api/timedtext?v=video&lang=en&tlang=fr" + var tokenRequest: Request? = null + val service = service("WEBVTT\n\n00:00.000 --> 00:01.000\nHello", "text/vtt") { + tokenRequest = it + } + + val result = service.fetchSubtitleContent(rawUrl) as YouTubeSubtitleContentResult.Ready + + assertTrue(result.content.decodeToString().startsWith("WEBVTT")) + assertEquals(rawUrl, tokenRequest?.url?.queryParameter("url")) + } + + @Test + fun `Token throttle is preserved as a typed subtitle result`() = runTest { + var calls = 0 + val service = service( + """{"error":"throttled","code":"subtitle_upstream_throttled"}""", + code = 429, + ) { calls++ } + + assertEquals( + YouTubeSubtitleContentResult.Throttled, + service.fetchSubtitleContent("https://www.youtube.com/api/timedtext?v=video&lang=en"), + ) + assertEquals(1, calls) + } + + private fun service( + body: String, + contentType: String = "application/json", + code: Int = 200, + observeRequest: (Request) -> Unit = {}, + ): YouTubeSubtitleService { val client = OkHttpClient.Builder() .addInterceptor { chain -> + observeRequest(chain.request()) Response.Builder() .request(chain.request()) .protocol(Protocol.HTTP_1_1) - .code(200) + .code(code) .message("test") - .body(body.toResponseBody("application/json".toMediaType())) + .body(body.toResponseBody(contentType.toMediaType())) .build() } .build() From bf75639113a8607e19fd1b00bb188dce7c5f9da8 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 09:39:39 +0200 Subject: [PATCH 09/33] chore: update PipePipeExtractor revision --- build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle.kts b/build.gradle.kts index ae7b3e35..f47efe67 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,7 +38,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.6.1") - implementation("com.github.Priveetee.PipePipeExtractor:extractor:21ab9b6ac415d23ef88df19c5ad126c7952bb543") + implementation("com.github.Priveetee.PipePipeExtractor:extractor:c0184bf9b8534076492f0d233697af5ed5f015fe") compileOnly("com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751") implementation("org.json:json:20260719") implementation("com.squareup.okhttp3:okhttp:5.4.0") From a886f2b04a2e8ef3c57db4870fd1ae556c4e8d15 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 15:44:44 +0200 Subject: [PATCH 10/33] fix: enforce blocked content on stream routes --- openapi/paths/streams.yaml | 16 +++ .../server/ApplicationStreamRoutes.kt | 1 + .../server/routes/StreamRouteDependencies.kt | 2 + .../typetype/server/routes/StreamRoutes.kt | 23 +++- .../server/services/AllowedChannelsService.kt | 4 + .../server/services/BlockedContentFilters.kt | 7 + .../server/services/BlockedContentProfile.kt | 29 ++++- .../server/BlockedStreamRoutesTest.kt | 120 ++++++++++++++++++ .../services/BlockedContentProfileTest.kt | 64 ++++++++++ 9 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 src/test/kotlin/dev/typetype/server/BlockedStreamRoutesTest.kt create mode 100644 src/test/kotlin/dev/typetype/server/services/BlockedContentProfileTest.kt diff --git a/openapi/paths/streams.yaml b/openapi/paths/streams.yaml index 743342b7..33aad372 100644 --- a/openapi/paths/streams.yaml +++ b/openapi/paths/streams.yaml @@ -19,6 +19,10 @@ YoutubeSabrStreams: $ref: ../components/streams.yaml#/StreamResponse '400': $ref: ../components/common.yaml#/JsonError + '401': + $ref: ../components/common.yaml#/JsonError + '403': + $ref: ../components/common.yaml#/JsonError '422': $ref: ../components/common.yaml#/JsonError YoutubeSabrBootstrap: @@ -42,6 +46,10 @@ YoutubeSabrBootstrap: $ref: ../components/streams.yaml#/StreamResponse '400': $ref: ../components/common.yaml#/JsonError + '401': + $ref: ../components/common.yaml#/JsonError + '403': + $ref: ../components/common.yaml#/JsonError '422': $ref: ../components/common.yaml#/JsonError NicoNicoStreams: @@ -62,6 +70,10 @@ NicoNicoStreams: $ref: ../components/streams.yaml#/StreamResponse '400': $ref: ../components/common.yaml#/JsonError + '401': + $ref: ../components/common.yaml#/JsonError + '403': + $ref: ../components/common.yaml#/JsonError '422': $ref: ../components/common.yaml#/JsonError BiliBiliStreams: @@ -82,6 +94,10 @@ BiliBiliStreams: $ref: ../components/streams.yaml#/StreamResponse '400': $ref: ../components/common.yaml#/JsonError + '401': + $ref: ../components/common.yaml#/JsonError + '403': + $ref: ../components/common.yaml#/JsonError '422': $ref: ../components/common.yaml#/JsonError AudioOnly: diff --git a/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt b/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt index dc8fc948..a626a815 100644 --- a/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt @@ -27,6 +27,7 @@ internal fun Route.installStreamRoutes( authService = authService, accessControlService = svc.accessControlService, adminSettingsService = adminSettingsService, + blockedService = svc.blockedService, publicHlsManifestTokenService = svc.publicHlsManifestTokenService, sabrStreamContractFilter = { url, data -> data.withPlayableSabrStreams(url, svc.sabrSessionStore) }, ) diff --git a/src/main/kotlin/dev/typetype/server/routes/StreamRouteDependencies.kt b/src/main/kotlin/dev/typetype/server/routes/StreamRouteDependencies.kt index 04bcb785..6598572a 100644 --- a/src/main/kotlin/dev/typetype/server/routes/StreamRouteDependencies.kt +++ b/src/main/kotlin/dev/typetype/server/routes/StreamRouteDependencies.kt @@ -4,12 +4,14 @@ import dev.typetype.server.models.StreamResponse import dev.typetype.server.services.AccessControlService import dev.typetype.server.services.AdminSettingsService import dev.typetype.server.services.AuthService +import dev.typetype.server.services.BlockedService import dev.typetype.server.services.PublicHlsManifestTokenService internal data class StreamRouteDependencies( val authService: AuthService?, val accessControlService: AccessControlService?, val adminSettingsService: AdminSettingsService?, + val blockedService: BlockedService?, val publicHlsManifestTokenService: PublicHlsManifestTokenService?, val sabrStreamContractFilter: (suspend (String, StreamResponse) -> StreamResponse)?, ) diff --git a/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt index 872aa9f2..81f60626 100644 --- a/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/StreamRoutes.kt @@ -6,8 +6,11 @@ import dev.typetype.server.models.StreamResponse import dev.typetype.server.services.AccessControlService import dev.typetype.server.services.AdminSettingsService import dev.typetype.server.services.AuthService +import dev.typetype.server.services.BlockedContentProfile +import dev.typetype.server.services.BlockedService import dev.typetype.server.services.PublicHlsManifestTokenService import dev.typetype.server.services.StreamService +import dev.typetype.server.services.filterBlocked import dev.typetype.server.services.filterAllowed import dev.typetype.server.services.withSabrManifestUrls import io.ktor.http.HttpHeaders @@ -24,6 +27,7 @@ fun Route.streamRoutes( authService: AuthService? = null, accessControlService: AccessControlService? = null, adminSettingsService: AdminSettingsService? = null, + blockedService: BlockedService? = null, publicHlsManifestTokenService: PublicHlsManifestTokenService? = null, nicoNicoStreamService: StreamService = streamService, bilibiliStreamService: StreamService = streamService, @@ -34,6 +38,7 @@ fun Route.streamRoutes( authService = authService, accessControlService = accessControlService, adminSettingsService = adminSettingsService, + blockedService = blockedService, publicHlsManifestTokenService = publicHlsManifestTokenService, sabrStreamContractFilter = sabrStreamContractFilter, ) @@ -69,11 +74,26 @@ private fun Route.streamRoute( dependencies.adminSettingsService, ) ?: return@get val accessProfile = access.profile + val blockedProfile = access.userId + ?.let { dependencies.blockedService?.profileFor(it) } + ?: BlockedContentProfile.empty + if (blockedProfile.blocksVideo(url)) { + return@get call.respond( + HttpStatusCode.Forbidden, + ErrorResponse("Video is blocked", "content_blocked"), + ) + } when (val result = streamService.getStreamInfo(url)) { is ExtractionResult.Success -> { if (!accessProfile.allowsUploader(result.data.uploaderUrl, result.data.uploaderName)) { return@get call.respond(HttpStatusCode.Forbidden, ErrorResponse("Channel is not allowed")) } + if (!blockedProfile.allowsRequestedVideo(url, result.data.uploaderUrl, result.data.uploaderName)) { + return@get call.respond( + HttpStatusCode.Forbidden, + ErrorResponse("Channel is blocked", "content_blocked"), + ) + } val selected = if (deliveryMode.isSabr()) { result.data.withSabrManifestUrls().onlySabrStreams() } else { @@ -81,6 +101,7 @@ private fun Route.streamRoute( } val filtered = selected .filterAllowed(accessProfile) + .filterBlocked(blockedProfile) .withSignedPublicHlsUrl( deliveryMode.isSabr() && selected.isLive || access.userId != null && !access.allowGuest, dependencies.publicHlsManifestTokenService, @@ -98,7 +119,7 @@ private fun Route.streamRoute( } call.response.headers.append( HttpHeaders.CacheControl, - if (accessProfile.enabled) AUTHENTICATED_STREAMS_CACHE_CONTROL else STREAMS_CACHE_CONTROL, + if (access.userId != null) AUTHENTICATED_STREAMS_CACHE_CONTROL else STREAMS_CACHE_CONTROL, ) call.respond(data) } diff --git a/src/main/kotlin/dev/typetype/server/services/AllowedChannelsService.kt b/src/main/kotlin/dev/typetype/server/services/AllowedChannelsService.kt index 31bbfc4d..fba4aea1 100644 --- a/src/main/kotlin/dev/typetype/server/services/AllowedChannelsService.kt +++ b/src/main/kotlin/dev/typetype/server/services/AllowedChannelsService.kt @@ -86,3 +86,7 @@ internal fun normalizeChannelKey(value: String): String = value.trim() .substringBefore('?') .removeSuffix("/") .replace("http://", "https://") + .replace( + Regex("^https://(?:www\\.|m\\.|music\\.)youtube\\.com", RegexOption.IGNORE_CASE), + "https://youtube.com", + ) diff --git a/src/main/kotlin/dev/typetype/server/services/BlockedContentFilters.kt b/src/main/kotlin/dev/typetype/server/services/BlockedContentFilters.kt index fcba974b..7dee1388 100644 --- a/src/main/kotlin/dev/typetype/server/services/BlockedContentFilters.kt +++ b/src/main/kotlin/dev/typetype/server/services/BlockedContentFilters.kt @@ -1,9 +1,16 @@ package dev.typetype.server.services import dev.typetype.server.models.SearchPageResponse +import dev.typetype.server.models.StreamResponse internal fun SearchPageResponse.filterBlocked(profile: BlockedContentProfile): SearchPageResponse = copy( items = items.filter { profile.allowsVideo(it.url, it.title, it.uploaderUrl, it.uploaderName) }, channels = channels.filter { profile.allowsChannel(url = it.url, name = it.name) }, playlists = playlists.filter { profile.allowsChannel(url = "", name = it.uploaderName) }, ) + +internal fun StreamResponse.filterBlocked(profile: BlockedContentProfile): StreamResponse = copy( + relatedStreams = relatedStreams.filter { + profile.allowsVideo(it.url, it.title, it.uploaderUrl, it.uploaderName) + }, +) diff --git a/src/main/kotlin/dev/typetype/server/services/BlockedContentProfile.kt b/src/main/kotlin/dev/typetype/server/services/BlockedContentProfile.kt index 07581898..9595097f 100644 --- a/src/main/kotlin/dev/typetype/server/services/BlockedContentProfile.kt +++ b/src/main/kotlin/dev/typetype/server/services/BlockedContentProfile.kt @@ -9,20 +9,37 @@ data class BlockedContentProfile( val keywords: List, ) { fun allowsVideo(url: String, title: String, uploaderUrl: String, uploaderName: String): Boolean = - videos.none { normalizeUrl(it.url) == normalizeUrl(url) } && + !blocksVideo(url) && keywords.none { containsBlockedKeyword(title, it.keyword) } && - allowsChannel(uploaderUrl, uploaderName) + !blocksChannel(uploaderUrl, uploaderName) - fun allowsChannel(url: String, name: String): Boolean = channels.none { item -> + fun allowsRequestedVideo(url: String, uploaderUrl: String, uploaderName: String): Boolean = + !blocksVideo(url) && !blocksChannel(uploaderUrl, uploaderName) + + fun blocksVideo(url: String): Boolean { + val normalized = normalizeBlockedVideoKey(url) + return normalized.isNotBlank() && videos.any { normalizeBlockedVideoKey(it.url) == normalized } + } + + fun blocksChannel(url: String, name: String): Boolean = channels.any { item -> val blockedUrl = normalizeChannelKey(item.url) - val blockedName = item.name?.trim().orEmpty() + val blockedName = normalizeBlockedKeyword(item.name.orEmpty()) blockedUrl.isNotBlank() && blockedUrl == normalizeChannelKey(url) || - blockedName.isNotBlank() && blockedName.equals(name.trim(), ignoreCase = true) + blockedName.isNotBlank() && blockedName == normalizeBlockedKeyword(name) } + fun allowsChannel(url: String, name: String): Boolean = !blocksChannel(url, name) + companion object { val empty = BlockedContentProfile(videos = emptyList(), channels = emptyList(), keywords = emptyList()) } } -private fun normalizeUrl(value: String): String = value.trim().trimEnd('/') +private val YOUTUBE_VIDEO_ID = + Regex("(?:[?&]v=|/(?:shorts|embed|live)/|youtu\\.be/)([A-Za-z0-9_-]{6,})", RegexOption.IGNORE_CASE) + +internal fun normalizeBlockedVideoKey(value: String): String { + val trimmed = value.trim() + val youtubeId = YOUTUBE_VIDEO_ID.find(trimmed)?.groupValues?.get(1) + return youtubeId?.let { "youtube:video:$it" } ?: normalizeChannelKey(trimmed) +} diff --git a/src/test/kotlin/dev/typetype/server/BlockedStreamRoutesTest.kt b/src/test/kotlin/dev/typetype/server/BlockedStreamRoutesTest.kt new file mode 100644 index 00000000..15cb41be --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/BlockedStreamRoutesTest.kt @@ -0,0 +1,120 @@ +package dev.typetype.server + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.routes.streamRoutes +import dev.typetype.server.services.AuthService +import dev.typetype.server.services.BlockedService +import dev.typetype.server.services.StreamService +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.client.statement.bodyAsText +import io.ktor.http.HttpHeaders +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.install +import io.ktor.server.plugins.contentnegotiation.ContentNegotiation +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import io.mockk.coEvery +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class BlockedStreamRoutesTest { + private val auth = AuthService.fixed(TEST_USER_ID) + private val blocked = BlockedService() + private val streams: StreamService = mockk() + + companion object { + @BeforeAll + @JvmStatic + fun initDb() { + TestDatabase.setup() + } + } + + @BeforeEach + fun clean() { + TestDatabase.truncateAll() + } + + @Test + fun `blocked video cannot be extracted through an equivalent url`() = testApplication { + blocked.addVideo(TEST_USER_ID, "https://www.youtube.com/watch?v=blocked-video") + application { installRoutes() } + + val response = get("https://youtu.be/blocked-video") + + assertEquals(HttpStatusCode.Forbidden, response.status) + assertTrue(response.bodyAsText().contains("\"code\":\"content_blocked\"")) + } + + @Test + fun `blocked channel cannot be extracted`() = testApplication { + blocked.addChannel(TEST_USER_ID, "https://www.youtube.com/@blocked", "Blocked") + coEvery { streams.getStreamInfo(any()) } returns ExtractionResult.Success( + sabrResponse().copy( + uploaderName = "Blocked", + uploaderUrl = "https://m.youtube.com/@blocked/", + ), + ) + application { installRoutes() } + + val response = get("https://youtube.com/watch?v=channel-video") + + assertEquals(HttpStatusCode.Forbidden, response.status) + assertTrue(response.bodyAsText().contains("\"code\":\"content_blocked\"")) + } + + @Test + fun `authenticated stream filters blocked related videos and disables shared caching`() = + testApplication { + blocked.addChannel(TEST_USER_ID, "https://youtube.com/@blocked", "Blocked") + coEvery { streams.getStreamInfo(any()) } returns ExtractionResult.Success( + sabrResponse().copy( + relatedStreams = listOf( + testVideoItem().copy( + title = "Hidden", + uploaderName = "Blocked", + uploaderUrl = "https://www.youtube.com/@blocked", + ), + testVideoItem().copy(title = "Visible", url = "https://youtube.com/watch?v=visible-video"), + ), + ), + ) + application { installRoutes() } + + val response = get("https://youtube.com/watch?v=source-video") + val body = response.bodyAsText() + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("no-store", response.headers[HttpHeaders.CacheControl]) + assertFalse(body.contains("\"title\":\"Hidden\"")) + assertTrue(body.contains("\"title\":\"Visible\"")) + } + + private fun io.ktor.server.application.Application.installRoutes() { + install(ContentNegotiation) { json() } + routing { + streamRoutes( + streamService = streams, + authService = auth, + blockedService = blocked, + ) + } + } + + private suspend fun io.ktor.server.testing.ApplicationTestBuilder.get(url: String) = + client.get("/streams/youtube/sabr?url=${java.net.URLEncoder.encode(url, Charsets.UTF_8)}") { + headers.append(HttpHeaders.Authorization, "Bearer test-jwt") + } + + private fun sabrResponse() = testStreamResponse( + videoOnlyStreams = listOf(testVideoStream().copy(deliveryMethod = "sabr")), + audioStreams = listOf(testAudioStream(deliveryMethod = "sabr")), + ) +} diff --git a/src/test/kotlin/dev/typetype/server/services/BlockedContentProfileTest.kt b/src/test/kotlin/dev/typetype/server/services/BlockedContentProfileTest.kt new file mode 100644 index 00000000..198aa1d3 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/BlockedContentProfileTest.kt @@ -0,0 +1,64 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.BlockedItem +import dev.typetype.server.models.BlockedKeywordItem +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class BlockedContentProfileTest { + @Test + fun `matches equivalent youtube video urls`() { + val profile = profile( + videos = listOf( + BlockedItem(url = "https://www.youtube.com/watch?v=AbC_123-xyZ", blockedAt = 1), + ), + ) + + assertTrue(profile.blocksVideo("https://youtu.be/AbC_123-xyZ?t=12")) + assertTrue(profile.blocksVideo("https://m.youtube.com/shorts/AbC_123-xyZ")) + } + + @Test + fun `matches youtube channel hosts and normalized names`() { + val profile = profile( + channels = listOf( + BlockedItem( + url = "http://www.youtube.com/@Example/?view=0", + name = "Test Channel", + blockedAt = 1, + ), + ), + ) + + assertTrue(profile.blocksChannel("https://m.youtube.com/@Example/", "Other")) + assertTrue(profile.blocksChannel("", "test channel")) + } + + @Test + fun `filters video url channel and keyword independently`() { + val profile = profile( + videos = listOf(BlockedItem(url = "https://youtube.com/watch?v=blocked-video", blockedAt = 1)), + channels = listOf(BlockedItem("https://youtube.com/@blocked", "Blocked", null, 1)), + keywords = listOf(BlockedKeywordItem("spoiler", 1)), + ) + + assertFalse(profile.allowsVideo("https://youtu.be/blocked-video", "One", "", "")) + assertFalse( + profile.allowsVideo( + "https://youtube.com/watch?v=other-video", + "Two", + "https://www.youtube.com/@blocked", + "Blocked", + ), + ) + assertFalse(profile.allowsVideo("https://youtube.com/watch?v=third-video", "A spoiler", "", "")) + assertTrue(profile.allowsVideo("https://youtube.com/watch?v=visible-video", "Visible", "", "")) + } + + private fun profile( + videos: List = emptyList(), + channels: List = emptyList(), + keywords: List = emptyList(), + ) = BlockedContentProfile(videos, channels, keywords) +} From f2cf5453ae0580c6595c4e89f6b2e5c5a5cdc137 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 16:25:41 +0200 Subject: [PATCH 11/33] fix: preserve typed subtitle throttling errors --- src/main/kotlin/dev/typetype/server/Plugins.kt | 14 +++++++++++++- .../server/routes/YouTubeSubtitleResponse.kt | 2 ++ .../server/YouTubeSubtitleProxyRoutesTest.kt | 2 ++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/typetype/server/Plugins.kt b/src/main/kotlin/dev/typetype/server/Plugins.kt index 8e575846..63c900d5 100644 --- a/src/main/kotlin/dev/typetype/server/Plugins.kt +++ b/src/main/kotlin/dev/typetype/server/Plugins.kt @@ -7,6 +7,7 @@ import io.ktor.http.HttpMethod import io.ktor.http.HttpStatusCode import io.ktor.serialization.kotlinx.json.json import io.ktor.server.application.Application +import io.ktor.server.application.ApplicationCall import io.ktor.server.application.install import io.ktor.server.plugins.calllogging.CallLogging import io.ktor.server.plugins.contentnegotiation.ContentNegotiation @@ -17,6 +18,7 @@ import io.ktor.server.plugins.statuspages.StatusPages import io.ktor.server.request.path import io.ktor.server.response.respond import io.ktor.server.websocket.WebSockets +import io.ktor.util.AttributeKey import kotlinx.serialization.json.Json import org.slf4j.LoggerFactory import kotlin.time.Duration.Companion.minutes @@ -30,6 +32,7 @@ private const val PROXY_STORYBOARD_RATE_LIMIT = 1200 private const val USER_DATA_RATE_LIMIT = 120 private const val MAX_WEBSOCKET_FRAME_BYTES = 64L * 1024L * 1024L private val RATE_LIMIT_WINDOW = 1.minutes +private val preserveTooManyRequestsBodyAttribute = AttributeKey("preserveTooManyRequestsBody") val EXTRACTION_ZONE = RateLimitName("extraction") val DEARROW_ZONE = RateLimitName("dearrow") @@ -40,7 +43,6 @@ val PROXY_STORYBOARD_ZONE = RateLimitName("proxy-storyboard") val USER_DATA_ZONE = RateLimitName("user-data") fun Application.configurePlugins(authService: AuthService) { - val log = LoggerFactory.getLogger("RequestLogger") installRequestObservability() install(CallLogging) { format(::requestLogLine) @@ -94,8 +96,18 @@ fun Application.configurePlugins(authService: AuthService) { requestKey { call -> userDataRateLimitKey(call, authService) } } } + configureStatusPages() +} + +internal fun ApplicationCall.preserveTooManyRequestsBody() { + attributes.put(preserveTooManyRequestsBodyAttribute, Unit) +} + +internal fun Application.configureStatusPages() { + val log = LoggerFactory.getLogger("RequestLogger") install(StatusPages) { status(HttpStatusCode.TooManyRequests) { call, status -> + if (call.attributes.contains(preserveTooManyRequestsBodyAttribute)) return@status if (!call.response.headers.contains(HttpHeaders.RetryAfter)) call.response.headers.append(HttpHeaders.RetryAfter, "60") call.respond(status, ErrorResponse("Too many requests", "rate_limited")) } diff --git a/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt b/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt index 47a403f6..22789878 100644 --- a/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt +++ b/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt @@ -1,6 +1,7 @@ package dev.typetype.server.routes import dev.typetype.server.models.ErrorResponse +import dev.typetype.server.preserveTooManyRequestsBody import dev.typetype.server.services.YouTubeSubtitleContentResult import io.ktor.http.ContentType import io.ktor.http.HttpStatusCode @@ -47,5 +48,6 @@ private suspend fun ApplicationCall.respondSubtitleError( message: String, code: String, ) { + if (status == HttpStatusCode.TooManyRequests) preserveTooManyRequestsBody() respond(status, ErrorResponse(message, code)) } diff --git a/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt b/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt index b382783f..e1c682b3 100644 --- a/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt @@ -59,6 +59,7 @@ class YouTubeSubtitleProxyRoutesTest { application { installRequestObservability() install(ContentNegotiation) { json(Json { encodeDefaults = true }) } + configureStatusPages() routing { proxyRoutes(proxyService, service) } } @@ -68,6 +69,7 @@ class YouTubeSubtitleProxyRoutesTest { } assertEquals(HttpStatusCode.TooManyRequests, response.status) + assertEquals(null, response.headers[HttpHeaders.RetryAfter]) assertEquals("subtitle-request-123", response.headers[REQUEST_ID_HEADER]) assertEquals("subtitle-request-123", tokenRequest?.header(REQUEST_ID_HEADER)) val body = response.bodyAsText() From d61748c38aa101d431d18311325cbc8355588bf2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 19:04:20 +0200 Subject: [PATCH 12/33] feat: add grouped search filter API --- openapi.yaml | 2 + openapi/components/media.yaml | 12 ++- openapi/paths/search.yaml | 16 ++++ .../server/models/SearchFilterGroup.kt | 11 +++ .../server/models/SearchFilterOption.kt | 1 + .../server/models/SearchFiltersResponse.kt | 1 + .../typetype/server/routes/SearchRoutes.kt | 10 +- .../server/services/CachedSearchService.kt | 18 +++- .../server/services/PipePipeSearchService.kt | 91 +++++++++++++------ .../server/services/SearchFilterMappers.kt | 71 +++++++++++++-- .../typetype/server/services/SearchService.kt | 14 ++- .../services/YoutubeScopedSearchService.kt | 12 ++- 12 files changed, 204 insertions(+), 55 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/server/models/SearchFilterGroup.kt diff --git a/openapi.yaml b/openapi.yaml index 2cebeb7e..253722a9 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -111,6 +111,8 @@ components: $ref: ./openapi/components/media.yaml#/SearchPageResponse SearchFilterOption: $ref: ./openapi/components/media.yaml#/SearchFilterOption + SearchFilterGroup: + $ref: ./openapi/components/media.yaml#/SearchFilterGroup SearchFiltersResponse: $ref: ./openapi/components/media.yaml#/SearchFiltersResponse ChannelResultItem: diff --git a/openapi/components/media.yaml b/openapi/components/media.yaml index 3ee436bc..8bc028b9 100644 --- a/openapi/components/media.yaml +++ b/openapi/components/media.yaml @@ -49,12 +49,22 @@ SearchFilterOption: properties: value: { type: string } label: { type: string } + isDefault: { type: boolean, default: false } +SearchFilterGroup: + type: object + required: [key, label, multiSelect, options] + properties: + key: { type: string } + label: { type: string } + multiSelect: { type: boolean } + options: { type: array, items: { $ref: '#/SearchFilterOption' } } SearchFiltersResponse: type: object - required: [contentFilters, sortFilters] + required: [contentFilters, sortFilters, filterGroups] properties: contentFilters: { type: array, items: { $ref: '#/SearchFilterOption' } } sortFilters: { type: array, items: { $ref: '#/SearchFilterOption' } } + filterGroups: { type: array, items: { $ref: '#/SearchFilterGroup' }, default: [] } ChannelResponse: type: object required: [name, description, avatarUrl, bannerUrl, subscriberCount, isVerified, videos, nextpage] diff --git a/openapi/paths/search.yaml b/openapi/paths/search.yaml index 398ccc9d..815b8949 100644 --- a/openapi/paths/search.yaml +++ b/openapi/paths/search.yaml @@ -22,7 +22,18 @@ Search: - name: sortFilter in: query required: false + deprecated: true + description: Legacy single-filter parameter. Use `filter` for grouped selections. schema: { type: string } + - name: filter + in: query + required: false + description: Repeat for each selected search filter. + style: form + explode: true + schema: + type: array + items: { type: string } responses: '200': description: Search page. @@ -46,6 +57,11 @@ SearchFilters: in: query required: true schema: { type: integer, enum: [0, 3, 4, 5, 6] } + - name: contentFilter + in: query + required: false + description: Return filter groups applicable to this content type. + schema: { type: string } responses: '200': description: Search filter capabilities. diff --git a/src/main/kotlin/dev/typetype/server/models/SearchFilterGroup.kt b/src/main/kotlin/dev/typetype/server/models/SearchFilterGroup.kt new file mode 100644 index 00000000..c8b179c6 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/models/SearchFilterGroup.kt @@ -0,0 +1,11 @@ +package dev.typetype.server.models + +import kotlinx.serialization.Serializable + +@Serializable +data class SearchFilterGroup( + val key: String, + val label: String, + val multiSelect: Boolean, + val options: List, +) diff --git a/src/main/kotlin/dev/typetype/server/models/SearchFilterOption.kt b/src/main/kotlin/dev/typetype/server/models/SearchFilterOption.kt index 8c0b03e4..4ca1a82f 100644 --- a/src/main/kotlin/dev/typetype/server/models/SearchFilterOption.kt +++ b/src/main/kotlin/dev/typetype/server/models/SearchFilterOption.kt @@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable data class SearchFilterOption( val value: String, val label: String, + val isDefault: Boolean = false, ) diff --git a/src/main/kotlin/dev/typetype/server/models/SearchFiltersResponse.kt b/src/main/kotlin/dev/typetype/server/models/SearchFiltersResponse.kt index cd96b2f9..ea492363 100644 --- a/src/main/kotlin/dev/typetype/server/models/SearchFiltersResponse.kt +++ b/src/main/kotlin/dev/typetype/server/models/SearchFiltersResponse.kt @@ -6,4 +6,5 @@ import kotlinx.serialization.Serializable data class SearchFiltersResponse( val contentFilters: List, val sortFilters: List, + val filterGroups: List, ) diff --git a/src/main/kotlin/dev/typetype/server/routes/SearchRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/SearchRoutes.kt index 5cc053dd..e5313e24 100644 --- a/src/main/kotlin/dev/typetype/server/routes/SearchRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/SearchRoutes.kt @@ -29,7 +29,8 @@ fun Route.searchRoutes( ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing or invalid 'service' parameter")) if (serviceId !in VALID_SERVICE_IDS) return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid 'service' parameter")) - when (val result = searchService.filters(serviceId = serviceId)) { + val contentFilter = call.request.queryParameters["contentFilter"] + when (val result = searchService.filters(serviceId = serviceId, contentFilter = contentFilter)) { is ExtractionResult.Success -> call.respond(result.data) is ExtractionResult.BadRequest -> call.respond(HttpStatusCode.BadRequest, ErrorResponse(result.message)) is ExtractionResult.Failure -> call.respond(HttpStatusCode.UnprocessableEntity, ErrorResponse(result.message)) @@ -44,11 +45,14 @@ fun Route.searchRoutes( return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid 'service' parameter")) val nextpage = call.request.queryParameters["nextpage"] val contentFilter = call.request.queryParameters["contentFilter"] - val sortFilter = call.request.queryParameters["sortFilter"] + val filters = buildList { + addAll(call.request.queryParameters.getAll("filter").orEmpty().filter(String::isNotBlank)) + call.request.queryParameters["sortFilter"]?.takeIf(String::isNotBlank)?.let(::add) + }.distinct() val access = call.accessProfileOrRespond(authService, accessControlService, adminSettingsService) ?: return@get val blocked = access.userId?.let { blockedService?.profileFor(it) } ?: BlockedContentProfile.empty - when (val result = searchService.search(query = query, serviceId = serviceId, nextpage = nextpage, contentFilter = contentFilter, sortFilter = sortFilter)) { + when (val result = searchService.search(query, serviceId, nextpage, contentFilter, filters)) { is ExtractionResult.Success -> call.respond(result.data.filterAllowed(access.profile).filterBlocked(blocked)) is ExtractionResult.BadRequest -> call.respond(HttpStatusCode.BadRequest, ErrorResponse(result.message)) is ExtractionResult.Failure -> call.respond(HttpStatusCode.UnprocessableEntity, ErrorResponse(result.message)) diff --git a/src/main/kotlin/dev/typetype/server/services/CachedSearchService.kt b/src/main/kotlin/dev/typetype/server/services/CachedSearchService.kt index 26249ae8..82cf5ee4 100644 --- a/src/main/kotlin/dev/typetype/server/services/CachedSearchService.kt +++ b/src/main/kotlin/dev/typetype/server/services/CachedSearchService.kt @@ -15,14 +15,24 @@ class CachedSearchService( serviceId: Int, nextpage: String?, contentFilter: String?, - sortFilter: String?, + filters: List, ): ExtractionResult = PublicExtractionCache.getOrLoad( cache = cache, area = "search", - key = PublicCacheKey.of("search-v2", serviceId.toString(), query, nextpage, contentFilter, sortFilter), + key = PublicCacheKey.of( + "search-v3", + serviceId.toString(), + query, + nextpage, + contentFilter, + *filters.sorted().toTypedArray(), + ), serializer = SearchPageResponse.serializer(), ttlSeconds = { PublicCachePolicy.searchTtl(serviceId, nextpage) }, - ) { delegate.search(query, serviceId, nextpage, contentFilter, sortFilter) } + ) { delegate.search(query, serviceId, nextpage, contentFilter, filters) } - override suspend fun filters(serviceId: Int): ExtractionResult = delegate.filters(serviceId) + override suspend fun filters( + serviceId: Int, + contentFilter: String?, + ): ExtractionResult = delegate.filters(serviceId, contentFilter) } diff --git a/src/main/kotlin/dev/typetype/server/services/PipePipeSearchService.kt b/src/main/kotlin/dev/typetype/server/services/PipePipeSearchService.kt index a78eee6d..a94cfb61 100644 --- a/src/main/kotlin/dev/typetype/server/services/PipePipeSearchService.kt +++ b/src/main/kotlin/dev/typetype/server/services/PipePipeSearchService.kt @@ -7,7 +7,10 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.linkhandler.SearchQueryHandlerFactory import org.schabi.newpipe.extractor.search.SearchInfo +import org.schabi.newpipe.extractor.search.filter.Filter +import org.schabi.newpipe.extractor.search.filter.FilterItem class PipePipeSearchService : SearchService { @@ -16,35 +19,46 @@ class PipePipeSearchService : SearchService { serviceId: Int, nextpage: String?, contentFilter: String?, - sortFilter: String?, + filters: List, ): ExtractionResult = withContext(Dispatchers.IO) { - if (serviceId == YOUTUBE_SERVICE_ID && sortFilter != null) { - return@withContext ExtractionResult.BadRequest("Sort filters are unavailable for YouTube") - } - val page = if (nextpage != null) { runCatching { nextpage.toPage() } .getOrElse { return@withContext ExtractionResult.BadRequest("Invalid nextpage cursor") } } else null + val service = runCatching { NewPipe.getService(serviceId) } + .getOrElse { return@withContext ExtractionResult.Failure(it.message ?: "Search failed") } + val factory = service.searchQHFactory + val selectedContentFilter = when (val resolution = factory.resolveContentFilter(contentFilter)) { + is SearchFilterResolution.Valid -> resolution.items + is SearchFilterResolution.Invalid -> + return@withContext ExtractionResult.BadRequest(resolution.message) + } + val availableFilters = factory.filtersFor(selectedContentFilter) + val selectedFilters = if (filters.isEmpty()) { + availableFilters.defaultSearchFilters() + } else { + when (val resolution = availableFilters.resolveSearchFilters(filters)) { + is SearchFilterResolution.Valid -> resolution.items + is SearchFilterResolution.Invalid -> + return@withContext ExtractionResult.BadRequest(resolution.message) + } + } + val queryHandler = runCatching { + factory.fromQuery( + query, + selectedContentFilter.ifEmpty { null }, + selectedFilters.ifEmpty { null }, + ) + }.getOrElse { + return@withContext ExtractionResult.Failure(it.message ?: "Search failed") + } + val contentKind = contentFilter.toSearchContentKind(selectedContentFilter) + runCatching { withExtractionRetry { withTimeout(30_000L) { - val service = NewPipe.getService(serviceId) - val factory = service.searchQHFactory - val selectedContentFilter = if (contentFilter == null) { - factory.availableContentFilter.defaultSearchFilter() - } else { - factory.availableContentFilter.findSearchFilter(contentFilter) - } - val selectedSortFilter = factory.availableSortFilter.findSearchFilter(sortFilter) - val queryHandler = factory.fromQuery( - query, - selectedContentFilter.ifEmpty { null }, - selectedSortFilter.ifEmpty { null }, - ) - val contentKind = contentFilter.toSearchContentKind(selectedContentFilter) if (page == null) { SearchInfo.getInfo(service, queryHandler).toSearchPageResponse().filteredBy(contentKind) } else { @@ -54,29 +68,46 @@ class PipePipeSearchService : SearchService { } }.fold( onSuccess = { ExtractionResult.Success(it) }, - onFailure = { ExtractionResult.Failure(it.message ?: "Search failed") } + onFailure = { ExtractionResult.Failure(it.message ?: "Search failed") }, ) } - override suspend fun filters(serviceId: Int): ExtractionResult = + override suspend fun filters( + serviceId: Int, + contentFilter: String?, + ): ExtractionResult = withContext(Dispatchers.IO) { + val factory = runCatching { NewPipe.getService(serviceId).searchQHFactory } + .getOrElse { return@withContext ExtractionResult.Failure(it.message ?: "Search filters failed") } + val selectedContentFilter = when (val resolution = factory.resolveContentFilter(contentFilter)) { + is SearchFilterResolution.Valid -> resolution.items + is SearchFilterResolution.Invalid -> + return@withContext ExtractionResult.BadRequest(resolution.message) + } + val availableFilters = factory.filtersFor(selectedContentFilter) runCatching { - val factory = NewPipe.getService(serviceId).searchQHFactory SearchFiltersResponse( - contentFilters = factory.availableContentFilter.toSearchFilterOptions(), - sortFilters = if (serviceId == YOUTUBE_SERVICE_ID) { - emptyList() - } else { - factory.availableSortFilter.toSearchFilterOptions() - }, + contentFilters = factory.availableContentFilter.toSearchContentFilterOptions(), + sortFilters = availableFilters.toSearchFilterOptions(), + filterGroups = availableFilters.toSearchFilterGroups(), ) }.fold( onSuccess = { ExtractionResult.Success(it) }, onFailure = { ExtractionResult.Failure(it.message ?: "Search filters failed") }, ) } +} - private companion object { - const val YOUTUBE_SERVICE_ID = 0 +private fun SearchQueryHandlerFactory.resolveContentFilter(value: String?): SearchFilterResolution { + if (value == null) return SearchFilterResolution.Valid(availableContentFilter.defaultSearchFilter()) + val selected = availableContentFilter.findSearchFilter(value) + return if (selected.isEmpty()) { + SearchFilterResolution.Invalid("Unknown content filter") + } else { + SearchFilterResolution.Valid(selected) } } + +private fun SearchQueryHandlerFactory.filtersFor(contentFilter: List): Filter? = + contentFilter.firstOrNull()?.let { getContentFilterSortFilterVariant(it.identifier) } + ?: availableSortFilter diff --git a/src/main/kotlin/dev/typetype/server/services/SearchFilterMappers.kt b/src/main/kotlin/dev/typetype/server/services/SearchFilterMappers.kt index 8050188a..80223e8e 100644 --- a/src/main/kotlin/dev/typetype/server/services/SearchFilterMappers.kt +++ b/src/main/kotlin/dev/typetype/server/services/SearchFilterMappers.kt @@ -1,20 +1,62 @@ package dev.typetype.server.services +import dev.typetype.server.models.SearchFilterGroup import dev.typetype.server.models.SearchFilterOption import org.schabi.newpipe.extractor.search.filter.Filter +import org.schabi.newpipe.extractor.search.filter.FilterGroup import org.schabi.newpipe.extractor.search.filter.FilterItem +internal sealed interface SearchFilterResolution { + data class Valid(val items: List) : SearchFilterResolution + data class Invalid(val message: String) : SearchFilterResolution +} + +internal fun Filter?.toSearchContentFilterOptions(): List = entries() + .mapIndexed { index, entry -> entry.toOption(isDefault = index == 0, includeGroup = true) } + internal fun Filter?.toSearchFilterOptions(): List = this?.filterGroups - ?.flatMap { group -> group.filterItems.map { item -> item.toSearchFilterOption(group.groupName.orEmpty()) } } - ?: emptyList() + ?.flatMap { group -> + group.filterItems.mapIndexed { index, item -> + FilterEntry(group, item).toOption(group.onlyOneCheckable && index == 0, includeGroup = true) + } + }.orEmpty() + +internal fun Filter?.toSearchFilterGroups(): List = this?.filterGroups + ?.map { group -> + SearchFilterGroup( + key = "${group.groupName.orEmpty()}|${group.identifier}", + label = group.groupName.orEmpty(), + multiSelect = !group.onlyOneCheckable, + options = group.filterItems.mapIndexed { index, item -> + FilterEntry(group, item).toOption(group.onlyOneCheckable && index == 0) + }, + ) + }.orEmpty() internal fun Filter?.findSearchFilter(value: String?): List = value?.let { raw -> - this?.filterGroups - ?.flatMap { group -> group.filterItems.map { group.groupName.orEmpty() to it } } - ?.firstOrNull { (groupName, item) -> item.searchFilterValue(groupName) == raw } - ?.let { listOf(it.second) } + entries().firstOrNull { it.value == raw }?.let { listOf(it.item) } } ?: emptyList() +internal fun Filter?.resolveSearchFilters(values: List): SearchFilterResolution { + if (values.isEmpty()) return SearchFilterResolution.Valid(emptyList()) + val requested = values.toSet() + val selected = entries().filter { it.value in requested } + if (selected.size != requested.size) return SearchFilterResolution.Invalid("Unknown search filter") + val conflict = selected.groupBy { it.group.identifier } + .values + .firstOrNull { entries -> entries.first().group.onlyOneCheckable && entries.size > 1 } + if (conflict != null) { + val groupName = conflict.first().group.groupName.orEmpty().ifBlank { "filter" } + return SearchFilterResolution.Invalid("Only one '$groupName' filter can be selected") + } + return SearchFilterResolution.Valid(selected.map(FilterEntry::item)) +} + +internal fun Filter?.defaultSearchFilters(): List = this?.filterGroups + ?.filter(FilterGroup::onlyOneCheckable) + ?.mapNotNull { it.filterItems.firstOrNull() } + .orEmpty() + internal fun Filter?.defaultSearchFilter(): List = this?.filterGroups ?.firstOrNull() ?.filterItems @@ -22,10 +64,19 @@ internal fun Filter?.defaultSearchFilter(): List = this?.filterGroup ?.let { listOf(it) } ?: emptyList() -private fun FilterItem.toSearchFilterOption(groupName: String): SearchFilterOption = SearchFilterOption( - value = searchFilterValue(groupName), - label = if (groupName.isBlank()) name else "$groupName: $name", -) +private data class FilterEntry(val group: FilterGroup, val item: FilterItem) { + val value: String = item.searchFilterValue(group.groupName.orEmpty()) + + fun toOption(isDefault: Boolean, includeGroup: Boolean = false): SearchFilterOption { + val groupName = group.groupName.orEmpty() + val label = if (includeGroup && groupName.isNotBlank()) "$groupName: ${item.name}" else item.name + return SearchFilterOption(value = value, label = label, isDefault = isDefault) + } +} + +private fun Filter?.entries(): List = this?.filterGroups + ?.flatMap { group -> group.filterItems.map { item -> FilterEntry(group, item) } } + .orEmpty() private fun FilterItem.searchFilterValue(groupName: String): String = listOf(groupName, identifier.toString(), name).joinToString("|") diff --git a/src/main/kotlin/dev/typetype/server/services/SearchService.kt b/src/main/kotlin/dev/typetype/server/services/SearchService.kt index 0cc413c2..16353179 100644 --- a/src/main/kotlin/dev/typetype/server/services/SearchService.kt +++ b/src/main/kotlin/dev/typetype/server/services/SearchService.kt @@ -5,6 +5,16 @@ import dev.typetype.server.models.SearchFiltersResponse import dev.typetype.server.models.SearchPageResponse interface SearchService { - suspend fun search(query: String, serviceId: Int, nextpage: String? = null, contentFilter: String? = null, sortFilter: String? = null): ExtractionResult - suspend fun filters(serviceId: Int): ExtractionResult + suspend fun search( + query: String, + serviceId: Int, + nextpage: String? = null, + contentFilter: String? = null, + filters: List = emptyList(), + ): ExtractionResult + + suspend fun filters( + serviceId: Int, + contentFilter: String? = null, + ): ExtractionResult } diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeScopedSearchService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedSearchService.kt index 92f001a1..cf0af470 100644 --- a/src/main/kotlin/dev/typetype/server/services/YoutubeScopedSearchService.kt +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeScopedSearchService.kt @@ -10,16 +10,18 @@ class YoutubeScopedSearchService(private val delegate: SearchService) : SearchSe serviceId: Int, nextpage: String?, contentFilter: String?, - sortFilter: String?, + filters: List, ): ExtractionResult = if (serviceId == YOUTUBE_SERVICE_ID) { YoutubeSessionTokenScope.withoutCredentials { - delegate.search(query, serviceId, nextpage, contentFilter, sortFilter) + delegate.search(query, serviceId, nextpage, contentFilter, filters) } } else { - delegate.search(query, serviceId, nextpage, contentFilter, sortFilter) + delegate.search(query, serviceId, nextpage, contentFilter, filters) } - override suspend fun filters(serviceId: Int): ExtractionResult = - delegate.filters(serviceId) + override suspend fun filters( + serviceId: Int, + contentFilter: String?, + ): ExtractionResult = delegate.filters(serviceId, contentFilter) } From fc863394a87871f894d01901b33fa859c9038496 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 19:04:24 +0200 Subject: [PATCH 13/33] test: cover grouped search filters --- .../server/SearchFilterMappersTest.kt | 114 ++++++++++++++++++ .../server/SearchFiltersRoutesTest.kt | 34 +++++- 2 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 src/test/kotlin/dev/typetype/server/SearchFilterMappersTest.kt diff --git a/src/test/kotlin/dev/typetype/server/SearchFilterMappersTest.kt b/src/test/kotlin/dev/typetype/server/SearchFilterMappersTest.kt new file mode 100644 index 00000000..1356f083 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/SearchFilterMappersTest.kt @@ -0,0 +1,114 @@ +package dev.typetype.server + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.services.PipePipeSearchService +import dev.typetype.server.services.SearchFilterResolution +import dev.typetype.server.services.VALID_SERVICE_IDS +import dev.typetype.server.services.defaultSearchFilter +import dev.typetype.server.services.defaultSearchFilters +import dev.typetype.server.services.findSearchFilter +import dev.typetype.server.services.resolveSearchFilters +import dev.typetype.server.services.toSearchContentFilterOptions +import dev.typetype.server.services.toSearchFilterGroups +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.services.youtube.search.filter.YoutubeSearchSortFilter +import org.schabi.newpipe.extractor.services.youtube.search.filter.protobuf.DateFilter +import org.schabi.newpipe.extractor.services.youtube.search.filter.protobuf.LenFilter +import org.schabi.newpipe.extractor.services.youtube.search.filter.protobuf.SortOrder +import org.schabi.newpipe.extractor.services.youtube.search.filter.protobuf.TypeFilter + +class SearchFilterMappersTest { + private val factory = NewPipe.getService(0).searchQHFactory + private val content = factory.availableContentFilter.defaultSearchFilter() + private val filters = factory.getContentFilterSortFilterVariant(content.first().identifier) + + @Test + fun `YouTube filters expose exclusive and multi-select groups`() { + val groups = filters.toSearchFilterGroups() + + assertEquals(listOf("sortby", "upload_date", "duration", "features"), groups.map { it.label }) + assertFalse(groups.first { it.label == "duration" }.multiSelect) + assertTrue(groups.first { it.label == "features" }.multiSelect) + assertTrue(groups.first { it.label == "sortby" }.options.first().isDefault) + assertFalse(groups.first { it.label == "features" }.options.first().isDefault) + } + + @Test + fun `YouTube filters resolve selections across groups`() { + val groups = filters.toSearchFilterGroups() + val values = listOf( + groups.option("sortby", "sort_view"), + groups.option("upload_date", "past_week"), + groups.option("duration", "short_video"), + groups.option("features", "HD"), + groups.option("features", "Subtitles"), + ) + + val resolution = filters.resolveSearchFilters(values) + + assertInstanceOf(SearchFilterResolution.Valid::class.java, resolution) + assertEquals(5, (resolution as SearchFilterResolution.Valid).items.size) + val url = factory.fromQuery("kotlin", content, resolution.items).url + val request = YoutubeSearchSortFilter().decodeSp(url.substringAfter("&sp=")) + + assertEquals(SortOrder.views.value.toLong(), request.sorted) + assertEquals(DateFilter.week.value.toLong(), request.filter.date) + assertEquals(LenFilter.duration_short.value.toLong(), request.filter.length) + assertTrue(request.filter.is_hd) + assertTrue(request.filter.subtitles) + } + + @Test + fun `YouTube content filters retain their type with default selections`() { + val contentFilters = factory.availableContentFilter + val videoValue = contentFilters.toSearchContentFilterOptions() + .first { it.label.endsWith("videos") } + .value + val videoContent = contentFilters.findSearchFilter(videoValue) + val videoFilters = factory.getContentFilterSortFilterVariant(videoContent.first().identifier) + + val url = factory.fromQuery("kotlin", videoContent, videoFilters.defaultSearchFilters()).url + val request = YoutubeSearchSortFilter().decodeSp(url.substringAfter("&sp=")) + + assertEquals(TypeFilter.video.value.toLong(), request.filter.type) + } + + @Test + fun `YouTube filters reject conflicting exclusive selections`() { + val sortOptions = filters.toSearchFilterGroups().first { it.label == "sortby" }.options + + val resolution = filters.resolveSearchFilters(sortOptions.take(2).map { it.value }) + + assertInstanceOf(SearchFilterResolution.Invalid::class.java, resolution) + assertTrue((resolution as SearchFilterResolution.Invalid).message.contains("sortby")) + } + + @Test + fun `YouTube filters reject unknown selections`() { + val resolution = filters.resolveSearchFilters(listOf("unknown")) + + assertInstanceOf(SearchFilterResolution.Invalid::class.java, resolution) + } + + @Test + fun `all supported services expose filter capabilities`() = runTest { + val service = PipePipeSearchService() + + VALID_SERVICE_IDS.forEach { serviceId -> + assertInstanceOf( + ExtractionResult.Success::class.java, + service.filters(serviceId, null), + "service $serviceId", + ) + } + } +} + +private fun List.option(group: String, label: String): String = + first { it.label == group }.options.first { it.label == label }.value diff --git a/src/test/kotlin/dev/typetype/server/SearchFiltersRoutesTest.kt b/src/test/kotlin/dev/typetype/server/SearchFiltersRoutesTest.kt index a9f6e521..2d020801 100644 --- a/src/test/kotlin/dev/typetype/server/SearchFiltersRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/SearchFiltersRoutesTest.kt @@ -1,6 +1,7 @@ package dev.typetype.server import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.SearchFilterGroup import dev.typetype.server.models.SearchFilterOption import dev.typetype.server.models.SearchFiltersResponse import dev.typetype.server.models.SearchPageResponse @@ -26,10 +27,18 @@ class SearchFiltersRoutesTest { @Test fun `GET search filters returns supported filters`() = withApp { - coEvery { searchService.filters(0) } returns ExtractionResult.Success( + coEvery { searchService.filters(0, null) } returns ExtractionResult.Success( SearchFiltersResponse( contentFilters = listOf(SearchFilterOption(value = "type|1|Videos", label = "Type: Videos")), sortFilters = listOf(SearchFilterOption(value = "sort|2|Upload date", label = "Sort: Upload date")), + filterGroups = listOf( + SearchFilterGroup( + key = "sort|2", + label = "Sort", + multiSelect = false, + options = listOf(SearchFilterOption(value = "sort|2|Upload date", label = "Upload date")), + ) + ), ) ) val response = client.get("/search/filters?service=0") @@ -37,6 +46,23 @@ class SearchFiltersRoutesTest { assertEquals(HttpStatusCode.OK, response.status) assertTrue(body.contains("contentFilters")) assertTrue(body.contains("Upload date")) + assertTrue(body.contains("filterGroups")) + } + + @Test + fun `GET search filters passes selected content filter`() = withApp { + coEvery { searchService.filters(0, "content") } returns ExtractionResult.Success( + SearchFiltersResponse( + contentFilters = emptyList(), + sortFilters = emptyList(), + filterGroups = emptyList(), + ) + ) + + val response = client.get("/search/filters?service=0&contentFilter=content") + + assertEquals(HttpStatusCode.OK, response.status) + coVerify { searchService.filters(0, "content") } } @Test @@ -44,9 +70,11 @@ class SearchFiltersRoutesTest { coEvery { searchService.search(any(), any(), any(), any(), any()) } returns ExtractionResult.Success( SearchPageResponse(items = emptyList(), nextpage = null, searchSuggestion = null, isCorrectedSearch = false) ) - val response = client.get("/search?q=test&service=0&contentFilter=content&sortFilter=sort") + val response = client.get( + "/search?q=test&service=0&contentFilter=content&filter=views&filter=short&sortFilter=legacy" + ) assertEquals(HttpStatusCode.OK, response.status) - coVerify { searchService.search("test", 0, null, "content", "sort") } + coVerify { searchService.search("test", 0, null, "content", listOf("views", "short", "legacy")) } } private fun withApp(block: suspend io.ktor.server.testing.ApplicationTestBuilder.() -> Unit) = testApplication { From de5ec9e962c676d1082698682181e4155ad9641e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 20:49:09 +0200 Subject: [PATCH 14/33] fix: keep YouTube requests on configured proxy --- .../dev/typetype/server/downloader/YoutubeProxySelector.kt | 2 +- .../kotlin/dev/typetype/server/YoutubeProxySelectorTest.kt | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/main/kotlin/dev/typetype/server/downloader/YoutubeProxySelector.kt b/src/main/kotlin/dev/typetype/server/downloader/YoutubeProxySelector.kt index 24bca222..d9016d4b 100644 --- a/src/main/kotlin/dev/typetype/server/downloader/YoutubeProxySelector.kt +++ b/src/main/kotlin/dev/typetype/server/downloader/YoutubeProxySelector.kt @@ -11,7 +11,7 @@ internal class YoutubeProxySelector private constructor( private val proxy: Proxy, ) : ProxySelector() { override fun select(uri: URI): List = - if (isYoutubeHost(uri.host)) listOf(proxy, Proxy.NO_PROXY) else DIRECT + if (isYoutubeHost(uri.host)) listOf(proxy) else DIRECT override fun connectFailed(uri: URI, socketAddress: SocketAddress, exception: IOException) = Unit diff --git a/src/test/kotlin/dev/typetype/server/YoutubeProxySelectorTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeProxySelectorTest.kt index e41bd942..fb0f5be5 100644 --- a/src/test/kotlin/dev/typetype/server/YoutubeProxySelectorTest.kt +++ b/src/test/kotlin/dev/typetype/server/YoutubeProxySelectorTest.kt @@ -22,11 +22,9 @@ class YoutubeProxySelectorTest { "https://i.ytimg.com/vi/id/hqdefault.jpg", "https://yt3.googleusercontent.com/avatar", ).forEach { url -> - val proxies = selector.select(URI(url)) - val proxy = proxies.first() + val proxy = selector.select(URI(url)).single() assertEquals(Proxy.Type.HTTP, proxy.type()) assertEquals(InetSocketAddress.createUnresolved("proxy.internal", 8080), proxy.address()) - assertEquals(Proxy.NO_PROXY, proxies.last()) } } From 0b4efe555bd2a21a278ef43c70ac5eb831270deb Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 21:46:33 +0200 Subject: [PATCH 15/33] fix: bootstrap SABR before restoring playback position --- .../server/services/SabrSessionPumpLoop.kt | 6 +++ .../server/services/SabrStartupBootstrap.kt | 1 + .../services/SabrSeekRepositionPumpTest.kt | 41 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/src/main/kotlin/dev/typetype/server/services/SabrSessionPumpLoop.kt b/src/main/kotlin/dev/typetype/server/services/SabrSessionPumpLoop.kt index 44ef828e..1f3953ed 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrSessionPumpLoop.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrSessionPumpLoop.kt @@ -69,6 +69,12 @@ internal class SabrSessionPumpLoop( runtime: SabrPumpRuntime, ): Boolean { preparePumpEviction(holder) + if (holder.prepareStartupBootstrapPump()) { + holder.setPlaybackState(SabrPlaybackState.REQUESTING) + pumpOnce(holder, localization, runtime) + holder.setPlaybackState(SabrPlaybackState.IDLE) + return true + } holder.consumeRefetch()?.let { request -> if (holder.session.isBeyondEnd(request) && !holder.isFutureLiveRequest(request)) { holder.clearSegmentDemand(request) diff --git a/src/main/kotlin/dev/typetype/server/services/SabrStartupBootstrap.kt b/src/main/kotlin/dev/typetype/server/services/SabrStartupBootstrap.kt index 32fd1f51..47797f98 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrStartupBootstrap.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrStartupBootstrap.kt @@ -1,6 +1,7 @@ package dev.typetype.server.services internal fun SabrSessionHolder.prepareStartupBootstrapPump(): Boolean { + if (expectsLive() || !hasPendingSeek()) return false if (session.requestNumber == 0) { session.streamState.setPlayerTimeMs(0L) return true diff --git a/src/test/kotlin/dev/typetype/server/services/SabrSeekRepositionPumpTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrSeekRepositionPumpTest.kt index 4edb2ff2..ca8ae740 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrSeekRepositionPumpTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrSeekRepositionPumpTest.kt @@ -3,6 +3,7 @@ package dev.typetype.server.services import io.mockk.every import io.mockk.mockk import io.mockk.verify +import io.mockk.verifyOrder import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -59,6 +60,7 @@ class SabrSeekRepositionPumpTest { val request = SabrSegmentRequest.media(video, 24) val session = mockk(relaxed = true) every { session.streamState } returns mockk(relaxed = true) + every { session.requestNumber } returns 2 every { session.getCachedSegment(any()) } returns null every { session.pumpOnceStreamingForDemand(any(), request) } returns mockk(relaxed = true) val holder = holder(session, audio, video) @@ -78,6 +80,45 @@ class SabrSeekRepositionPumpTest { } } + @Test + fun `cold playback bootstraps before applying saved position`() = runTest { + SabrSegmentDemandTracker.clearAll() + try { + val audio = format(140, true) + val video = format(137, false) + val request = SabrSegmentRequest.media(video, 180) + val session = mockk(relaxed = true) + val state = mockk(relaxed = true) + var requestNumber = 0 + every { session.streamState } returns state + every { session.requestNumber } answers { requestNumber } + every { session.getCachedSegment(any()) } returns null + every { session.pumpOnceStreaming(any()) } answers { + requestNumber = 1 + 2 + } + every { state.getMaxSegment(audio) } returns 1 + every { state.getMaxSegment(video) } returns 1 + every { session.pumpOnceStreamingForDemand(any(), request) } returns mockk(relaxed = true) + val holder = holder(session, audio, video) + holder.setRequestedSeekTimeMs(900_000L) + holder.requestSegmentDemand(request) + holder.requestForwardSeek(request) + var rounds = 0 + + SabrSessionPumpLoop().run({ rounds++ < 2 }, holder, intervalMs = 0L) + + verifyOrder { + state.setPlayerTimeMs(0L) + session.pumpOnceStreaming(any()) + session.prepareForForwardJump(request, 900_000L) + session.pumpOnceStreamingForDemand(any(), request) + } + } finally { + SabrSegmentDemandTracker.clearAll() + } + } + @Test fun `forward seek discovered beyond end is discarded`() = runTest { SabrSegmentDemandTracker.clearAll() From 64aa66f795ad80cf8918c0da5ba9a947082634d9 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sat, 1 Aug 2026 22:04:31 +0200 Subject: [PATCH 16/33] fix: recover rejected SABR attestations --- .../typetype/server/routes/SabrPlaybackRecovery.kt | 5 ++++- .../server/routes/SabrPlaybackRecoveryTest.kt | 13 +++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt b/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt index 8f7f312f..1880d371 100644 --- a/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt +++ b/src/main/kotlin/dev/typetype/server/routes/SabrPlaybackRecovery.kt @@ -17,7 +17,10 @@ internal class SabrPlaybackRecovery(private val sessionStore: SabrSessionStore) sessionStore.invalidatePlaybackInfo(holder.key.videoId) return RETRY_FRESH_SESSION } - if (failure.contains("protected no-media")) { + if ( + failure.contains("protected no-media") || + failure.contains("attestation required", ignoreCase = true) + ) { sessionStore.recoverProtectedPlaybackInfo(holder) return RETRY_FRESH_SESSION } diff --git a/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt b/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt index 181e1aa8..0cc7b3d7 100644 --- a/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt +++ b/src/test/kotlin/dev/typetype/server/routes/SabrPlaybackRecoveryTest.kt @@ -102,4 +102,17 @@ class SabrPlaybackRecoveryTest { assertEquals(emptyList(), recovery.retryVideoItags()) coVerify(exactly = 1) { store.recoverProtectedPlaybackInfo(holder) } } + + @Test + fun `attestation rejection refreshes context and requests fresh session`() = runTest { + val holder = mockk() + val store = mockk() + every { holder.terminalFailure() } returns + "SABR error: SABR attestation required: status=3, policy=true" + every { holder.key } returns SabrSessionKey("video", "user", 140, null, 137, 900_000L) + coEvery { store.recoverProtectedPlaybackInfo(holder) } returns Unit + + assertEquals("retry_fresh_session", SabrPlaybackRecovery(store).action(holder)) + coVerify(exactly = 1) { store.recoverProtectedPlaybackInfo(holder) } + } } From 167513726710035c7c8bce05204b252a8d7536fc Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 2 Aug 2026 00:08:55 +0200 Subject: [PATCH 17/33] feat: define YouTube subtitle delivery contract --- .../PipePipeYouTubeSubtitleResolver.kt | 82 +++++++++++++ .../services/YouTubeSubtitleContentFetcher.kt | 85 +++++++++++++ .../services/YouTubeSubtitleContract.kt | 113 ++++++++++++++++++ 3 files changed, 280 insertions(+) create mode 100644 src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcher.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleContract.kt diff --git a/src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt b/src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt new file mode 100644 index 00000000..f00fb98f --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt @@ -0,0 +1,82 @@ +package dev.typetype.server.services + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.exceptions.ReCaptchaException +import org.schabi.newpipe.extractor.stream.SubtitlesStream + +internal fun interface YouTubeSubtitleTrackResolver { + suspend fun resolve(selection: YouTubeSubtitleSelection): YouTubeSubtitleResolution +} + +internal class PipePipeYouTubeSubtitleResolver : YouTubeSubtitleTrackResolver { + override suspend fun resolve(selection: YouTubeSubtitleSelection): YouTubeSubtitleResolution = try { + withTimeout(RESOLUTION_TIMEOUT_MS) { + runPipePipeCall { + val url = "https://www.youtube.com/watch?v=${selection.videoId}" + val service = NewPipe.getServiceByUrl(url) + val extractor = service.getStreamExtractor(service.streamLHFactory.fromUrl(url)) + extractor.fetchPage() + val track = extractor.getSubtitles(selection.format.mediaFormat) + .firstOrNull { it.matchesYouTubeSubtitle(selection) } + ?: return@runPipePipeCall YouTubeSubtitleResolution.NotFound + val content = track.contentForYouTubeSubtitle(selection) + ?: return@runPipePipeCall YouTubeSubtitleResolution.NotFound + YouTubeSubtitleResolution.Ready( + ResolvedYouTubeSubtitle( + content = content, + isUrl = track.isUrl, + isLive = extractor.streamType.toApiStreamType().isLiveContentType(), + ), + ) + } + } + } catch (_: TimeoutCancellationException) { + YouTubeSubtitleResolution.Unavailable + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + if (error.hasCause()) { + YouTubeSubtitleResolution.Throttled + } else { + YouTubeSubtitleResolution.Unavailable + } + } + + private inline fun Throwable.hasCause(): Boolean = + generateSequence(this) { it.cause }.any { it is T } + + private companion object { + const val RESOLUTION_TIMEOUT_MS = 30_000L + } +} + +internal fun SubtitlesStream.matchesYouTubeSubtitle(selection: YouTubeSubtitleSelection): Boolean { + val url = content.toHttpUrlOrNull() + val trackLanguage = languageTag.orEmpty() + val sourceLanguage = url?.queryParameter("lang") ?: trackLanguage + val requestedSource = selection.sourceLanguage ?: selection.language + if (!sourceLanguage.equals(requestedSource, ignoreCase = true) && + !trackLanguage.equals(selection.language, ignoreCase = true) + ) return false + val auto = isAutoGenerated || url?.queryParameter("kind") == "asr" || + url?.queryParameter("vssId")?.startsWith("a.") == true + if (auto != (selection.variant == YouTubeSubtitleVariant.Auto)) return false + return selection.trackName == null || url?.queryParameter("name") == selection.trackName +} + +internal fun SubtitlesStream.contentForYouTubeSubtitle(selection: YouTubeSubtitleSelection): String? { + if (!isUrl) return content.takeIf { selection.translationLanguage == null } + val url = content.toHttpUrlOrNull()?.takeIf { isYouTubeTimedTextUrl(it.toString()) } ?: return null + return url.newBuilder() + .setQueryParameter("fmt", selection.format.value) + .apply { + if (selection.translationLanguage == null) removeAllQueryParameters("tlang") + else setQueryParameter("tlang", selection.translationLanguage) + } + .build() + .toString() +} diff --git a/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcher.kt b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcher.kt new file mode 100644 index 00000000..7fcf0f98 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcher.kt @@ -0,0 +1,85 @@ +package dev.typetype.server.services + +import dev.typetype.server.REQUEST_ID_HEADER +import dev.typetype.server.currentRequestId +import kotlinx.coroutines.suspendCancellableCoroutine +import okhttp3.Call +import okhttp3.Callback +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import java.io.IOException +import kotlin.coroutines.resume + +internal fun interface YouTubeSubtitleContentFetcher { + suspend fun fetch(url: String, format: YouTubeSubtitleFormat): YouTubeSubtitleFetchResult +} + +internal class OkHttpYouTubeSubtitleContentFetcher( + private val client: OkHttpClient, +) : YouTubeSubtitleContentFetcher { + override suspend fun fetch(url: String, format: YouTubeSubtitleFormat): YouTubeSubtitleFetchResult { + if (!isYouTubeTimedTextUrl(url)) return YouTubeSubtitleFetchResult.Unavailable + val request = Request.Builder() + .url(url) + .header("Accept", "${format.contentType},*/*;q=0.8") + .header("Origin", YOUTUBE_ORIGIN) + .header("Referer", "$YOUTUBE_ORIGIN/") + .header("User-Agent", OkHttpProxyService.BROWSER_USER_AGENT) + .apply { currentRequestId()?.let { header(REQUEST_ID_HEADER, it) } } + .build() + return execute(request, format) + } + + private suspend fun execute( + request: Request, + format: YouTubeSubtitleFormat, + ): YouTubeSubtitleFetchResult = suspendCancellableCoroutine { continuation -> + val call = client.newCall(request) + continuation.invokeOnCancellation { call.cancel() } + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) continuation.resume(YouTubeSubtitleFetchResult.Unavailable) + } + + override fun onResponse(call: Call, response: Response) { + val result = runCatching { response.use { readResponse(it, format) } } + .getOrDefault(YouTubeSubtitleFetchResult.Unavailable) + if (continuation.isActive) continuation.resume(result) + } + }) + } + + private fun readResponse(response: Response, format: YouTubeSubtitleFormat): YouTubeSubtitleFetchResult { + if (response.code == 429) return YouTubeSubtitleFetchResult.Throttled + if (response.code == 403 || response.code == 404 || response.code == 410) { + return YouTubeSubtitleFetchResult.Expired + } + if (!response.isSuccessful) return YouTubeSubtitleFetchResult.Unavailable + val body = response.body + if (body.contentLength() > MAX_SUBTITLE_BYTES) return YouTubeSubtitleFetchResult.InvalidPayload + return runCatching { body.byteStream().readNBytes(MAX_SUBTITLE_BYTES + 1) } + .fold( + onSuccess = { bytes -> + if (isValidSubtitlePayload(bytes, format)) YouTubeSubtitleFetchResult.Ready(bytes) + else YouTubeSubtitleFetchResult.InvalidPayload + }, + onFailure = { YouTubeSubtitleFetchResult.Unavailable }, + ) + } + + private companion object { + const val YOUTUBE_ORIGIN = "https://m.youtube.com" + } +} + +internal const val MAX_SUBTITLE_BYTES = 5 * 1024 * 1024 + +internal fun isValidSubtitlePayload(content: ByteArray, format: YouTubeSubtitleFormat): Boolean { + if (content.isEmpty() || content.size > MAX_SUBTITLE_BYTES) return false + val text = content.toString(Charsets.UTF_8).trimStart() + return when (format) { + YouTubeSubtitleFormat.Vtt -> text.startsWith("WEBVTT") + YouTubeSubtitleFormat.Ttml -> text.startsWith(" Date: Sun, 2 Aug 2026 00:09:00 +0200 Subject: [PATCH 18/33] feat: cache YouTube subtitle delivery --- .../server/services/YouTubeSubtitleCache.kt | 75 +++++++++++++++++ .../YouTubeSubtitleDeliveryService.kt | 82 +++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleCache.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleDeliveryService.kt diff --git a/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleCache.kt b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleCache.kt new file mode 100644 index 00000000..03297d24 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleCache.kt @@ -0,0 +1,75 @@ +package dev.typetype.server.services + +import dev.typetype.server.cache.CacheJson +import dev.typetype.server.cache.CacheService +import kotlinx.serialization.Serializable +import java.security.MessageDigest +import java.time.Duration +import java.util.HexFormat + +internal class YouTubeSubtitleCache(private val sharedCache: CacheService?) { + private val vod = BoundedExpiringCache( + maxEntries = 128, + maxWeight = MAX_MEMORY_BYTES, + ttl = Duration.ofSeconds(VOD_TTL_SECONDS), + weigher = { it.content.size.toLong() }, + ) + private val live = BoundedExpiringCache( + maxEntries = 32, + maxWeight = MAX_LIVE_MEMORY_BYTES, + ttl = Duration.ofSeconds(LIVE_TTL_SECONDS), + weigher = { it.content.size.toLong() }, + ) + + suspend fun get(selection: YouTubeSubtitleSelection): YouTubeSubtitleContentResult.Ready? { + val key = selection.key() + vod.get(key)?.let { return it } + live.get(key)?.let { return it } + val encoded = runCatching { sharedCache?.get(key) }.getOrNull() ?: return null + val cached = runCatching { CacheJson.decodeFromString(encoded) }.getOrNull() + ?: return null + val ready = cached.toReady(selection.format) ?: return null + memoryCache(cached.isLive).put(key, ready) + return ready + } + + suspend fun put(selection: YouTubeSubtitleSelection, value: YouTubeSubtitleContentResult.Ready) { + val key = selection.key() + memoryCache(value.isLive).put(key, value) + val cached = CachedYouTubeSubtitle(value.content.toString(Charsets.UTF_8), value.isLive) + runCatching { + sharedCache?.set( + key, + CacheJson.encodeToString(CachedYouTubeSubtitle.serializer(), cached), + if (value.isLive) LIVE_TTL_SECONDS else VOD_TTL_SECONDS, + ) + } + } + + private fun memoryCache(isLive: Boolean) = if (isLive) live else vod + + private fun YouTubeSubtitleSelection.key(): String { + val digest = MessageDigest.getInstance("SHA-256").digest(cacheKey.encodeToByteArray()) + return "$CACHE_PREFIX:${HexFormat.of().formatHex(digest)}" + } + + @Serializable + private data class CachedYouTubeSubtitle(val content: String, val isLive: Boolean) { + fun toReady(format: YouTubeSubtitleFormat): YouTubeSubtitleContentResult.Ready? { + val bytes = content.encodeToByteArray() + return if (isValidSubtitlePayload(bytes, format)) { + YouTubeSubtitleContentResult.Ready(bytes, format, isLive) + } else { + null + } + } + } + + private companion object { + const val CACHE_PREFIX = "youtube-subtitle:v1" + const val VOD_TTL_SECONDS = 21_600L + const val LIVE_TTL_SECONDS = 5L + const val MAX_MEMORY_BYTES = 64L * 1024 * 1024 + const val MAX_LIVE_MEMORY_BYTES = 8L * 1024 * 1024 + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleDeliveryService.kt b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleDeliveryService.kt new file mode 100644 index 00000000..1e5ff2a2 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleDeliveryService.kt @@ -0,0 +1,82 @@ +package dev.typetype.server.services + +import kotlinx.coroutines.CompletableDeferred +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.Semaphore + +internal class YouTubeSubtitleDeliveryService( + private val resolver: YouTubeSubtitleTrackResolver, + private val fetcher: YouTubeSubtitleContentFetcher, + private val cache: YouTubeSubtitleCache, +) { + private val inFlight = ConcurrentHashMap>() + private val upstreamPermits = Semaphore(MAX_CONCURRENT_UPSTREAM_REQUESTS) + + suspend fun fetch(selection: YouTubeSubtitleSelection): YouTubeSubtitleContentResult { + cache.get(selection)?.let { return it } + val pending = CompletableDeferred() + val existing = inFlight.putIfAbsent(selection.cacheKey, pending) + if (existing != null) return existing.await() + if (!upstreamPermits.tryAcquire()) { + pending.complete(YouTubeSubtitleContentResult.Unavailable) + inFlight.remove(selection.cacheKey, pending) + return YouTubeSubtitleContentResult.Unavailable + } + return try { + val result = cache.get(selection) ?: load(selection) + if (result is YouTubeSubtitleContentResult.Ready) cache.put(selection, result) + pending.complete(result) + result + } catch (error: Throwable) { + pending.completeExceptionally(error) + throw error + } finally { + inFlight.remove(selection.cacheKey, pending) + upstreamPermits.release() + } + } + + private suspend fun load(selection: YouTubeSubtitleSelection): YouTubeSubtitleContentResult { + repeat(MAX_RESOLUTION_ATTEMPTS) { attempt -> + when (val resolution = resolver.resolve(selection)) { + YouTubeSubtitleResolution.NotFound -> return YouTubeSubtitleContentResult.NotFound + YouTubeSubtitleResolution.Throttled -> return YouTubeSubtitleContentResult.Throttled + YouTubeSubtitleResolution.Unavailable -> return YouTubeSubtitleContentResult.Unavailable + is YouTubeSubtitleResolution.Ready -> { + val result = fetchResolved(resolution.track, selection.format) + if (result == YouTubeSubtitleFetchResult.Expired && attempt < MAX_RESOLUTION_ATTEMPTS - 1) { + return@repeat + } + return result.toContentResult(selection.format, resolution.track.isLive) + } + } + } + return YouTubeSubtitleContentResult.Expired + } + + private suspend fun fetchResolved( + track: ResolvedYouTubeSubtitle, + format: YouTubeSubtitleFormat, + ): YouTubeSubtitleFetchResult { + if (track.isUrl) return fetcher.fetch(track.content, format) + val bytes = track.content.encodeToByteArray() + return if (isValidSubtitlePayload(bytes, format)) YouTubeSubtitleFetchResult.Ready(bytes) + else YouTubeSubtitleFetchResult.InvalidPayload + } + + private fun YouTubeSubtitleFetchResult.toContentResult( + format: YouTubeSubtitleFormat, + isLive: Boolean, + ): YouTubeSubtitleContentResult = when (this) { + is YouTubeSubtitleFetchResult.Ready -> YouTubeSubtitleContentResult.Ready(content, format, isLive) + YouTubeSubtitleFetchResult.Expired -> YouTubeSubtitleContentResult.Expired + YouTubeSubtitleFetchResult.Throttled -> YouTubeSubtitleContentResult.Throttled + YouTubeSubtitleFetchResult.InvalidPayload -> YouTubeSubtitleContentResult.InvalidPayload + YouTubeSubtitleFetchResult.Unavailable -> YouTubeSubtitleContentResult.Unavailable + } + + private companion object { + const val MAX_RESOLUTION_ATTEMPTS = 2 + const val MAX_CONCURRENT_UPSTREAM_REQUESTS = 64 + } +} From 49152b5f76e25a7f5dd2e1ff8a1c1350a31be900 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 2 Aug 2026 00:09:06 +0200 Subject: [PATCH 19/33] feat: serve YouTube subtitles through Server --- openapi.yaml | 1 + openapi/paths/proxy.yaml | 7 +- openapi/paths/subtitles.yaml | 62 ++++++++++++++ .../server/ApplicationStreamRoutes.kt | 4 +- .../server/ExtractionServiceRegistry.kt | 9 ++ .../dev/typetype/server/ServiceRegistry.kt | 2 +- .../dev/typetype/server/routes/ProxyRoutes.kt | 10 ++- .../server/routes/YouTubeSubtitleResponse.kt | 23 ++++- .../server/routes/YouTubeSubtitleRoutes.kt | 44 ++++++++++ .../server/services/YouTubeSubtitleService.kt | 85 ------------------- 10 files changed, 152 insertions(+), 95 deletions(-) create mode 100644 openapi/paths/subtitles.yaml create mode 100644 src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleRoutes.kt diff --git a/openapi.yaml b/openapi.yaml index 253722a9..00121b5c 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -25,6 +25,7 @@ paths: /streams/bilibili: { $ref: ./openapi/paths/streams.yaml#/BiliBiliStreams } /streams/audio-only: { $ref: ./openapi/paths/streams.yaml#/AudioOnly } /streams/audio-only/source: { $ref: ./openapi/paths/streams.yaml#/AudioOnlySource } + /subtitles/youtube/{videoId}: { $ref: ./openapi/paths/subtitles.yaml#/YoutubeSubtitle } /proxy: { $ref: ./openapi/paths/proxy.yaml#/Proxy } /sabr/download/{videoId}: { $ref: ./openapi/paths/sabr-download.yaml#/SabrDownload } /sabr/playback/{sessionId}/position: { $ref: ./openapi/paths/sabr-playback.yaml#/SabrPlaybackPosition } diff --git a/openapi/paths/proxy.yaml b/openapi/paths/proxy.yaml index 4276bd6e..b1162a6a 100644 --- a/openapi/paths/proxy.yaml +++ b/openapi/paths/proxy.yaml @@ -1,10 +1,11 @@ Proxy: get: tags: [extraction] - summary: Retrieve proxied media or YouTube subtitle content + summary: Retrieve proxied media description: >- - YouTube timed-text URLs are refreshed through the Token service and returned as same-origin - WebVTT. Other supported URLs retain the generic streaming proxy behavior. + Streams supported remote content. Existing clients that submit a YouTube timed-text URL are + routed through the dedicated subtitle resolver for compatibility; new clients should use + /subtitles/youtube/{videoId}. parameters: - name: url in: query diff --git a/openapi/paths/subtitles.yaml b/openapi/paths/subtitles.yaml new file mode 100644 index 00000000..a7468276 --- /dev/null +++ b/openapi/paths/subtitles.yaml @@ -0,0 +1,62 @@ +YoutubeSubtitle: + get: + tags: [playback] + summary: Retrieve a YouTube subtitle track + description: >- + Resolves a fresh timed-text track through PipePipeExtractor and returns bounded subtitle + content through the configured YouTube egress. VOD responses are cached by selection and + simultaneous identical requests are coalesced. Active and post-live tracks use a separate + short-lived server cache and are not browser-cacheable. + parameters: + - name: videoId + in: path + required: true + schema: + type: string + pattern: '^[A-Za-z0-9_-]{11}$' + - name: language + in: query + required: true + schema: { type: string, minLength: 1, maxLength: 64 } + - name: variant + in: query + required: true + schema: { type: string, enum: [manual, auto] } + - name: format + in: query + required: false + schema: { type: string, enum: [vtt, ttml], default: vtt } + - name: sourceLanguage + in: query + required: false + schema: { type: string, minLength: 1, maxLength: 64 } + - name: translation + in: query + required: false + schema: { type: string, minLength: 1, maxLength: 64 } + - name: name + in: query + required: false + description: Distinguishes multiple tracks with the same language and generation variant. + schema: { type: string, minLength: 1, maxLength: 128 } + responses: + '200': + description: Selected subtitle content. + headers: + X-Request-ID: + $ref: ../components/common.yaml#/RequestIdHeader + Cache-Control: + schema: { type: string } + content: + text/vtt: + schema: { type: string } + application/ttml+xml: + schema: { type: string } + '400': + $ref: ../components/common.yaml#/JsonError + '404': + $ref: ../components/common.yaml#/JsonError + '429': + $ref: ../components/common.yaml#/JsonError + '502': + $ref: ../components/common.yaml#/JsonError diff --git a/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt b/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt index a626a815..681158ef 100644 --- a/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/ApplicationStreamRoutes.kt @@ -8,6 +8,7 @@ import dev.typetype.server.routes.proxyRoutes import dev.typetype.server.routes.storyboardProxyRoutes import dev.typetype.server.routes.streamRoutes import dev.typetype.server.routes.withPlayableSabrStreams +import dev.typetype.server.routes.youtubeSubtitleRoutes import dev.typetype.server.services.AdminSettingsService import dev.typetype.server.services.AuthService import io.ktor.server.plugins.ratelimit.rateLimit @@ -57,7 +58,8 @@ internal fun Route.installStreamRoutes( internal fun Route.installProxyRoutes(svc: ServiceRegistry) { rateLimit(PROXY_ZONE) { - proxyRoutes(svc.proxyService, svc.youtubeSubtitleService) + proxyRoutes(svc.proxyService, svc.youtubeSubtitleDeliveryService) + youtubeSubtitleRoutes(svc.youtubeSubtitleDeliveryService) audioOnlySourceRoutes( streamService = svc.streamService, proxyService = svc.proxyService, diff --git a/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt index 6182e354..39b218a8 100644 --- a/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt @@ -34,6 +34,10 @@ import dev.typetype.server.services.SabrSessionStore import dev.typetype.server.services.SignedHlsManifestTokenService import dev.typetype.server.services.TypetypeTokenYoutubeSessionClient import dev.typetype.server.services.YouTubeSubtitleService +import dev.typetype.server.services.YouTubeSubtitleCache +import dev.typetype.server.services.YouTubeSubtitleDeliveryService +import dev.typetype.server.services.OkHttpYouTubeSubtitleContentFetcher +import dev.typetype.server.services.PipePipeYouTubeSubtitleResolver import dev.typetype.server.services.YoutubePlayerClient import dev.typetype.server.services.YoutubePlayerClientFallbackStreamService import dev.typetype.server.services.YoutubePlayerClientStreamService @@ -75,6 +79,11 @@ internal class ExtractionServiceRegistry( .build() val sabrSessionStore = SabrSessionStore(subtitleServiceUrl, initCache = cache) val youtubeSubtitleService = YouTubeSubtitleService(httpClient, subtitleServiceUrl) + val youtubeSubtitleDeliveryService = YouTubeSubtitleDeliveryService( + PipePipeYouTubeSubtitleResolver(), + OkHttpYouTubeSubtitleContentFetcher(httpClient), + YouTubeSubtitleCache(cache), + ) private val directPipePipeStreamService = PipePipeStreamService( cache, youtubeSubtitleService, diff --git a/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt b/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt index 3e918502..b0cff887 100644 --- a/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/ServiceRegistry.kt @@ -69,7 +69,7 @@ internal class ServiceRegistry( val podcastService = extraction.podcastService val publicPlaylistService = extraction.publicPlaylistService val proxyService = extraction.proxyService - val youtubeSubtitleService = extraction.youtubeSubtitleService + val youtubeSubtitleDeliveryService = extraction.youtubeSubtitleDeliveryService val nicoVideoProxyService = extraction.nicoVideoProxyService val manifestService = extraction.manifestService val nativeManifestService = extraction.nativeManifestService diff --git a/src/main/kotlin/dev/typetype/server/routes/ProxyRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/ProxyRoutes.kt index cef0ecef..e5b2bf2e 100644 --- a/src/main/kotlin/dev/typetype/server/routes/ProxyRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/ProxyRoutes.kt @@ -2,8 +2,10 @@ package dev.typetype.server.routes import dev.typetype.server.models.ErrorResponse import dev.typetype.server.services.ProxyService -import dev.typetype.server.services.YouTubeSubtitleService +import dev.typetype.server.services.YouTubeSubtitleContentResult +import dev.typetype.server.services.YouTubeSubtitleDeliveryService import dev.typetype.server.services.isYouTubeTimedTextUrl +import dev.typetype.server.services.subtitleSelectionFromTimedTextUrl import io.ktor.http.HttpStatusCode import io.ktor.server.response.respond import io.ktor.server.routing.Route @@ -11,14 +13,16 @@ import io.ktor.server.routing.get internal fun Route.proxyRoutes( proxyService: ProxyService, - youtubeSubtitleService: YouTubeSubtitleService? = null, + youtubeSubtitleService: YouTubeSubtitleDeliveryService? = null, ) { get("/proxy") { val url = call.request.queryParameters["url"] ?: return@get call.respond(HttpStatusCode.BadRequest, ErrorResponse("Missing 'url' parameter")) if (youtubeSubtitleService != null && isYouTubeTimedTextUrl(url)) { - return@get call.respondYouTubeSubtitle(youtubeSubtitleService.fetchSubtitleContent(url)) + val selection = subtitleSelectionFromTimedTextUrl(url) + ?: return@get call.respondYouTubeSubtitle(YouTubeSubtitleContentResult.InvalidRequest) + return@get call.respondYouTubeSubtitle(youtubeSubtitleService.fetch(selection)) } val rangeHeader = call.request.headers["Range"] diff --git a/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt b/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt index 22789878..ddd76ea1 100644 --- a/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt +++ b/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleResponse.kt @@ -4,6 +4,7 @@ import dev.typetype.server.models.ErrorResponse import dev.typetype.server.preserveTooManyRequestsBody import dev.typetype.server.services.YouTubeSubtitleContentResult import io.ktor.http.ContentType +import io.ktor.http.HttpHeaders import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall import io.ktor.server.response.respond @@ -12,8 +13,12 @@ import io.ktor.server.response.respondBytes internal suspend fun ApplicationCall.respondYouTubeSubtitle(result: YouTubeSubtitleContentResult) { when (result) { is YouTubeSubtitleContentResult.Ready -> { - response.headers.append("Cache-Control", "private, max-age=300", safeOnly = false) - respondBytes(result.content, ContentType.parse("text/vtt; charset=utf-8")) + response.headers.append( + HttpHeaders.CacheControl, + if (result.isLive) LIVE_CACHE_CONTROL else VOD_CACHE_CONTROL, + safeOnly = false, + ) + respondBytes(result.content, ContentType.parse(result.format.contentType)) } YouTubeSubtitleContentResult.InvalidRequest -> respondSubtitleError( HttpStatusCode.BadRequest, @@ -30,6 +35,11 @@ internal suspend fun ApplicationCall.respondYouTubeSubtitle(result: YouTubeSubti "YouTube temporarily throttled subtitle retrieval", "subtitle_upstream_throttled", ) + YouTubeSubtitleContentResult.Expired -> respondSubtitleError( + HttpStatusCode.BadGateway, + "YouTube subtitle URL expired after refresh", + "subtitle_url_expired", + ) YouTubeSubtitleContentResult.InvalidPayload -> respondSubtitleError( HttpStatusCode.BadGateway, "YouTube returned invalid subtitle content", @@ -43,6 +53,12 @@ internal suspend fun ApplicationCall.respondYouTubeSubtitle(result: YouTubeSubti } } +internal suspend fun ApplicationCall.respondYouTubeSubtitleInvalidRequest() = respondSubtitleError( + HttpStatusCode.BadRequest, + "Invalid YouTube subtitle request", + "subtitle_request_invalid", +) + private suspend fun ApplicationCall.respondSubtitleError( status: HttpStatusCode, message: String, @@ -51,3 +67,6 @@ private suspend fun ApplicationCall.respondSubtitleError( if (status == HttpStatusCode.TooManyRequests) preserveTooManyRequestsBody() respond(status, ErrorResponse(message, code)) } + +private const val VOD_CACHE_CONTROL = "public, max-age=21600, stale-while-revalidate=3600" +private const val LIVE_CACHE_CONTROL = "no-store" diff --git a/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleRoutes.kt new file mode 100644 index 00000000..f568222d --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/routes/YouTubeSubtitleRoutes.kt @@ -0,0 +1,44 @@ +package dev.typetype.server.routes + +import dev.typetype.server.services.YouTubeSubtitleDeliveryService +import dev.typetype.server.services.YouTubeSubtitleFormat +import dev.typetype.server.services.YouTubeSubtitleSelection +import dev.typetype.server.services.YouTubeSubtitleVariant +import dev.typetype.server.services.isValidSubtitleTag +import dev.typetype.server.services.isValidYouTubeVideoId +import io.ktor.server.routing.Route +import io.ktor.server.routing.get + +internal fun Route.youtubeSubtitleRoutes(service: YouTubeSubtitleDeliveryService) { + get("/subtitles/youtube/{videoId}") { + val selection = call.subtitleSelection() + ?: return@get call.respondYouTubeSubtitleInvalidRequest() + call.respondYouTubeSubtitle(service.fetch(selection)) + } +} + +private fun io.ktor.server.application.ApplicationCall.subtitleSelection(): YouTubeSubtitleSelection? { + val videoId = parameters["videoId"]?.trim().orEmpty() + val language = request.queryParameters["language"]?.trim().orEmpty() + val variant = YouTubeSubtitleVariant.from(request.queryParameters["variant"]) + val format = YouTubeSubtitleFormat.from(request.queryParameters["format"] ?: "vtt") + val sourceLanguage = request.queryParameters["sourceLanguage"]?.trim()?.takeIf(String::isNotEmpty) + val translation = request.queryParameters["translation"]?.trim()?.takeIf(String::isNotEmpty) + val trackName = request.queryParameters["name"]?.trim()?.takeIf(String::isNotEmpty) + if (!isValidYouTubeVideoId(videoId) || !isValidSubtitleTag(language)) return null + if (variant == null || format == null) return null + if (sourceLanguage != null && !isValidSubtitleTag(sourceLanguage)) return null + if (translation != null && !isValidSubtitleTag(translation)) return null + if (trackName != null && trackName.length > MAX_TRACK_NAME_LENGTH) return null + return YouTubeSubtitleSelection( + videoId = videoId, + language = language, + variant = variant, + format = format, + sourceLanguage = sourceLanguage, + translationLanguage = translation, + trackName = trackName, + ) +} + +private const val MAX_TRACK_NAME_LENGTH = 128 diff --git a/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleService.kt b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleService.kt index a8a1a97e..0d1f5367 100644 --- a/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleService.kt +++ b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleService.kt @@ -5,11 +5,8 @@ import dev.typetype.server.cache.CacheJson import dev.typetype.server.currentRequestId import dev.typetype.server.models.SubtitleItem import kotlinx.coroutines.suspendCancellableCoroutine -import kotlinx.serialization.Serializable import okhttp3.Call import okhttp3.Callback -import okhttp3.HttpUrl.Companion.toHttpUrl -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -56,91 +53,9 @@ internal class YouTubeSubtitleService(private val httpClient: OkHttpClient, priv } } - suspend fun fetchSubtitleContent(url: String): YouTubeSubtitleContentResult { - val tokenUrl = baseUrl.toHttpUrl().newBuilder() - .addPathSegments("subtitles/content") - .addQueryParameter("url", url) - .build() - return execute(Request.Builder().url(tokenUrl).build()) { response -> - if (response.isSuccessful) { - val body = response.body - val declaredSize = body.contentLength() - if (declaredSize > MAX_SUBTITLE_BYTES) return@execute YouTubeSubtitleContentResult.InvalidPayload - val bytes = body.byteStream().readNBytes(MAX_SUBTITLE_BYTES + 1) - if (bytes.size > MAX_SUBTITLE_BYTES || !bytes.isWebVtt()) { - YouTubeSubtitleContentResult.InvalidPayload - } else { - YouTubeSubtitleContentResult.Ready(bytes) - } - } else { - val error = runCatching { - CacheJson.decodeFromString(response.body.string()) - }.getOrNull() - when (error?.code) { - "subtitle_request_invalid" -> YouTubeSubtitleContentResult.InvalidRequest - "subtitle_track_not_found" -> YouTubeSubtitleContentResult.NotFound - "subtitle_upstream_throttled" -> YouTubeSubtitleContentResult.Throttled - "subtitle_payload_invalid" -> YouTubeSubtitleContentResult.InvalidPayload - else -> YouTubeSubtitleContentResult.Unavailable - } - } - } - } - - private suspend fun execute( - request: Request, - readResponse: (Response) -> YouTubeSubtitleContentResult, - ): YouTubeSubtitleContentResult { - val requestId = currentRequestId() - val observedRequest = request.newBuilder() - .apply { requestId?.let { header(REQUEST_ID_HEADER, it) } } - .build() - return suspendCancellableCoroutine { continuation -> - val call = httpClient.newCall(observedRequest) - continuation.invokeOnCancellation { call.cancel() } - call.enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - if (continuation.isActive) continuation.resume(YouTubeSubtitleContentResult.Unavailable) - } - - override fun onResponse(call: Call, response: Response) { - val result = response.use { - runCatching { readResponse(it) }.getOrDefault(YouTubeSubtitleContentResult.Unavailable) - } - if (continuation.isActive) continuation.resume(result) - } - }) - } - } - - private companion object { - const val MAX_SUBTITLE_BYTES = 5 * 1024 * 1024 - } } internal sealed interface YouTubeSubtitleInventoryResult { data class Ready(val tracks: List) : YouTubeSubtitleInventoryResult data object Unavailable : YouTubeSubtitleInventoryResult } - -internal sealed interface YouTubeSubtitleContentResult { - data class Ready(val content: ByteArray) : YouTubeSubtitleContentResult - data object InvalidRequest : YouTubeSubtitleContentResult - data object NotFound : YouTubeSubtitleContentResult - data object Throttled : YouTubeSubtitleContentResult - data object InvalidPayload : YouTubeSubtitleContentResult - data object Unavailable : YouTubeSubtitleContentResult -} - -internal fun isYouTubeTimedTextUrl(rawUrl: String): Boolean { - val url = rawUrl.toHttpUrlOrNull() ?: return false - return url.isHttps && - (url.host == "youtube.com" || url.host.endsWith(".youtube.com")) && - url.encodedPath == "/api/timedtext" -} - -private fun ByteArray.isWebVtt(): Boolean = - toString(Charsets.UTF_8).trimStart().startsWith("WEBVTT") - -@Serializable -private data class TokenSubtitleError(val code: String? = null) From 548992deaf98f95a17dc7376e0be57c80eca526f Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 2 Aug 2026 00:09:12 +0200 Subject: [PATCH 20/33] test: cover YouTube subtitle resolution --- .../PipePipeYouTubeSubtitleResolverTest.kt | 75 ++++++++++++++++++ .../YouTubeSubtitleContentFetcherTest.kt | 76 +++++++++++++++++++ .../services/YouTubeSubtitleContractTest.kt | 38 ++++++++++ 3 files changed, 189 insertions(+) create mode 100644 src/test/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolverTest.kt create mode 100644 src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcherTest.kt create mode 100644 src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContractTest.kt diff --git a/src/test/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolverTest.kt b/src/test/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolverTest.kt new file mode 100644 index 00000000..c2080eb5 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolverTest.kt @@ -0,0 +1,75 @@ +package dev.typetype.server.services + +import okhttp3.HttpUrl.Companion.toHttpUrl +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.schabi.newpipe.extractor.MediaFormat +import org.schabi.newpipe.extractor.stream.SubtitlesStream + +class PipePipeYouTubeSubtitleResolverTest { + @Test + fun `manual and automatic tracks remain distinct`() { + val manual = track("https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en", false) + val automatic = track("https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&kind=asr", true) + + assertTrue(manual.matchesYouTubeSubtitle(selection(YouTubeSubtitleVariant.Manual))) + assertFalse(manual.matchesYouTubeSubtitle(selection(YouTubeSubtitleVariant.Auto))) + assertTrue(automatic.matchesYouTubeSubtitle(selection(YouTubeSubtitleVariant.Auto))) + assertFalse(automatic.matchesYouTubeSubtitle(selection(YouTubeSubtitleVariant.Manual))) + } + + @Test + fun `translated selection preserves source language and requested translation`() { + val track = track( + "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&kind=asr&tlang=de", + true, + ) + val selection = selection(YouTubeSubtitleVariant.Auto).copy( + language = "fr", + sourceLanguage = "en", + translationLanguage = "fr", + ) + + assertTrue(track.matchesYouTubeSubtitle(selection)) + val resolved = requireNotNull(track.contentForYouTubeSubtitle(selection)).toHttpUrl() + assertEquals("en", resolved.queryParameter("lang")) + assertEquals("fr", resolved.queryParameter("tlang")) + assertEquals("vtt", resolved.queryParameter("fmt")) + } + + @Test + fun `inline subtitles cannot be translated by URL parameters`() { + val inline = SubtitlesStream.Builder() + .setContent("", false) + .setMediaFormat(MediaFormat.TTML) + .setLanguageCode("en") + .setAutoGenerated(false) + .build() + + assertNull( + inline.contentForYouTubeSubtitle( + selection(YouTubeSubtitleVariant.Manual).copy( + format = YouTubeSubtitleFormat.Ttml, + translationLanguage = "fr", + ), + ), + ) + } + + private fun track(url: String, automatic: Boolean) = SubtitlesStream.Builder() + .setContent(url, true) + .setMediaFormat(MediaFormat.VTT) + .setLanguageCode("en") + .setAutoGenerated(automatic) + .build() + + private fun selection(variant: YouTubeSubtitleVariant) = YouTubeSubtitleSelection( + videoId = "abcdefghijk", + language = "en", + variant = variant, + format = YouTubeSubtitleFormat.Vtt, + ) +} diff --git a/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcherTest.kt b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcherTest.kt new file mode 100644 index 00000000..e519c0e4 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcherTest.kt @@ -0,0 +1,76 @@ +package dev.typetype.server.services + +import kotlinx.coroutines.test.runTest +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class YouTubeSubtitleContentFetcherTest { + @Test + fun `fetcher sends browser context and accepts bounded WebVTT`() = runTest { + var accept: String? = null + var origin: String? = null + val fetcher = fetcher(VTT, "text/vtt") { request -> + accept = request.header("Accept") + origin = request.header("Origin") + } + + val result = fetcher.fetch(TIMED_TEXT_URL, YouTubeSubtitleFormat.Vtt) + + assertTrue(result is YouTubeSubtitleFetchResult.Ready) + assertTrue(accept?.startsWith("text/vtt") == true) + assertEquals("https://m.youtube.com", origin) + } + + @Test + fun `fetcher classifies throttle and expired URLs`() = runTest { + assertEquals( + YouTubeSubtitleFetchResult.Throttled, + fetcher("throttled", code = 429).fetch(TIMED_TEXT_URL, YouTubeSubtitleFormat.Vtt), + ) + assertEquals( + YouTubeSubtitleFetchResult.Expired, + fetcher("expired", code = 403).fetch(TIMED_TEXT_URL, YouTubeSubtitleFormat.Vtt), + ) + } + + @Test + fun `fetcher rejects an unexpected success payload`() = runTest { + assertEquals( + YouTubeSubtitleFetchResult.InvalidPayload, + fetcher("challenge", "text/html") + .fetch(TIMED_TEXT_URL, YouTubeSubtitleFormat.Vtt), + ) + } + + private fun fetcher( + body: String, + contentType: String = "application/json", + code: Int = 200, + observe: (okhttp3.Request) -> Unit = {}, + ): OkHttpYouTubeSubtitleContentFetcher { + val client = OkHttpClient.Builder() + .addInterceptor { chain -> + observe(chain.request()) + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("test") + .body(body.toResponseBody(contentType.toMediaType())) + .build() + } + .build() + return OkHttpYouTubeSubtitleContentFetcher(client) + } + + private companion object { + const val VTT = "WEBVTT\n\n00:00.000 --> 00:01.000\nHello" + const val TIMED_TEXT_URL = "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&fmt=vtt" + } +} diff --git a/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContractTest.kt b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContractTest.kt new file mode 100644 index 00000000..b6994c18 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContractTest.kt @@ -0,0 +1,38 @@ +package dev.typetype.server.services + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class YouTubeSubtitleContractTest { + @Test + fun `legacy timed text selection preserves track and translation`() { + val selection = subtitleSelectionFromTimedTextUrl( + "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&kind=asr" + + "&name=English&tlang=fr&expire=123&sig=secret", + ) + + requireNotNull(selection) + assertEquals("abcdefghijk", selection.videoId) + assertEquals("fr", selection.language) + assertEquals("en", selection.sourceLanguage) + assertEquals("fr", selection.translationLanguage) + assertEquals("English", selection.trackName) + assertEquals(YouTubeSubtitleVariant.Auto, selection.variant) + assertEquals(YouTubeSubtitleFormat.Vtt, selection.format) + } + + @Test + fun `legacy selection rejects non YouTube and invalid video IDs`() { + assertNull( + subtitleSelectionFromTimedTextUrl( + "https://example.com/api/timedtext?v=abcdefghijk&lang=en", + ), + ) + assertNull( + subtitleSelectionFromTimedTextUrl( + "https://www.youtube.com/api/timedtext?v=short&lang=en", + ), + ) + } +} From 19d0839451fb8987a3b990992a8cbf771b1a4fb5 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 2 Aug 2026 00:09:17 +0200 Subject: [PATCH 21/33] test: cover YouTube subtitle delivery --- .../server/YouTubeSubtitleProxyRoutesTest.kt | 122 +++++++++----- .../YouTubeSubtitleDeliveryServiceTest.kt | 149 ++++++++++++++++++ .../services/YouTubeSubtitleServiceTest.kt | 29 ---- 3 files changed, 230 insertions(+), 70 deletions(-) create mode 100644 src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleDeliveryServiceTest.kt diff --git a/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt b/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt index e1c682b3..4323c872 100644 --- a/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt @@ -1,8 +1,13 @@ package dev.typetype.server import dev.typetype.server.routes.proxyRoutes +import dev.typetype.server.routes.youtubeSubtitleRoutes import dev.typetype.server.services.ProxyService -import dev.typetype.server.services.YouTubeSubtitleService +import dev.typetype.server.services.ResolvedYouTubeSubtitle +import dev.typetype.server.services.YouTubeSubtitleCache +import dev.typetype.server.services.YouTubeSubtitleDeliveryService +import dev.typetype.server.services.YouTubeSubtitleFetchResult +import dev.typetype.server.services.YouTubeSubtitleResolution import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter @@ -17,12 +22,6 @@ import io.ktor.server.testing.testApplication import io.mockk.coVerify import io.mockk.mockk import kotlinx.serialization.json.Json -import okhttp3.MediaType.Companion.toMediaType -import okhttp3.OkHttpClient -import okhttp3.Protocol -import okhttp3.Request -import okhttp3.Response -import okhttp3.ResponseBody.Companion.toResponseBody import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -31,71 +30,112 @@ class YouTubeSubtitleProxyRoutesTest { private val proxyService: ProxyService = mockk(relaxed = true) @Test - fun `YouTube subtitle proxy returns same-origin WebVTT`() = testApplication { - val service = subtitleService("WEBVTT\n\n00:00.000 --> 00:01.000\nHello", "text/vtt") + fun `dedicated YouTube subtitle route returns cacheable WebVTT`() = testApplication { + val service = subtitleService(YouTubeSubtitleFetchResult.Ready(VTT)) + application { + install(ContentNegotiation) { json() } + routing { youtubeSubtitleRoutes(service) } + } + + val response = client.get("/subtitles/youtube/abcdefghijk") { + parameter("language", "en") + parameter("variant", "manual") + parameter("format", "vtt") + } + + assertEquals(HttpStatusCode.OK, response.status) + assertTrue(response.headers[HttpHeaders.ContentType]?.startsWith("text/vtt") == true) + assertEquals("public, max-age=21600, stale-while-revalidate=3600", response.headers[HttpHeaders.CacheControl]) + assertTrue(response.bodyAsText().startsWith("WEBVTT")) + } + + @Test + fun `live YouTube subtitle route disables response caching`() = testApplication { + val service = subtitleService(YouTubeSubtitleFetchResult.Ready(VTT), isLive = true) + application { + install(ContentNegotiation) { json() } + routing { youtubeSubtitleRoutes(service) } + } + + val response = client.get("/subtitles/youtube/abcdefghijk") { + parameter("language", "en") + parameter("variant", "auto") + } + + assertEquals(HttpStatusCode.OK, response.status) + assertEquals("no-store", response.headers[HttpHeaders.CacheControl]) + } + + @Test + fun `legacy timed text proxy uses dedicated subtitle delivery`() = testApplication { + val service = subtitleService(YouTubeSubtitleFetchResult.Ready(VTT)) application { install(ContentNegotiation) { json() } routing { proxyRoutes(proxyService, service) } } val response = client.get("/proxy") { - parameter("url", "https://www.youtube.com/api/timedtext?v=video&lang=en") + parameter("url", "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en") } assertEquals(HttpStatusCode.OK, response.status) - assertTrue(response.headers[HttpHeaders.ContentType]?.startsWith("text/vtt") == true) - assertEquals("private, max-age=300", response.headers[HttpHeaders.CacheControl]) assertTrue(response.bodyAsText().startsWith("WEBVTT")) coVerify(exactly = 0) { proxyService.pipe(any(), any(), any()) } } @Test fun `YouTube subtitle throttle returns a typed request id error`() = testApplication { - var tokenRequest: Request? = null - val service = subtitleService( - """{"error":"throttled","code":"subtitle_upstream_throttled"}""", - code = 429, - ) { tokenRequest = it } + val service = subtitleService(YouTubeSubtitleFetchResult.Throttled) application { installRequestObservability() install(ContentNegotiation) { json(Json { encodeDefaults = true }) } configureStatusPages() - routing { proxyRoutes(proxyService, service) } + routing { youtubeSubtitleRoutes(service) } } - val response = client.get("/proxy") { + val response = client.get("/subtitles/youtube/abcdefghijk") { header(REQUEST_ID_HEADER, "subtitle-request-123") - parameter("url", "https://www.youtube.com/api/timedtext?v=video&lang=en") + parameter("language", "en") + parameter("variant", "manual") } assertEquals(HttpStatusCode.TooManyRequests, response.status) - assertEquals(null, response.headers[HttpHeaders.RetryAfter]) assertEquals("subtitle-request-123", response.headers[REQUEST_ID_HEADER]) - assertEquals("subtitle-request-123", tokenRequest?.header(REQUEST_ID_HEADER)) val body = response.bodyAsText() assertTrue(body.contains("\"code\":\"subtitle_upstream_throttled\"")) assertTrue(body.contains("\"requestId\":\"subtitle-request-123\"")) - coVerify(exactly = 0) { proxyService.pipe(any(), any(), any()) } + } + + @Test + fun `invalid dedicated subtitle selection returns typed bad request`() = testApplication { + val service = subtitleService(YouTubeSubtitleFetchResult.Ready(VTT)) + application { + installRequestObservability() + install(ContentNegotiation) { json(Json { encodeDefaults = true }) } + configureStatusPages() + routing { youtubeSubtitleRoutes(service) } + } + + val response = client.get("/subtitles/youtube/not-valid") { + parameter("language", "en") + parameter("variant", "manual") + } + + assertEquals(HttpStatusCode.BadRequest, response.status) + assertTrue(response.bodyAsText().contains("\"code\":\"subtitle_request_invalid\"")) } private fun subtitleService( - body: String, - contentType: String = "application/json", - code: Int = 200, - observeRequest: (Request) -> Unit = {}, - ): YouTubeSubtitleService { - val client = OkHttpClient.Builder() - .addInterceptor { chain -> - observeRequest(chain.request()) - Response.Builder() - .request(chain.request()) - .protocol(Protocol.HTTP_1_1) - .code(code) - .message("test") - .body(body.toResponseBody(contentType.toMediaType())) - .build() - } - .build() - return YouTubeSubtitleService(client, "http://token") + fetchResult: YouTubeSubtitleFetchResult, + isLive: Boolean = false, + ) = YouTubeSubtitleDeliveryService( + resolver = { YouTubeSubtitleResolution.Ready(ResolvedYouTubeSubtitle(TIMED_TEXT_URL, true, isLive)) }, + fetcher = { _, _ -> fetchResult }, + cache = YouTubeSubtitleCache(null), + ) + + private companion object { + val VTT = "WEBVTT\n\n00:00.000 --> 00:01.000\nHello".encodeToByteArray() + const val TIMED_TEXT_URL = "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&fmt=vtt" } } diff --git a/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleDeliveryServiceTest.kt b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleDeliveryServiceTest.kt new file mode 100644 index 00000000..b09e96ca --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleDeliveryServiceTest.kt @@ -0,0 +1,149 @@ +package dev.typetype.server.services + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.yield +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertInstanceOf +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.util.concurrent.atomic.AtomicInteger + +class YouTubeSubtitleDeliveryServiceTest { + @Test + fun `VOD subtitle content is cached by stable selection`() = runTest { + val resolutions = AtomicInteger() + val fetches = AtomicInteger() + val service = service( + resolver = { + resolutions.incrementAndGet() + readyTrack() + }, + fetcher = { _, _ -> + fetches.incrementAndGet() + YouTubeSubtitleFetchResult.Ready(VTT) + }, + ) + + val first = service.fetch(SELECTION) + val second = service.fetch(SELECTION) + + assertTrue(first is YouTubeSubtitleContentResult.Ready) + assertEquals(first, second) + assertEquals(1, resolutions.get()) + assertEquals(1, fetches.get()) + } + + @Test + fun `simultaneous subtitle requests share one upstream fetch`() = runTest { + val resolutions = AtomicInteger() + val started = CompletableDeferred() + val release = CompletableDeferred() + val service = service( + resolver = { + resolutions.incrementAndGet() + started.complete(Unit) + release.await() + readyTrack() + }, + fetcher = { _, _ -> YouTubeSubtitleFetchResult.Ready(VTT) }, + ) + + val first = async { service.fetch(SELECTION) } + started.await() + val second = async { service.fetch(SELECTION) } + yield() + release.complete(Unit) + + assertEquals(first.await(), second.await()) + assertEquals(1, resolutions.get()) + } + + @Test + fun `expired subtitle URL is refreshed once`() = runTest { + val resolutions = AtomicInteger() + val fetches = AtomicInteger() + val service = service( + resolver = { + resolutions.incrementAndGet() + readyTrack() + }, + fetcher = { _, _ -> + if (fetches.incrementAndGet() == 1) YouTubeSubtitleFetchResult.Expired + else YouTubeSubtitleFetchResult.Ready(VTT) + }, + ) + + val result = service.fetch(SELECTION) + + assertTrue(result is YouTubeSubtitleContentResult.Ready) + assertEquals(2, resolutions.get()) + assertEquals(2, fetches.get()) + } + + @Test + fun `repeated expired subtitle URL returns stable typed result`() = runTest { + val resolutions = AtomicInteger() + val service = service( + resolver = { + resolutions.incrementAndGet() + readyTrack() + }, + fetcher = { _, _ -> YouTubeSubtitleFetchResult.Expired }, + ) + + assertEquals(YouTubeSubtitleContentResult.Expired, service.fetch(SELECTION)) + assertEquals(2, resolutions.get()) + } + + @Test + fun `inline TTML is returned without an upstream content request`() = runTest { + val fetches = AtomicInteger() + val selection = SELECTION.copy(format = YouTubeSubtitleFormat.Ttml) + val service = service( + resolver = { + YouTubeSubtitleResolution.Ready( + ResolvedYouTubeSubtitle(TTML.decodeToString(), isUrl = false, isLive = false), + ) + }, + fetcher = { _, _ -> + fetches.incrementAndGet() + YouTubeSubtitleFetchResult.Unavailable + }, + ) + + val result = service.fetch(selection) + + val ready = assertInstanceOf(YouTubeSubtitleContentResult.Ready::class.java, result) + assertTrue(ready.content.contentEquals(TTML)) + assertEquals(YouTubeSubtitleFormat.Ttml, ready.format) + assertEquals(false, ready.isLive) + assertEquals(0, fetches.get()) + } + + private fun service( + resolver: suspend (YouTubeSubtitleSelection) -> YouTubeSubtitleResolution, + fetcher: suspend (String, YouTubeSubtitleFormat) -> YouTubeSubtitleFetchResult, + ) = YouTubeSubtitleDeliveryService( + resolver = YouTubeSubtitleTrackResolver(resolver), + fetcher = YouTubeSubtitleContentFetcher(fetcher), + cache = YouTubeSubtitleCache(null), + ) + + private fun readyTrack(isLive: Boolean = false) = YouTubeSubtitleResolution.Ready( + ResolvedYouTubeSubtitle(TIMED_TEXT_URL, isUrl = true, isLive = isLive), + ) + + private companion object { + val VTT = "WEBVTT\n\n00:00.000 --> 00:01.000\nHello".encodeToByteArray() + val TTML = "".encodeToByteArray() + const val TIMED_TEXT_URL = "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&fmt=vtt" + val SELECTION = YouTubeSubtitleSelection( + videoId = "abcdefghijk", + language = "en", + variant = YouTubeSubtitleVariant.Manual, + format = YouTubeSubtitleFormat.Vtt, + ) + } +} diff --git a/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleServiceTest.kt b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleServiceTest.kt index cce3fb18..b581b964 100644 --- a/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleServiceTest.kt @@ -42,35 +42,6 @@ class YouTubeSubtitleServiceTest { ) } - @Test - fun `subtitle content URL is encoded and returned as WebVTT`() = runTest { - val rawUrl = "https://www.youtube.com/api/timedtext?v=video&lang=en&tlang=fr" - var tokenRequest: Request? = null - val service = service("WEBVTT\n\n00:00.000 --> 00:01.000\nHello", "text/vtt") { - tokenRequest = it - } - - val result = service.fetchSubtitleContent(rawUrl) as YouTubeSubtitleContentResult.Ready - - assertTrue(result.content.decodeToString().startsWith("WEBVTT")) - assertEquals(rawUrl, tokenRequest?.url?.queryParameter("url")) - } - - @Test - fun `Token throttle is preserved as a typed subtitle result`() = runTest { - var calls = 0 - val service = service( - """{"error":"throttled","code":"subtitle_upstream_throttled"}""", - code = 429, - ) { calls++ } - - assertEquals( - YouTubeSubtitleContentResult.Throttled, - service.fetchSubtitleContent("https://www.youtube.com/api/timedtext?v=video&lang=en"), - ) - assertEquals(1, calls) - } - private fun service( body: String, contentType: String = "application/json", From 7cdb25c2f30a8e471f0cf9999e6765f65cc577de Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 2 Aug 2026 00:28:33 +0200 Subject: [PATCH 22/33] fix: isolate YouTube subtitle extraction --- .../PipePipeYouTubeSubtitleResolver.kt | 47 ++++++++++++------- .../PipePipeYouTubeSubtitleResolverTest.kt | 23 +++++++++ 2 files changed, 52 insertions(+), 18 deletions(-) diff --git a/src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt b/src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt index f00fb98f..acb515a7 100644 --- a/src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt +++ b/src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt @@ -12,26 +12,16 @@ internal fun interface YouTubeSubtitleTrackResolver { suspend fun resolve(selection: YouTubeSubtitleSelection): YouTubeSubtitleResolution } -internal class PipePipeYouTubeSubtitleResolver : YouTubeSubtitleTrackResolver { +internal class PipePipeYouTubeSubtitleResolver( + private val extract: suspend (YouTubeSubtitleSelection) -> YouTubeSubtitleResolution = + ::extractYouTubeSubtitle, +) : YouTubeSubtitleTrackResolver { override suspend fun resolve(selection: YouTubeSubtitleSelection): YouTubeSubtitleResolution = try { withTimeout(RESOLUTION_TIMEOUT_MS) { - runPipePipeCall { - val url = "https://www.youtube.com/watch?v=${selection.videoId}" - val service = NewPipe.getServiceByUrl(url) - val extractor = service.getStreamExtractor(service.streamLHFactory.fromUrl(url)) - extractor.fetchPage() - val track = extractor.getSubtitles(selection.format.mediaFormat) - .firstOrNull { it.matchesYouTubeSubtitle(selection) } - ?: return@runPipePipeCall YouTubeSubtitleResolution.NotFound - val content = track.contentForYouTubeSubtitle(selection) - ?: return@runPipePipeCall YouTubeSubtitleResolution.NotFound - YouTubeSubtitleResolution.Ready( - ResolvedYouTubeSubtitle( - content = content, - isUrl = track.isUrl, - isLive = extractor.streamType.toApiStreamType().isLiveContentType(), - ), - ) + YoutubeSessionTokenScope.withoutCredentials { + YoutubePlayerClientScope.withClient(YoutubePlayerClient.MWEB) { + extract(selection) + } } } } catch (_: TimeoutCancellationException) { @@ -54,6 +44,27 @@ internal class PipePipeYouTubeSubtitleResolver : YouTubeSubtitleTrackResolver { } } +private suspend fun extractYouTubeSubtitle( + selection: YouTubeSubtitleSelection, +): YouTubeSubtitleResolution = runPipePipeCall { + val url = "https://www.youtube.com/watch?v=${selection.videoId}" + val service = NewPipe.getServiceByUrl(url) + val extractor = service.getStreamExtractor(service.streamLHFactory.fromUrl(url)) + extractor.fetchPage() + val track = extractor.getSubtitles(selection.format.mediaFormat) + .firstOrNull { it.matchesYouTubeSubtitle(selection) } + ?: return@runPipePipeCall YouTubeSubtitleResolution.NotFound + val content = track.contentForYouTubeSubtitle(selection) + ?: return@runPipePipeCall YouTubeSubtitleResolution.NotFound + YouTubeSubtitleResolution.Ready( + ResolvedYouTubeSubtitle( + content = content, + isUrl = track.isUrl, + isLive = extractor.streamType.toApiStreamType().isLiveContentType(), + ), + ) +} + internal fun SubtitlesStream.matchesYouTubeSubtitle(selection: YouTubeSubtitleSelection): Boolean { val url = content.toHttpUrlOrNull() val trackLanguage = languageTag.orEmpty() diff --git a/src/test/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolverTest.kt b/src/test/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolverTest.kt index c2080eb5..c979cf70 100644 --- a/src/test/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolverTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolverTest.kt @@ -1,15 +1,38 @@ package dev.typetype.server.services import okhttp3.HttpUrl.Companion.toHttpUrl +import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.schabi.newpipe.extractor.MediaFormat +import org.schabi.newpipe.extractor.NewPipe +import org.schabi.newpipe.extractor.ServiceList import org.schabi.newpipe.extractor.stream.SubtitlesStream class PipePipeYouTubeSubtitleResolverTest { + @Test + fun `resolution uses the public mweb extraction context`() = runBlocking { + val youtube = ServiceList.YouTube + NewPipe.setYoutubePlayerClient(YoutubePlayerClient.WEB_SAFARI.value) + youtube.setTokens("SID=session-cookie") + youtube.setAdditionalTokens("session-pot") + var observed: Triple? = null + val resolver = PipePipeYouTubeSubtitleResolver { + observed = Triple( + NewPipe.getYoutubePlayerClient(), + youtube.tokens.orEmpty(), + youtube.additionalTokens.orEmpty(), + ) + YouTubeSubtitleResolution.NotFound + } + + assertEquals(YouTubeSubtitleResolution.NotFound, resolver.resolve(selection(YouTubeSubtitleVariant.Manual))) + assertEquals(Triple(YoutubePlayerClient.MWEB.value, "", ""), observed) + } + @Test fun `manual and automatic tracks remain distinct`() { val manual = track("https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en", false) From 93415228d367062584dc29de96196ddbec7235ea Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 2 Aug 2026 00:43:21 +0200 Subject: [PATCH 23/33] fix: reuse YouTube subtitle stream metadata --- .../server/ExtractionServiceRegistry.kt | 12 +-- .../PipePipeYouTubeSubtitleResolver.kt | 93 ------------------- .../services/StreamYouTubeSubtitleResolver.kt | 83 +++++++++++++++++ ...t => StreamYouTubeSubtitleResolverTest.kt} | 89 +++++++++--------- 4 files changed, 133 insertions(+), 144 deletions(-) delete mode 100644 src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt create mode 100644 src/main/kotlin/dev/typetype/server/services/StreamYouTubeSubtitleResolver.kt rename src/test/kotlin/dev/typetype/server/services/{PipePipeYouTubeSubtitleResolverTest.kt => StreamYouTubeSubtitleResolverTest.kt} (51%) diff --git a/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt index 39b218a8..2e27dd66 100644 --- a/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt @@ -37,7 +37,7 @@ import dev.typetype.server.services.YouTubeSubtitleService import dev.typetype.server.services.YouTubeSubtitleCache import dev.typetype.server.services.YouTubeSubtitleDeliveryService import dev.typetype.server.services.OkHttpYouTubeSubtitleContentFetcher -import dev.typetype.server.services.PipePipeYouTubeSubtitleResolver +import dev.typetype.server.services.StreamYouTubeSubtitleResolver import dev.typetype.server.services.YoutubePlayerClient import dev.typetype.server.services.YoutubePlayerClientFallbackStreamService import dev.typetype.server.services.YoutubePlayerClientStreamService @@ -79,11 +79,6 @@ internal class ExtractionServiceRegistry( .build() val sabrSessionStore = SabrSessionStore(subtitleServiceUrl, initCache = cache) val youtubeSubtitleService = YouTubeSubtitleService(httpClient, subtitleServiceUrl) - val youtubeSubtitleDeliveryService = YouTubeSubtitleDeliveryService( - PipePipeYouTubeSubtitleResolver(), - OkHttpYouTubeSubtitleContentFetcher(httpClient), - YouTubeSubtitleCache(cache), - ) private val directPipePipeStreamService = PipePipeStreamService( cache, youtubeSubtitleService, @@ -120,6 +115,11 @@ internal class ExtractionServiceRegistry( cache, "stream-youtube-sabr:v1", ) + val youtubeSubtitleDeliveryService = YouTubeSubtitleDeliveryService( + StreamYouTubeSubtitleResolver(youtubeSabrStreamService, youtubeSubtitleService::fetchSubtitleInventory), + OkHttpYouTubeSubtitleContentFetcher(httpClient), + YouTubeSubtitleCache(cache), + ) val youtubeSabrBootstrapStreamService = CachedStreamService( YoutubeScopedStreamService(SabrBootstrapStreamService(sabrSessionStore, tokenYoutubeSessionClient)), cache, diff --git a/src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt b/src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt deleted file mode 100644 index acb515a7..00000000 --- a/src/main/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolver.kt +++ /dev/null @@ -1,93 +0,0 @@ -package dev.typetype.server.services - -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.TimeoutCancellationException -import kotlinx.coroutines.withTimeout -import okhttp3.HttpUrl.Companion.toHttpUrlOrNull -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.exceptions.ReCaptchaException -import org.schabi.newpipe.extractor.stream.SubtitlesStream - -internal fun interface YouTubeSubtitleTrackResolver { - suspend fun resolve(selection: YouTubeSubtitleSelection): YouTubeSubtitleResolution -} - -internal class PipePipeYouTubeSubtitleResolver( - private val extract: suspend (YouTubeSubtitleSelection) -> YouTubeSubtitleResolution = - ::extractYouTubeSubtitle, -) : YouTubeSubtitleTrackResolver { - override suspend fun resolve(selection: YouTubeSubtitleSelection): YouTubeSubtitleResolution = try { - withTimeout(RESOLUTION_TIMEOUT_MS) { - YoutubeSessionTokenScope.withoutCredentials { - YoutubePlayerClientScope.withClient(YoutubePlayerClient.MWEB) { - extract(selection) - } - } - } - } catch (_: TimeoutCancellationException) { - YouTubeSubtitleResolution.Unavailable - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - if (error.hasCause()) { - YouTubeSubtitleResolution.Throttled - } else { - YouTubeSubtitleResolution.Unavailable - } - } - - private inline fun Throwable.hasCause(): Boolean = - generateSequence(this) { it.cause }.any { it is T } - - private companion object { - const val RESOLUTION_TIMEOUT_MS = 30_000L - } -} - -private suspend fun extractYouTubeSubtitle( - selection: YouTubeSubtitleSelection, -): YouTubeSubtitleResolution = runPipePipeCall { - val url = "https://www.youtube.com/watch?v=${selection.videoId}" - val service = NewPipe.getServiceByUrl(url) - val extractor = service.getStreamExtractor(service.streamLHFactory.fromUrl(url)) - extractor.fetchPage() - val track = extractor.getSubtitles(selection.format.mediaFormat) - .firstOrNull { it.matchesYouTubeSubtitle(selection) } - ?: return@runPipePipeCall YouTubeSubtitleResolution.NotFound - val content = track.contentForYouTubeSubtitle(selection) - ?: return@runPipePipeCall YouTubeSubtitleResolution.NotFound - YouTubeSubtitleResolution.Ready( - ResolvedYouTubeSubtitle( - content = content, - isUrl = track.isUrl, - isLive = extractor.streamType.toApiStreamType().isLiveContentType(), - ), - ) -} - -internal fun SubtitlesStream.matchesYouTubeSubtitle(selection: YouTubeSubtitleSelection): Boolean { - val url = content.toHttpUrlOrNull() - val trackLanguage = languageTag.orEmpty() - val sourceLanguage = url?.queryParameter("lang") ?: trackLanguage - val requestedSource = selection.sourceLanguage ?: selection.language - if (!sourceLanguage.equals(requestedSource, ignoreCase = true) && - !trackLanguage.equals(selection.language, ignoreCase = true) - ) return false - val auto = isAutoGenerated || url?.queryParameter("kind") == "asr" || - url?.queryParameter("vssId")?.startsWith("a.") == true - if (auto != (selection.variant == YouTubeSubtitleVariant.Auto)) return false - return selection.trackName == null || url?.queryParameter("name") == selection.trackName -} - -internal fun SubtitlesStream.contentForYouTubeSubtitle(selection: YouTubeSubtitleSelection): String? { - if (!isUrl) return content.takeIf { selection.translationLanguage == null } - val url = content.toHttpUrlOrNull()?.takeIf { isYouTubeTimedTextUrl(it.toString()) } ?: return null - return url.newBuilder() - .setQueryParameter("fmt", selection.format.value) - .apply { - if (selection.translationLanguage == null) removeAllQueryParameters("tlang") - else setQueryParameter("tlang", selection.translationLanguage) - } - .build() - .toString() -} diff --git a/src/main/kotlin/dev/typetype/server/services/StreamYouTubeSubtitleResolver.kt b/src/main/kotlin/dev/typetype/server/services/StreamYouTubeSubtitleResolver.kt new file mode 100644 index 00000000..85643b1f --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/StreamYouTubeSubtitleResolver.kt @@ -0,0 +1,83 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.StreamResponse +import dev.typetype.server.models.SubtitleItem +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withTimeout +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull + +internal fun interface YouTubeSubtitleTrackResolver { + suspend fun resolve(selection: YouTubeSubtitleSelection): YouTubeSubtitleResolution +} + +internal class StreamYouTubeSubtitleResolver( + private val streamService: StreamService, + private val fetchInventory: suspend (String) -> YouTubeSubtitleInventoryResult, +) : YouTubeSubtitleTrackResolver { + override suspend fun resolve(selection: YouTubeSubtitleSelection): YouTubeSubtitleResolution = try { + withTimeout(RESOLUTION_TIMEOUT_MS) { + val stream = streamService.streamForSubtitle(selection.videoId) + val inventory = fetchInventory(selection.videoId) + val tracks = (inventory as? YouTubeSubtitleInventoryResult.Ready)?.tracks + ?.takeIf(List::isNotEmpty) + ?: stream?.subtitles + ?: return@withTimeout YouTubeSubtitleResolution.Unavailable + val track = tracks.firstOrNull { it.matchesYouTubeSubtitle(selection) } + ?: return@withTimeout YouTubeSubtitleResolution.NotFound + val content = track.contentForYouTubeSubtitle(selection) + ?: return@withTimeout YouTubeSubtitleResolution.NotFound + YouTubeSubtitleResolution.Ready( + ResolvedYouTubeSubtitle( + content = content, + isUrl = true, + isLive = stream?.isLiveSubtitleContent() == true, + ), + ) + } + } catch (_: TimeoutCancellationException) { + YouTubeSubtitleResolution.Unavailable + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + YouTubeSubtitleResolution.Unavailable + } + + private companion object { + const val RESOLUTION_TIMEOUT_MS = 30_000L + } +} + +private suspend fun StreamService.streamForSubtitle(videoId: String): StreamResponse? = + when (val result = getStreamInfo("https://www.youtube.com/watch?v=$videoId")) { + is ExtractionResult.Success -> result.data + is ExtractionResult.BadRequest, is ExtractionResult.Failure -> null + } + +private fun StreamResponse.isLiveSubtitleContent(): Boolean = isLive || isLiveContent + +internal fun SubtitleItem.matchesYouTubeSubtitle(selection: YouTubeSubtitleSelection): Boolean { + val parsedUrl = url.toHttpUrlOrNull() + val sourceLanguage = parsedUrl?.queryParameter("lang") ?: languageTag + val requestedSource = selection.sourceLanguage ?: selection.language + if (!sourceLanguage.equals(requestedSource, ignoreCase = true) && + !languageTag.equals(selection.language, ignoreCase = true) + ) return false + val auto = isAutoGenerated || parsedUrl?.queryParameter("kind") == "asr" || + parsedUrl?.queryParameter("vssId")?.startsWith("a.") == true + if (auto != (selection.variant == YouTubeSubtitleVariant.Auto)) return false + return selection.trackName == null || parsedUrl?.queryParameter("name") == selection.trackName +} + +internal fun SubtitleItem.contentForYouTubeSubtitle(selection: YouTubeSubtitleSelection): String? { + val parsedUrl = url.toHttpUrlOrNull()?.takeIf { isYouTubeTimedTextUrl(it.toString()) } ?: return null + return parsedUrl.newBuilder() + .setQueryParameter("fmt", selection.format.value) + .apply { + if (selection.translationLanguage == null) removeAllQueryParameters("tlang") + else setQueryParameter("tlang", selection.translationLanguage) + } + .build() + .toString() +} diff --git a/src/test/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolverTest.kt b/src/test/kotlin/dev/typetype/server/services/StreamYouTubeSubtitleResolverTest.kt similarity index 51% rename from src/test/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolverTest.kt rename to src/test/kotlin/dev/typetype/server/services/StreamYouTubeSubtitleResolverTest.kt index c979cf70..53713cf4 100644 --- a/src/test/kotlin/dev/typetype/server/services/PipePipeYouTubeSubtitleResolverTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/StreamYouTubeSubtitleResolverTest.kt @@ -1,36 +1,47 @@ package dev.typetype.server.services -import okhttp3.HttpUrl.Companion.toHttpUrl +import dev.typetype.server.models.ExtractionResult +import dev.typetype.server.models.SubtitleItem +import dev.typetype.server.testStreamResponse import kotlinx.coroutines.runBlocking +import okhttp3.HttpUrl.Companion.toHttpUrl import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse -import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import org.schabi.newpipe.extractor.MediaFormat -import org.schabi.newpipe.extractor.NewPipe -import org.schabi.newpipe.extractor.ServiceList -import org.schabi.newpipe.extractor.stream.SubtitlesStream -class PipePipeYouTubeSubtitleResolverTest { +class StreamYouTubeSubtitleResolverTest { @Test - fun `resolution uses the public mweb extraction context`() = runBlocking { - val youtube = ServiceList.YouTube - NewPipe.setYoutubePlayerClient(YoutubePlayerClient.WEB_SAFARI.value) - youtube.setTokens("SID=session-cookie") - youtube.setAdditionalTokens("session-pot") - var observed: Triple? = null - val resolver = PipePipeYouTubeSubtitleResolver { - observed = Triple( - NewPipe.getYoutubePlayerClient(), - youtube.tokens.orEmpty(), - youtube.additionalTokens.orEmpty(), - ) - YouTubeSubtitleResolution.NotFound - } + fun `resolution uses fresh inventory and preserves live state`() = runBlocking { + val resolver = StreamYouTubeSubtitleResolver( + streamService = streamService( + listOf(track("https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=de", false)), + isLive = true, + ), + fetchInventory = { + YouTubeSubtitleInventoryResult.Ready( + listOf(track("https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en", false)), + ) + }, + ) - assertEquals(YouTubeSubtitleResolution.NotFound, resolver.resolve(selection(YouTubeSubtitleVariant.Manual))) - assertEquals(Triple(YoutubePlayerClient.MWEB.value, "", ""), observed) + val result = resolver.resolve(selection(YouTubeSubtitleVariant.Manual)) + + val ready = result as YouTubeSubtitleResolution.Ready + assertTrue(ready.track.isLive) + assertEquals("en", ready.track.content.toHttpUrl().queryParameter("lang")) + } + + @Test + fun `stream inventory is used when fresh inventory is unavailable`() = runBlocking { + val resolver = StreamYouTubeSubtitleResolver( + streamService = streamService( + listOf(track("https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en", false)), + ), + fetchInventory = { YouTubeSubtitleInventoryResult.Unavailable }, + ) + + assertTrue(resolver.resolve(selection(YouTubeSubtitleVariant.Manual)) is YouTubeSubtitleResolution.Ready) } @Test @@ -63,31 +74,19 @@ class PipePipeYouTubeSubtitleResolverTest { assertEquals("vtt", resolved.queryParameter("fmt")) } - @Test - fun `inline subtitles cannot be translated by URL parameters`() { - val inline = SubtitlesStream.Builder() - .setContent("", false) - .setMediaFormat(MediaFormat.TTML) - .setLanguageCode("en") - .setAutoGenerated(false) - .build() - - assertNull( - inline.contentForYouTubeSubtitle( - selection(YouTubeSubtitleVariant.Manual).copy( - format = YouTubeSubtitleFormat.Ttml, - translationLanguage = "fr", - ), - ), + private fun streamService(subtitles: List, isLive: Boolean = false) = object : StreamService { + override suspend fun getStreamInfo(url: String) = ExtractionResult.Success( + testStreamResponse().copy(subtitles = subtitles, isLive = isLive, isLiveContent = isLive), ) } - private fun track(url: String, automatic: Boolean) = SubtitlesStream.Builder() - .setContent(url, true) - .setMediaFormat(MediaFormat.VTT) - .setLanguageCode("en") - .setAutoGenerated(automatic) - .build() + private fun track(url: String, automatic: Boolean) = SubtitleItem( + url = url, + mimeType = "application/ttml+xml", + languageTag = "en", + displayLanguageName = "English", + isAutoGenerated = automatic, + ) private fun selection(variant: YouTubeSubtitleVariant) = YouTubeSubtitleSelection( videoId = "abcdefghijk", From 12b30671fe1822f3c713fa63cb9ad9e6e7d892b4 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 2 Aug 2026 00:55:15 +0200 Subject: [PATCH 24/33] fix: keep subtitle content on token egress --- .../server/ExtractionServiceRegistry.kt | 7 +- .../services/YouTubeSubtitleContentFetcher.kt | 89 +++++++++++-------- .../YouTubeSubtitleContentFetcherTest.kt | 62 +++++++++++++ 3 files changed, 122 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt index 2e27dd66..59dcdffb 100644 --- a/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt @@ -38,6 +38,7 @@ import dev.typetype.server.services.YouTubeSubtitleCache import dev.typetype.server.services.YouTubeSubtitleDeliveryService import dev.typetype.server.services.OkHttpYouTubeSubtitleContentFetcher import dev.typetype.server.services.StreamYouTubeSubtitleResolver +import dev.typetype.server.services.TokenYouTubeSubtitleContentFetcher import dev.typetype.server.services.YoutubePlayerClient import dev.typetype.server.services.YoutubePlayerClientFallbackStreamService import dev.typetype.server.services.YoutubePlayerClientStreamService @@ -117,7 +118,11 @@ internal class ExtractionServiceRegistry( ) val youtubeSubtitleDeliveryService = YouTubeSubtitleDeliveryService( StreamYouTubeSubtitleResolver(youtubeSabrStreamService, youtubeSubtitleService::fetchSubtitleInventory), - OkHttpYouTubeSubtitleContentFetcher(httpClient), + TokenYouTubeSubtitleContentFetcher( + httpClient, + subtitleServiceUrl, + OkHttpYouTubeSubtitleContentFetcher(httpClient), + ), YouTubeSubtitleCache(cache), ) val youtubeSabrBootstrapStreamService = CachedStreamService( diff --git a/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcher.kt b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcher.kt index 7fcf0f98..acb9e662 100644 --- a/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcher.kt +++ b/src/main/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcher.kt @@ -5,6 +5,7 @@ import dev.typetype.server.currentRequestId import kotlinx.coroutines.suspendCancellableCoroutine import okhttp3.Call import okhttp3.Callback +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response @@ -28,51 +29,69 @@ internal class OkHttpYouTubeSubtitleContentFetcher( .header("User-Agent", OkHttpProxyService.BROWSER_USER_AGENT) .apply { currentRequestId()?.let { header(REQUEST_ID_HEADER, it) } } .build() - return execute(request, format) + return client.executeSubtitleRequest(request, format) } +} - private suspend fun execute( - request: Request, - format: YouTubeSubtitleFormat, - ): YouTubeSubtitleFetchResult = suspendCancellableCoroutine { continuation -> - val call = client.newCall(request) - continuation.invokeOnCancellation { call.cancel() } - call.enqueue(object : Callback { - override fun onFailure(call: Call, e: IOException) { - if (continuation.isActive) continuation.resume(YouTubeSubtitleFetchResult.Unavailable) - } +internal class TokenYouTubeSubtitleContentFetcher( + private val client: OkHttpClient, + baseUrl: String, + private val directFetcher: YouTubeSubtitleContentFetcher, +) : YouTubeSubtitleContentFetcher { + private val endpoint = baseUrl.toHttpUrl().newBuilder() + .addPathSegments("subtitles/content") + .build() - override fun onResponse(call: Call, response: Response) { - val result = runCatching { response.use { readResponse(it, format) } } - .getOrDefault(YouTubeSubtitleFetchResult.Unavailable) - if (continuation.isActive) continuation.resume(result) - } - }) + override suspend fun fetch(url: String, format: YouTubeSubtitleFormat): YouTubeSubtitleFetchResult { + if (format == YouTubeSubtitleFormat.Ttml) return directFetcher.fetch(url, format) + if (!isYouTubeTimedTextUrl(url)) return YouTubeSubtitleFetchResult.Unavailable + val request = Request.Builder() + .url(endpoint.newBuilder().addQueryParameter("url", url).build()) + .header("Accept", format.contentType) + .apply { currentRequestId()?.let { header(REQUEST_ID_HEADER, it) } } + .build() + return client.executeSubtitleRequest(request, format) } +} - private fun readResponse(response: Response, format: YouTubeSubtitleFormat): YouTubeSubtitleFetchResult { - if (response.code == 429) return YouTubeSubtitleFetchResult.Throttled - if (response.code == 403 || response.code == 404 || response.code == 410) { - return YouTubeSubtitleFetchResult.Expired +private suspend fun OkHttpClient.executeSubtitleRequest( + request: Request, + format: YouTubeSubtitleFormat, +): YouTubeSubtitleFetchResult = suspendCancellableCoroutine { continuation -> + val call = newCall(request) + continuation.invokeOnCancellation { call.cancel() } + call.enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + if (continuation.isActive) continuation.resume(YouTubeSubtitleFetchResult.Unavailable) } - if (!response.isSuccessful) return YouTubeSubtitleFetchResult.Unavailable - val body = response.body - if (body.contentLength() > MAX_SUBTITLE_BYTES) return YouTubeSubtitleFetchResult.InvalidPayload - return runCatching { body.byteStream().readNBytes(MAX_SUBTITLE_BYTES + 1) } - .fold( - onSuccess = { bytes -> - if (isValidSubtitlePayload(bytes, format)) YouTubeSubtitleFetchResult.Ready(bytes) - else YouTubeSubtitleFetchResult.InvalidPayload - }, - onFailure = { YouTubeSubtitleFetchResult.Unavailable }, - ) - } - private companion object { - const val YOUTUBE_ORIGIN = "https://m.youtube.com" + override fun onResponse(call: Call, response: Response) { + val result = runCatching { response.use { readSubtitleResponse(it, format) } } + .getOrDefault(YouTubeSubtitleFetchResult.Unavailable) + if (continuation.isActive) continuation.resume(result) + } + }) +} + +private fun readSubtitleResponse(response: Response, format: YouTubeSubtitleFormat): YouTubeSubtitleFetchResult { + if (response.code == 429) return YouTubeSubtitleFetchResult.Throttled + if (response.code == 403 || response.code == 404 || response.code == 410) { + return YouTubeSubtitleFetchResult.Expired } + if (!response.isSuccessful) return YouTubeSubtitleFetchResult.Unavailable + val body = response.body + if (body.contentLength() > MAX_SUBTITLE_BYTES) return YouTubeSubtitleFetchResult.InvalidPayload + return runCatching { body.byteStream().readNBytes(MAX_SUBTITLE_BYTES + 1) } + .fold( + onSuccess = { bytes -> + if (isValidSubtitlePayload(bytes, format)) YouTubeSubtitleFetchResult.Ready(bytes) + else YouTubeSubtitleFetchResult.InvalidPayload + }, + onFailure = { YouTubeSubtitleFetchResult.Unavailable }, + ) } +private const val YOUTUBE_ORIGIN = "https://m.youtube.com" internal const val MAX_SUBTITLE_BYTES = 5 * 1024 * 1024 internal fun isValidSubtitlePayload(content: ByteArray, format: YouTubeSubtitleFormat): Boolean { diff --git a/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcherTest.kt b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcherTest.kt index e519c0e4..478e17e0 100644 --- a/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcherTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/YouTubeSubtitleContentFetcherTest.kt @@ -48,6 +48,45 @@ class YouTubeSubtitleContentFetcherTest { ) } + @Test + fun `token fetcher keeps WebVTT retrieval on the token service`() = runTest { + var request: okhttp3.Request? = null + val fetcher = tokenFetcher(VTT, observe = { request = it }) + + val result = fetcher.fetch(TIMED_TEXT_URL, YouTubeSubtitleFormat.Vtt) + + assertTrue(result is YouTubeSubtitleFetchResult.Ready) + assertEquals("token", request?.url?.host) + assertEquals("/subtitles/content", request?.url?.encodedPath) + assertEquals(TIMED_TEXT_URL, request?.url?.queryParameter("url")) + } + + @Test + fun `token fetcher preserves typed throttling`() = runTest { + val result = tokenFetcher("throttled", code = 429) + .fetch(TIMED_TEXT_URL, YouTubeSubtitleFormat.Vtt) + + assertEquals(YouTubeSubtitleFetchResult.Throttled, result) + } + + @Test + fun `token fetcher leaves TTML on the direct path`() = runTest { + var directFormat: YouTubeSubtitleFormat? = null + val fetcher = tokenFetcher( + body = VTT, + observe = { error("Token must not receive TTML requests") }, + direct = { _, format -> + directFormat = format + YouTubeSubtitleFetchResult.Ready(TTML.encodeToByteArray()) + }, + ) + + val result = fetcher.fetch(TIMED_TEXT_URL, YouTubeSubtitleFormat.Ttml) + + assertTrue(result is YouTubeSubtitleFetchResult.Ready) + assertEquals(YouTubeSubtitleFormat.Ttml, directFormat) + } + private fun fetcher( body: String, contentType: String = "application/json", @@ -69,8 +108,31 @@ class YouTubeSubtitleContentFetcherTest { return OkHttpYouTubeSubtitleContentFetcher(client) } + private fun tokenFetcher( + body: String, + code: Int = 200, + observe: (okhttp3.Request) -> Unit = {}, + direct: suspend (String, YouTubeSubtitleFormat) -> YouTubeSubtitleFetchResult = + { _, _ -> YouTubeSubtitleFetchResult.Unavailable }, + ): TokenYouTubeSubtitleContentFetcher { + val client = OkHttpClient.Builder() + .addInterceptor { chain -> + observe(chain.request()) + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message("test") + .body(body.toResponseBody("text/vtt".toMediaType())) + .build() + } + .build() + return TokenYouTubeSubtitleContentFetcher(client, "http://token/", YouTubeSubtitleContentFetcher(direct)) + } + private companion object { const val VTT = "WEBVTT\n\n00:00.000 --> 00:01.000\nHello" + const val TTML = "" const val TIMED_TEXT_URL = "https://www.youtube.com/api/timedtext?v=abcdefghijk&lang=en&fmt=vtt" } } From 6d49da22c63e44c8c95de7098b757c1c1f917c3c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Sun, 2 Aug 2026 01:07:52 +0200 Subject: [PATCH 25/33] fix: avoid double compression on rate limits --- src/main/kotlin/dev/typetype/server/CompressionConfig.kt | 3 +++ .../dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt | 3 +++ 2 files changed, 6 insertions(+) diff --git a/src/main/kotlin/dev/typetype/server/CompressionConfig.kt b/src/main/kotlin/dev/typetype/server/CompressionConfig.kt index b0fc8920..18afbd5d 100644 --- a/src/main/kotlin/dev/typetype/server/CompressionConfig.kt +++ b/src/main/kotlin/dev/typetype/server/CompressionConfig.kt @@ -1,15 +1,18 @@ package dev.typetype.server import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode import io.ktor.server.application.Application import io.ktor.server.application.install import io.ktor.server.plugins.compression.Compression +import io.ktor.server.plugins.compression.condition import io.ktor.server.plugins.compression.excludeContentType import io.ktor.server.plugins.compression.gzip fun Application.configureCompression(): Unit { install(Compression) { gzip { + condition { _ -> response.status() != HttpStatusCode.TooManyRequests } excludeContentType(ContentType.parse("application/vnd.apple.mpegurl")) excludeContentType(ContentType.Image.Any) excludeContentType(ContentType.Video.Any) diff --git a/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt b/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt index 4323c872..2bca9ee5 100644 --- a/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/YouTubeSubtitleProxyRoutesTest.kt @@ -89,18 +89,21 @@ class YouTubeSubtitleProxyRoutesTest { application { installRequestObservability() install(ContentNegotiation) { json(Json { encodeDefaults = true }) } + configureCompression() configureStatusPages() routing { youtubeSubtitleRoutes(service) } } val response = client.get("/subtitles/youtube/abcdefghijk") { header(REQUEST_ID_HEADER, "subtitle-request-123") + header(HttpHeaders.AcceptEncoding, "gzip") parameter("language", "en") parameter("variant", "manual") } assertEquals(HttpStatusCode.TooManyRequests, response.status) assertEquals("subtitle-request-123", response.headers[REQUEST_ID_HEADER]) + assertEquals(null, response.headers[HttpHeaders.ContentEncoding]) val body = response.bodyAsText() assertTrue(body.contains("\"code\":\"subtitle_upstream_throttled\"")) assertTrue(body.contains("\"requestId\":\"subtitle-request-123\"")) From 2b2aacbbf47a67723cec8c13fcbe599c731bb920 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Tue, 4 Aug 2026 23:25:15 +0200 Subject: [PATCH 26/33] fix: preload adaptive SABR initialization ranges --- .../services/SabrAdaptiveInitialization.kt | 43 ++++++++++ .../services/SabrDownloadInitialization.kt | 20 +---- .../server/services/SabrSessionStore.kt | 5 +- .../SabrAdaptiveInitializationTest.kt | 79 +++++++++++++++++++ 4 files changed, 124 insertions(+), 23 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/server/services/SabrAdaptiveInitialization.kt create mode 100644 src/test/kotlin/dev/typetype/server/services/SabrAdaptiveInitializationTest.kt diff --git a/src/main/kotlin/dev/typetype/server/services/SabrAdaptiveInitialization.kt b/src/main/kotlin/dev/typetype/server/services/SabrAdaptiveInitialization.kt new file mode 100644 index 00000000..de684c89 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/SabrAdaptiveInitialization.kt @@ -0,0 +1,43 @@ +package dev.typetype.server.services + +import dev.typetype.server.cache.CacheService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible +import org.schabi.newpipe.extractor.localization.Localization +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat + +internal object SabrAdaptiveInitialization { + private val localization = Localization("en", "US") + + suspend fun fetch( + holder: SabrSessionHolder, + format: YoutubeSabrFormat, + cache: CacheService?, + timeoutMs: Long = 2_000L, + ): ByteArray? { + SabrInitializationData.fetch(holder.key.videoId, format, cache)?.let { + holder.session.streamState.ingestInitializationData(format, it) + return it + } + val data = fetchRange(holder, format, timeoutMs) ?: return null + SabrInitializationData.remember(holder.key.videoId, format, data, cache) + return data + } + + suspend fun fetchRange( + holder: SabrSessionHolder, + format: YoutubeSabrFormat, + timeoutMs: Long, + ): ByteArray? { + val poToken = holder.session.streamState.poToken?.takeIf { it.isNotEmpty() } + ?: holder.playerContextToken?.streamingPoTokenBytesFor(holder.info) + ?: return null + return runCatchingNonCancellation { + runInterruptible(Dispatchers.IO) { + holder.withPlayerContext { + fetchInitializationData(format, localization, timeoutMs, poToken) + } + } + }.getOrNull() + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/SabrDownloadInitialization.kt b/src/main/kotlin/dev/typetype/server/services/SabrDownloadInitialization.kt index 8941e0f3..62f296e5 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrDownloadInitialization.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrDownloadInitialization.kt @@ -1,33 +1,15 @@ package dev.typetype.server.services -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.runInterruptible -import org.schabi.newpipe.extractor.localization.Localization import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat internal object SabrDownloadInitialization { - private val localization = Localization("en", "US") - suspend fun fetch( store: SabrSessionStore, holder: SabrSessionHolder, format: YoutubeSabrFormat, ): ByteArray? { - SabrInitializationData.fetch(holder.key.videoId, format, store.initCache)?.let { - holder.session.streamState.ingestInitializationData(format, it) - return it - } - val poToken = holder.session.streamState.poToken?.takeIf { it.isNotEmpty() } - ?: holder.playerContextToken?.streamingPoTokenBytesFor(holder.info) + val direct = SabrAdaptiveInitialization.fetch(holder, format, store.initCache, DIRECT_TIMEOUT_MS) ?: return store.fetchInitializationData(holder, format) - val direct = runCatchingNonCancellation { - runInterruptible(Dispatchers.IO) { - holder.withPlayerContext { - fetchInitializationData(format, localization, DIRECT_TIMEOUT_MS, poToken) - } - } - }.getOrNull() ?: return store.fetchInitializationData(holder, format) - SabrInitializationData.remember(holder.key.videoId, format, direct, store.initCache) return direct } diff --git a/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt b/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt index 231a7675..a76ea88a 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt @@ -184,10 +184,7 @@ internal class SabrSessionStore( holder.liveInitialization(format)?.let { return it } val request = SabrSegmentRequest.initialization(format) holder.session.getCachedSegment(request)?.let { segmentCache.put(holder, it); return it.data } - SabrInitializationData.fetch(holder.key.videoId, format, initCache)?.let { - holder.session.streamState.ingestInitializationData(format, it) - return it - } + SabrAdaptiveInitialization.fetch(holder, format, initCache)?.let { return it } SabrInitializationData.bootstrap(holder, format, initCache)?.let { return it } val segment = pump.fetchSegment(holder, request) ?: return null segmentCache.put(holder, segment) diff --git a/src/test/kotlin/dev/typetype/server/services/SabrAdaptiveInitializationTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrAdaptiveInitializationTest.kt new file mode 100644 index 00000000..6c2f8a78 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/SabrAdaptiveInitializationTest.kt @@ -0,0 +1,79 @@ +package dev.typetype.server.services + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.schabi.newpipe.extractor.localization.Localization +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState + +class SabrAdaptiveInitializationTest { + @Test + fun `fetches initialization range with the active streaming token`() = runBlocking { + val fixture = fixture(byteArrayOf(1, 2, 3)) + val expected = byteArrayOf(4, 5, 6) + every { + fixture.session.fetchInitializationData( + fixture.format, + any(), + 2_000L, + match { it.contentEquals(fixture.poToken) }, + ) + } returns expected + + val actual = SabrAdaptiveInitialization.fetchRange(fixture.holder, fixture.format, 2_000L) + + assertArrayEquals(expected, actual) + } + + @Test + fun `returns null when the adaptive range request fails`() = runBlocking { + val fixture = fixture(byteArrayOf(1)) + every { + fixture.session.fetchInitializationData( + fixture.format, + any(), + 2_000L, + any(), + ) + } throws java.io.IOException("range unavailable") + + assertNull(SabrAdaptiveInitialization.fetchRange(fixture.holder, fixture.format, 2_000L)) + } + + @Test + fun `does not request a range without a streaming token`() = runBlocking { + val fixture = fixture(null) + + assertNull(SabrAdaptiveInitialization.fetchRange(fixture.holder, fixture.format, 2_000L)) + verify(exactly = 0) { + fixture.session.fetchInitializationData(any(), any(), any(), any()) + } + } + + private fun fixture(poToken: ByteArray?): Fixture { + val holder = mockk() + val session = mockk() + val state = mockk() + val format = mockk() + every { holder.session } returns session + every { holder.playerContextToken } returns null + every { holder.info } returns mockk() + every { session.streamState } returns state + every { state.poToken } returns poToken + return Fixture(holder, session, format, poToken ?: byteArrayOf()) + } + + private data class Fixture( + val holder: SabrSessionHolder, + val session: YoutubeSabrSession, + val format: YoutubeSabrFormat, + val poToken: ByteArray, + ) +} From 49a75ee336f45061cb1e373782b64dca4e65a66e Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 5 Aug 2026 10:33:49 +0200 Subject: [PATCH 27/33] fix: filter cached blocked recommendations --- .../server/routes/HomeRecommendationRoutes.kt | 6 ++- .../routes/HomeRecommendationShortsRoutes.kt | 6 ++- .../typetype/server/routes/UserDataRoutes.kt | 4 +- .../server/services/BlockedContentFilters.kt | 7 +++ .../server/HomeRecommendationRoutesTest.kt | 2 +- .../HomeRecommendationRoutesValidationTest.kt | 2 +- ...HomeRecommendationShortsDebugRoutesTest.kt | 2 +- .../HomeRecommendationShortsRoutesTest.kt | 2 +- .../BlockedHomeRecommendationsFilterTest.kt | 49 +++++++++++++++++++ 9 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 src/test/kotlin/dev/typetype/server/services/BlockedHomeRecommendationsFilterTest.kt diff --git a/src/main/kotlin/dev/typetype/server/routes/HomeRecommendationRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/HomeRecommendationRoutes.kt index 4de28fc8..bc9f264e 100644 --- a/src/main/kotlin/dev/typetype/server/routes/HomeRecommendationRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/HomeRecommendationRoutes.kt @@ -3,6 +3,7 @@ package dev.typetype.server.routes import dev.typetype.server.models.ErrorResponse import dev.typetype.server.services.AccessControlService import dev.typetype.server.services.AuthService +import dev.typetype.server.services.BlockedService import dev.typetype.server.services.HomeRecommendationCursorCodec import dev.typetype.server.services.HomeRecommendationContext import dev.typetype.server.services.HomeRecommendationDeviceClass @@ -12,6 +13,7 @@ import dev.typetype.server.services.HomeRecommendationService import dev.typetype.server.services.VALID_SERVICE_IDS import dev.typetype.server.services.YOUTUBE_SERVICE_ID import dev.typetype.server.services.filterAllowed +import dev.typetype.server.services.filterBlocked import io.ktor.http.HttpStatusCode import io.ktor.server.response.respond import io.ktor.server.routing.Route @@ -22,6 +24,7 @@ private const val MAX_RECOMMENDATION_LIMIT = 60 fun Route.homeRecommendationRoutes( recommendationService: HomeRecommendationService, authService: AuthService, + blockedService: BlockedService, accessControlService: AccessControlService? = null, ) { get("/recommendations/home") { @@ -44,13 +47,14 @@ fun Route.homeRecommendationRoutes( ) val profile = accessControlService?.profileFor(userId, authService.getUserRole(userId)) ?: dev.typetype.server.services.AccessControlProfile.unrestricted + val blocked = blockedService.profileFor(userId) val response = recommendationService.getHome( userId = userId, serviceId = serviceId, limit = limit, cursor = cursor, context = HomeRecommendationContext(serviceId, sessionContext), - ).filterAllowed(profile) + ).filterAllowed(profile).filterBlocked(blocked) call.respond(response) } } diff --git a/src/main/kotlin/dev/typetype/server/routes/HomeRecommendationShortsRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/HomeRecommendationShortsRoutes.kt index cdbdc796..a17b6524 100644 --- a/src/main/kotlin/dev/typetype/server/routes/HomeRecommendationShortsRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/HomeRecommendationShortsRoutes.kt @@ -3,6 +3,7 @@ package dev.typetype.server.routes import dev.typetype.server.models.ErrorResponse import dev.typetype.server.services.AccessControlService import dev.typetype.server.services.AuthService +import dev.typetype.server.services.BlockedService import dev.typetype.server.services.HomeRecommendationContext import dev.typetype.server.services.HomeRecommendationCursorCodec import dev.typetype.server.services.HomeRecommendationDeviceClass @@ -12,6 +13,7 @@ import dev.typetype.server.services.HomeRecommendationService import dev.typetype.server.services.VALID_SERVICE_IDS import dev.typetype.server.services.YOUTUBE_SERVICE_ID import dev.typetype.server.services.filterAllowed +import dev.typetype.server.services.filterBlocked import io.ktor.http.HttpStatusCode import io.ktor.server.response.respond import io.ktor.server.routing.Route @@ -22,6 +24,7 @@ private const val MAX_RECOMMENDATION_SHORTS_LIMIT = 60 fun Route.homeRecommendationShortsRoutes( recommendationService: HomeRecommendationService, authService: AuthService, + blockedService: BlockedService, accessControlService: AccessControlService? = null, ) { get("/recommendations/shorts") { @@ -45,6 +48,7 @@ fun Route.homeRecommendationShortsRoutes( ) val profile = accessControlService?.profileFor(userId, authService.getUserRole(userId)) ?: dev.typetype.server.services.AccessControlProfile.unrestricted + val blocked = blockedService.profileFor(userId) val response = recommendationService.getShorts( userId = userId, serviceId = serviceId, @@ -52,7 +56,7 @@ fun Route.homeRecommendationShortsRoutes( cursor = cursor, context = HomeRecommendationContext(serviceId, sessionContext), debug = debug, - ).filterAllowed(profile) + ).filterAllowed(profile).filterBlocked(blocked) call.respond(response) } } diff --git a/src/main/kotlin/dev/typetype/server/routes/UserDataRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/UserDataRoutes.kt index 0c169e0c..eab6f335 100644 --- a/src/main/kotlin/dev/typetype/server/routes/UserDataRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/UserDataRoutes.kt @@ -38,6 +38,6 @@ internal fun Route.userDataRoutes( bugReportRoutes(bugReportService, authService) restoreRoutes(restoreService, authService) typeTypeBackupRoutes(svc.typeTypeBackupService, authService) - homeRecommendationRoutes(svc.homeRecommendationService, authService, svc.accessControlService) - homeRecommendationShortsRoutes(svc.homeRecommendationService, authService, svc.accessControlService) + homeRecommendationRoutes(svc.homeRecommendationService, authService, svc.blockedService, svc.accessControlService) + homeRecommendationShortsRoutes(svc.homeRecommendationService, authService, svc.blockedService, svc.accessControlService) } diff --git a/src/main/kotlin/dev/typetype/server/services/BlockedContentFilters.kt b/src/main/kotlin/dev/typetype/server/services/BlockedContentFilters.kt index 7dee1388..0048911d 100644 --- a/src/main/kotlin/dev/typetype/server/services/BlockedContentFilters.kt +++ b/src/main/kotlin/dev/typetype/server/services/BlockedContentFilters.kt @@ -1,8 +1,15 @@ package dev.typetype.server.services +import dev.typetype.server.models.HomeRecommendationsResponse import dev.typetype.server.models.SearchPageResponse import dev.typetype.server.models.StreamResponse +internal fun HomeRecommendationsResponse.filterBlocked(profile: BlockedContentProfile): HomeRecommendationsResponse = copy( + items = items.filter { + profile.allowsVideo(it.url, it.title, it.uploaderUrl, it.uploaderName) + }, +) + internal fun SearchPageResponse.filterBlocked(profile: BlockedContentProfile): SearchPageResponse = copy( items = items.filter { profile.allowsVideo(it.url, it.title, it.uploaderUrl, it.uploaderName) }, channels = channels.filter { profile.allowsChannel(url = it.url, name = it.name) }, diff --git a/src/test/kotlin/dev/typetype/server/HomeRecommendationRoutesTest.kt b/src/test/kotlin/dev/typetype/server/HomeRecommendationRoutesTest.kt index 57ceb9ea..027baf2c 100644 --- a/src/test/kotlin/dev/typetype/server/HomeRecommendationRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/HomeRecommendationRoutesTest.kt @@ -60,7 +60,7 @@ class HomeRecommendationRoutesTest { private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { application { install(ContentNegotiation) { json() } - routing { homeRecommendationRoutes(service, auth) } + routing { homeRecommendationRoutes(service, auth, resolverDeps.blockedService) } } block() } diff --git a/src/test/kotlin/dev/typetype/server/HomeRecommendationRoutesValidationTest.kt b/src/test/kotlin/dev/typetype/server/HomeRecommendationRoutesValidationTest.kt index 29efdf8d..502d9a57 100644 --- a/src/test/kotlin/dev/typetype/server/HomeRecommendationRoutesValidationTest.kt +++ b/src/test/kotlin/dev/typetype/server/HomeRecommendationRoutesValidationTest.kt @@ -57,7 +57,7 @@ class HomeRecommendationRoutesValidationTest { private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { application { install(ContentNegotiation) { json() } - routing { homeRecommendationRoutes(service, auth) } + routing { homeRecommendationRoutes(service, auth, resolverDeps.blockedService) } } block() } diff --git a/src/test/kotlin/dev/typetype/server/HomeRecommendationShortsDebugRoutesTest.kt b/src/test/kotlin/dev/typetype/server/HomeRecommendationShortsDebugRoutesTest.kt index bbe768d2..0a36b92e 100644 --- a/src/test/kotlin/dev/typetype/server/HomeRecommendationShortsDebugRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/HomeRecommendationShortsDebugRoutesTest.kt @@ -60,7 +60,7 @@ class HomeRecommendationShortsDebugRoutesTest { fun `shorts endpoint returns source debug payload when enabled`() = testApplication { application { install(ContentNegotiation) { json() } - routing { homeRecommendationShortsRoutes(service, auth) } + routing { homeRecommendationShortsRoutes(service, auth, resolverDeps.blockedService) } } val response = client.get("/recommendations/shorts?limit=5&debug=true") { headers.append(HttpHeaders.Authorization, "Bearer test-jwt") diff --git a/src/test/kotlin/dev/typetype/server/HomeRecommendationShortsRoutesTest.kt b/src/test/kotlin/dev/typetype/server/HomeRecommendationShortsRoutesTest.kt index 4cdc03a5..fafcf6d9 100644 --- a/src/test/kotlin/dev/typetype/server/HomeRecommendationShortsRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/HomeRecommendationShortsRoutesTest.kt @@ -62,7 +62,7 @@ class HomeRecommendationShortsRoutesTest { private fun withApp(block: suspend ApplicationTestBuilder.() -> Unit) = testApplication { application { install(ContentNegotiation) { json() } - routing { homeRecommendationShortsRoutes(service, auth) } + routing { homeRecommendationShortsRoutes(service, auth, resolverDeps.blockedService) } } block() } diff --git a/src/test/kotlin/dev/typetype/server/services/BlockedHomeRecommendationsFilterTest.kt b/src/test/kotlin/dev/typetype/server/services/BlockedHomeRecommendationsFilterTest.kt new file mode 100644 index 00000000..d2ec5ab7 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/BlockedHomeRecommendationsFilterTest.kt @@ -0,0 +1,49 @@ +package dev.typetype.server.services + +import dev.typetype.server.models.BlockedItem +import dev.typetype.server.models.BlockedKeywordItem +import dev.typetype.server.models.HomeRecommendationsResponse +import dev.typetype.server.models.VideoItem +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class BlockedHomeRecommendationsFilterTest { + @Test + fun `filters cached recommendations with current blocked profile`() { + val response = HomeRecommendationsResponse( + items = listOf( + video("blocked-video", "Keep", "Allowed", "channel:allowed"), + video("allowed", "Keep", "Blocked", "channel:blocked"), + video("keyword", "Hide this topic", "Allowed", "channel:allowed"), + video("visible", "Keep", "Allowed", "channel:allowed"), + ), + nextCursor = null, + hasMore = false, + ) + val profile = BlockedContentProfile( + videos = listOf(BlockedItem(url = "https://youtube.com/watch?v=blocked-video")), + channels = listOf(BlockedItem(url = "channel:blocked", name = "Blocked")), + keywords = listOf(BlockedKeywordItem(keyword = "hide this")), + ) + + assertEquals(listOf("visible"), response.filterBlocked(profile).items.map { it.id }) + } + + private fun video(id: String, title: String, uploaderName: String, uploaderUrl: String) = VideoItem( + id = id, + title = title, + url = "https://youtube.com/watch?v=$id", + thumbnailUrl = "", + uploaderName = uploaderName, + uploaderUrl = uploaderUrl, + uploaderAvatarUrl = "", + duration = 60, + viewCount = 0, + uploadDate = "", + uploaded = 0, + streamType = "video_stream", + isShortFormContent = false, + uploaderVerified = false, + shortDescription = null, + ) +} From 225f83a39b8b4ca9dbda3faf4d5dd87d9ce27671 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 5 Aug 2026 11:09:35 +0200 Subject: [PATCH 28/33] feat: configure account session lifetime --- .../kotlin/dev/typetype/server/Application.kt | 5 ++- .../dev/typetype/server/ApplicationRoutes.kt | 13 ++++++-- .../dev/typetype/server/routes/AuthRoutes.kt | 18 ++++++++--- .../typetype/server/routes/OidcAuthRoutes.kt | 16 +++++++--- .../typetype/server/routes/RegisterRoutes.kt | 4 ++- .../server/services/AuthCookieHelpers.kt | 18 +++++------ .../typetype/server/services/AuthService.kt | 8 +++-- .../server/services/AuthSessionConfig.kt | 32 +++++++++++++++++++ .../server/services/AuthTokenIssuer.kt | 7 ++-- 9 files changed, 92 insertions(+), 29 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/server/services/AuthSessionConfig.kt diff --git a/src/main/kotlin/dev/typetype/server/Application.kt b/src/main/kotlin/dev/typetype/server/Application.kt index 498607fb..299d304c 100644 --- a/src/main/kotlin/dev/typetype/server/Application.kt +++ b/src/main/kotlin/dev/typetype/server/Application.kt @@ -4,6 +4,7 @@ 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.AuthSessionConfig import dev.typetype.server.services.AdminSettingsService import dev.typetype.server.services.AvatarService import dev.typetype.server.services.DownloaderGatewayService @@ -36,7 +37,8 @@ fun Application.module() { val dbPassword = System.getenv("DATABASE_PASSWORD") ?: "typetype" DatabaseFactory.init(dbUrl, dbUser, dbPassword) val jwtSecret = System.getenv("JWT_SECRET") ?: UUID.randomUUID().toString() - val authService = AuthService(jwtSecret) + val authSessionConfig = AuthSessionConfig.fromEnvironment() + val authService = AuthService(jwtSecret, sessionConfig = authSessionConfig) val oidcAuthService = OidcAuthService(OidcConfigLoader.fromEnvironment(), jwtSecret, authService) val userAdminService = UserAdminService() val passwordResetService = PasswordResetService() @@ -85,6 +87,7 @@ fun Application.module() { installApplicationRoutes( svc = svc, authService = authService, + authSessionConfig = authSessionConfig, adminSettingsService = adminSettingsService, activeSessionService = activeSessionService, downloaderGatewayService = downloaderGatewayService, diff --git a/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt b/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt index e1bef03f..44d80b01 100644 --- a/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt @@ -27,6 +27,7 @@ import dev.typetype.server.routes.youtubeRemoteBrowserRoutes import dev.typetype.server.services.ActiveSessionService import dev.typetype.server.services.AdminSettingsService import dev.typetype.server.services.AuthService +import dev.typetype.server.services.AuthSessionConfig import dev.typetype.server.services.AvatarService import dev.typetype.server.services.DownloaderGatewayService import dev.typetype.server.services.GitHubIssueService @@ -46,6 +47,7 @@ import io.ktor.server.routing.routing internal fun Application.installApplicationRoutes( svc: ServiceRegistry, authService: AuthService, + authSessionConfig: AuthSessionConfig, adminSettingsService: AdminSettingsService, activeSessionService: ActiveSessionService, downloaderGatewayService: DownloaderGatewayService, @@ -90,8 +92,15 @@ internal fun Application.installApplicationRoutes( ) } downloaderGatewayRoutes(downloaderGatewayService) - oidcAuthRoutes(oidcAuthService, adminSettingsService) - authRoutes(authService, passwordResetService, profileService, adminSettingsService, svc.homeRecommendationWarmupService) + oidcAuthRoutes(oidcAuthService, adminSettingsService, authSessionConfig) + authRoutes( + authService, + passwordResetService, + profileService, + adminSettingsService, + svc.homeRecommendationWarmupService, + authSessionConfig, + ) adminRoutes(authService, userAdminService, passwordResetService, adminSettingsService) adminIdentityRoutes(svc.accountIdentityService, authService) adminAllowListRoutes(authService, userAdminService, svc.adminManagedAccessService, svc.adminUserLookupService, svc.allowedChannelsService, svc.allowedPlaylistsService) diff --git a/src/main/kotlin/dev/typetype/server/routes/AuthRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/AuthRoutes.kt index 179ebd20..19bfda0f 100644 --- a/src/main/kotlin/dev/typetype/server/routes/AuthRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/AuthRoutes.kt @@ -5,6 +5,7 @@ import dev.typetype.server.models.UserProfileItem import dev.typetype.server.services.AdminSettingsService import dev.typetype.server.services.AuthCookieHelpers import dev.typetype.server.services.AuthService +import dev.typetype.server.services.AuthSessionConfig import dev.typetype.server.services.HomeRecommendationWarmup import dev.typetype.server.services.NoopHomeRecommendationWarmup import dev.typetype.server.services.PasswordResetService @@ -17,8 +18,15 @@ import io.ktor.server.routing.Route import io.ktor.server.routing.get import io.ktor.server.routing.post -fun Route.authRoutes(authService: AuthService, passwordResetService: PasswordResetService, profileService: ProfileService, adminSettingsService: AdminSettingsService, warmupService: HomeRecommendationWarmup = NoopHomeRecommendationWarmup) { - registerRoutes(authService, adminSettingsService, warmupService) +fun Route.authRoutes( + authService: AuthService, + passwordResetService: PasswordResetService, + profileService: ProfileService, + adminSettingsService: AdminSettingsService, + warmupService: HomeRecommendationWarmup = NoopHomeRecommendationWarmup, + sessionConfig: AuthSessionConfig = AuthSessionConfig(), +) { + registerRoutes(authService, adminSettingsService, warmupService, sessionConfig) post("/auth/login") { if (!adminSettingsService.get().localLoginEnabled) { @@ -32,7 +40,7 @@ fun Route.authRoutes(authService: AuthService, passwordResetService: PasswordRes call.respond(HttpStatusCode.Unauthorized, ErrorResponse("Invalid credentials")) return@post } - AuthCookieHelpers.setRefreshCookie(call.response, token.refreshToken) + AuthCookieHelpers.setRefreshCookie(call.response, token.refreshToken, sessionConfig) token.accessToken.warm(authService, warmupService) call.respond(SessionResponse(token.accessToken)) } @@ -44,14 +52,14 @@ fun Route.authRoutes(authService: AuthService, passwordResetService: PasswordRes call.respond(HttpStatusCode.Unauthorized, ErrorResponse("Invalid token")) return@post } - AuthCookieHelpers.setRefreshCookie(call.response, newToken.refreshToken) + AuthCookieHelpers.setRefreshCookie(call.response, newToken.refreshToken, sessionConfig) newToken.accessToken.warm(authService, warmupService) call.respond(SessionResponse(newToken.accessToken)) } post("/auth/logout") { val refreshToken = AuthCookieHelpers.extractRefreshToken(call) authService.logout(refreshToken) - AuthCookieHelpers.clearRefreshCookie(call.response) + AuthCookieHelpers.clearRefreshCookie(call.response, sessionConfig) call.respond(HttpStatusCode.NoContent) } diff --git a/src/main/kotlin/dev/typetype/server/routes/OidcAuthRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/OidcAuthRoutes.kt index ddc413e9..0421ca69 100644 --- a/src/main/kotlin/dev/typetype/server/routes/OidcAuthRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/OidcAuthRoutes.kt @@ -8,6 +8,7 @@ import dev.typetype.server.models.OidcStartResponse import dev.typetype.server.models.OidcStatusResponse import dev.typetype.server.services.AdminSettingsService import dev.typetype.server.services.AuthCookieHelpers +import dev.typetype.server.services.AuthSessionConfig import dev.typetype.server.services.OidcAuthService import dev.typetype.server.services.OidcCallbackSession import io.ktor.http.HttpStatusCode @@ -19,7 +20,11 @@ import io.ktor.server.routing.Route import io.ktor.server.routing.get import io.ktor.server.routing.post -fun Route.oidcAuthRoutes(oidcAuthService: OidcAuthService, adminSettingsService: AdminSettingsService) { +fun Route.oidcAuthRoutes( + oidcAuthService: OidcAuthService, + adminSettingsService: AdminSettingsService, + sessionConfig: AuthSessionConfig = AuthSessionConfig(), +) { get("/auth/oidc/status") { val settings = adminSettingsService.get() val config = oidcAuthService.publicConfig() @@ -41,7 +46,7 @@ fun Route.oidcAuthRoutes(oidcAuthService: OidcAuthService, adminSettingsService: val request = runCatching { call.receive() }.getOrElse { return@post call.respond(HttpStatusCode.BadRequest, ErrorResponse("Invalid request body")) } - call.respondOidcCallbackResult(oidcAuthService.callback(request)) + call.respondOidcCallbackResult(oidcAuthService.callback(request), sessionConfig) } } @@ -53,10 +58,13 @@ private suspend fun ApplicationCall.respondOidcStartResult(result: ExtractionRes } } -private suspend fun ApplicationCall.respondOidcCallbackResult(result: ExtractionResult) { +private suspend fun ApplicationCall.respondOidcCallbackResult( + result: ExtractionResult, + sessionConfig: AuthSessionConfig, +) { when (result) { is ExtractionResult.Success -> { - AuthCookieHelpers.setRefreshCookie(response, result.data.refreshToken) + AuthCookieHelpers.setRefreshCookie(response, result.data.refreshToken, sessionConfig) respond(OidcCallbackResponse(accessToken = result.data.accessToken, returnTo = result.data.returnTo)) } is ExtractionResult.BadRequest -> respond(HttpStatusCode.BadRequest, ErrorResponse(result.message)) diff --git a/src/main/kotlin/dev/typetype/server/routes/RegisterRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/RegisterRoutes.kt index 34e03c5e..ac9f1f4a 100644 --- a/src/main/kotlin/dev/typetype/server/routes/RegisterRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/RegisterRoutes.kt @@ -4,6 +4,7 @@ import dev.typetype.server.models.ErrorResponse import dev.typetype.server.services.AdminSettingsService import dev.typetype.server.services.AuthCookieHelpers import dev.typetype.server.services.AuthService +import dev.typetype.server.services.AuthSessionConfig import dev.typetype.server.services.HomeRecommendationWarmup import io.ktor.http.HttpStatusCode import io.ktor.server.application.call @@ -17,6 +18,7 @@ fun Route.registerRoutes( authService: AuthService, adminSettingsService: AdminSettingsService, warmupService: HomeRecommendationWarmup, + sessionConfig: AuthSessionConfig, ): Unit { get("/auth/register/status") { val bootstrapAvailable = !authService.hasAdmin() @@ -50,7 +52,7 @@ fun Route.registerRoutes( try { val token = authService.register(req.email, req.password, req.name) authService.verify(token.accessToken)?.let(warmupService::markActive) - AuthCookieHelpers.setRefreshCookie(call.response, token.refreshToken) + AuthCookieHelpers.setRefreshCookie(call.response, token.refreshToken, sessionConfig) call.respond(SessionResponse(token.accessToken)) } catch (e: Exception) { call.respond(HttpStatusCode.BadRequest, ErrorResponse("Registration failed")) diff --git a/src/main/kotlin/dev/typetype/server/services/AuthCookieHelpers.kt b/src/main/kotlin/dev/typetype/server/services/AuthCookieHelpers.kt index 708329f2..40fbc3dd 100644 --- a/src/main/kotlin/dev/typetype/server/services/AuthCookieHelpers.kt +++ b/src/main/kotlin/dev/typetype/server/services/AuthCookieHelpers.kt @@ -12,37 +12,37 @@ object AuthCookieHelpers { fun extractRefreshToken(call: ApplicationCall): String? = call.request.cookies[REFRESH_COOKIE_NAME] - fun setRefreshCookie(response: ApplicationResponse, token: String) { + fun setRefreshCookie(response: ApplicationResponse, token: String, config: AuthSessionConfig) { response.cookies.append( Cookie( name = REFRESH_COOKIE_NAME, value = token, httpOnly = true, - secure = true, + secure = !config.allowInsecureCookies, path = REFRESH_COOKIE_PATH, - maxAge = REFRESH_TTL_SECONDS.toInt(), + maxAge = config.refreshTtlSeconds.toInt(), encoding = CookieEncoding.RAW, - extensions = mapOf("SameSite" to "None"), + extensions = mapOf("SameSite" to config.sameSite), ) ) response.headers.append(HttpHeaders.AccessControlAllowCredentials, "true") } - fun clearRefreshCookie(response: ApplicationResponse) { + fun clearRefreshCookie(response: ApplicationResponse, config: AuthSessionConfig) { response.cookies.append( Cookie( name = REFRESH_COOKIE_NAME, value = "", httpOnly = true, - secure = true, + secure = !config.allowInsecureCookies, path = REFRESH_COOKIE_PATH, maxAge = 0, encoding = CookieEncoding.RAW, - extensions = mapOf("SameSite" to "None"), + extensions = mapOf("SameSite" to config.sameSite), ) ) response.headers.append(HttpHeaders.AccessControlAllowCredentials, "true") } - - const val REFRESH_TTL_SECONDS = 30L * 24L * 60L * 60L + private val AuthSessionConfig.sameSite: String + get() = if (allowInsecureCookies) "Lax" else "None" } diff --git a/src/main/kotlin/dev/typetype/server/services/AuthService.kt b/src/main/kotlin/dev/typetype/server/services/AuthService.kt index 4ed320f3..21a5eb59 100644 --- a/src/main/kotlin/dev/typetype/server/services/AuthService.kt +++ b/src/main/kotlin/dev/typetype/server/services/AuthService.kt @@ -12,10 +12,14 @@ import org.jetbrains.exposed.v1.jdbc.transactions.transaction import java.util.UUID import java.util.Date -open class AuthService(private val jwtSecret: String, private val hasUsersProbe: (() -> Boolean)? = null) { +open class AuthService( + private val jwtSecret: String, + private val hasUsersProbe: (() -> Boolean)? = null, + sessionConfig: AuthSessionConfig = AuthSessionConfig(), +) { private val accessCodec = AuthAccessTokenCodec(jwtSecret) private val sessionStore = AuthSessionStore() - private val tokenIssuer = AuthTokenIssuer(accessCodec, sessionStore) + private val tokenIssuer = AuthTokenIssuer(accessCodec, sessionStore, sessionConfig) private val sessionRefresher = AuthSessionRefresher(sessionStore, tokenIssuer) private val sessionVerifier = AuthSessionVerifier(accessCodec, sessionStore) private val sessionRevoker = AuthSessionRevoker(sessionStore) diff --git a/src/main/kotlin/dev/typetype/server/services/AuthSessionConfig.kt b/src/main/kotlin/dev/typetype/server/services/AuthSessionConfig.kt new file mode 100644 index 00000000..b51322b7 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/AuthSessionConfig.kt @@ -0,0 +1,32 @@ +package dev.typetype.server.services + +data class AuthSessionConfig( + val refreshTtlDays: Long = DEFAULT_REFRESH_TTL_DAYS, + val allowInsecureCookies: Boolean = false, +) { + val refreshTtlMs: Long = refreshTtlDays * MILLIS_PER_DAY + val refreshTtlSeconds: Long = refreshTtlDays * SECONDS_PER_DAY + + companion object { + const val DEFAULT_REFRESH_TTL_DAYS = 30L + const val MIN_REFRESH_TTL_DAYS = 1L + const val MAX_REFRESH_TTL_DAYS = 365L + private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L + private const val SECONDS_PER_DAY = 24L * 60L * 60L + + fun fromEnvironment(read: (String) -> String? = System::getenv): AuthSessionConfig = + AuthSessionConfig( + refreshTtlDays = read("AUTH_SESSION_TTL_DAYS") + ?.trim() + ?.toLongOrNull() + ?.coerceIn(MIN_REFRESH_TTL_DAYS, MAX_REFRESH_TTL_DAYS) + ?: DEFAULT_REFRESH_TTL_DAYS, + allowInsecureCookies = read("AUTH_ALLOW_INSECURE_COOKIES").isEnabled(), + ) + + private fun String?.isEnabled(): Boolean = when (this?.trim()?.lowercase()) { + "1", "true", "yes", "on" -> true + else -> false + } + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/AuthTokenIssuer.kt b/src/main/kotlin/dev/typetype/server/services/AuthTokenIssuer.kt index 1c9ce5e1..82eee7c3 100644 --- a/src/main/kotlin/dev/typetype/server/services/AuthTokenIssuer.kt +++ b/src/main/kotlin/dev/typetype/server/services/AuthTokenIssuer.kt @@ -5,13 +5,14 @@ import java.util.UUID class AuthTokenIssuer( private val accessCodec: AuthAccessTokenCodec, private val sessionStore: AuthSessionStore, + private val sessionConfig: AuthSessionConfig, ) { fun issue(userId: String, sessionId: String = UUID.randomUUID().toString()): AuthSessionTokens? { val now = System.currentTimeMillis() val refreshToken = UUID.randomUUID().toString() + UUID.randomUUID().toString() val refreshHash = AuthRefreshTokenHasher.hash(refreshToken) val accessToken = accessCodec.issue(userId = userId, sessionId = sessionId) - val expiresAt = now + REFRESH_TTL_MS + val expiresAt = now + sessionConfig.refreshTtlMs val ok = sessionStore.upsert( sessionId = sessionId, userId = userId, @@ -22,8 +23,4 @@ class AuthTokenIssuer( ) return if (ok) AuthSessionTokens(accessToken = accessToken, refreshToken = refreshToken) else null } - - companion object { - private const val REFRESH_TTL_MS = 30L * 24L * 60L * 60L * 1000L - } } From e92df8d800cba47de37507c4ee2750fd07132854 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 5 Aug 2026 11:09:53 +0200 Subject: [PATCH 29/33] test: cover configurable account sessions --- .../typetype/server/AuthCookieHelpersTest.kt | 38 ++++++++++++++++++ .../typetype/server/AuthServiceCoreTest.kt | 19 +++++++++ .../server/services/AuthSessionConfigTest.kt | 40 +++++++++++++++++++ 3 files changed, 97 insertions(+) create mode 100644 src/test/kotlin/dev/typetype/server/services/AuthSessionConfigTest.kt diff --git a/src/test/kotlin/dev/typetype/server/AuthCookieHelpersTest.kt b/src/test/kotlin/dev/typetype/server/AuthCookieHelpersTest.kt index 307ee201..574f3bda 100644 --- a/src/test/kotlin/dev/typetype/server/AuthCookieHelpersTest.kt +++ b/src/test/kotlin/dev/typetype/server/AuthCookieHelpersTest.kt @@ -1,6 +1,7 @@ package dev.typetype.server import dev.typetype.server.services.AuthCookieHelpers +import dev.typetype.server.services.AuthSessionConfig import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.statement.bodyAsText @@ -10,6 +11,8 @@ import io.ktor.server.routing.get import io.ktor.server.routing.routing import io.ktor.server.testing.testApplication import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class AuthCookieHelpersTest { @@ -27,4 +30,39 @@ class AuthCookieHelpersTest { } assertEquals("abc123", response.bodyAsText()) } + + @Test + fun `secure refresh cookie remains the default`() = testApplication { + application { + routing { + get("/probe") { + AuthCookieHelpers.setRefreshCookie(call.response, "abc123", AuthSessionConfig(refreshTtlDays = 45)) + call.respondText("ok") + } + } + } + + val cookie = client.get("/probe").headers.getAll(HttpHeaders.SetCookie).orEmpty().joinToString("; ") + assertTrue(cookie.contains("Max-Age=3888000")) + assertTrue(cookie.contains("Secure")) + assertTrue(cookie.contains("SameSite=None")) + } + + @Test + fun `explicit http compatibility cookie uses lax same site`() = testApplication { + application { + routing { + get("/probe") { + val config = AuthSessionConfig(refreshTtlDays = 7, allowInsecureCookies = true) + AuthCookieHelpers.setRefreshCookie(call.response, "abc123", config) + call.respondText("ok") + } + } + } + + val cookie = client.get("/probe").headers.getAll(HttpHeaders.SetCookie).orEmpty().joinToString("; ") + assertTrue(cookie.contains("Max-Age=604800")) + assertFalse(cookie.contains("Secure")) + assertTrue(cookie.contains("SameSite=Lax")) + } } diff --git a/src/test/kotlin/dev/typetype/server/AuthServiceCoreTest.kt b/src/test/kotlin/dev/typetype/server/AuthServiceCoreTest.kt index 158ac57e..487f90fb 100644 --- a/src/test/kotlin/dev/typetype/server/AuthServiceCoreTest.kt +++ b/src/test/kotlin/dev/typetype/server/AuthServiceCoreTest.kt @@ -1,7 +1,9 @@ package dev.typetype.server import dev.typetype.server.db.tables.UsersTable +import dev.typetype.server.db.tables.SessionsTable import dev.typetype.server.services.AuthService +import dev.typetype.server.services.AuthSessionConfig import org.jetbrains.exposed.v1.core.eq import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.transactions.transaction @@ -76,6 +78,23 @@ class AuthServiceCoreTest { assertEquals(expectedUser, refreshed?.let { service.verify(it.accessToken) }) } + @Test + fun `configured refresh lifetime is stored for new sessions`() { + val before = System.currentTimeMillis() + val service = AuthService( + "test-secret", + sessionConfig = AuthSessionConfig(refreshTtlDays = 3), + ) + + service.register("ttl@test.local", "secret-1", "TTL") + val expiresAt = transaction { + SessionsTable.selectAll().single()[SessionsTable.expiresAt] + } + + val expected = before + 3L * 24L * 60L * 60L * 1000L + assertTrue(expiresAt in expected..(expected + 5_000L)) + } + @Test fun `login supports public username identifier`() { val service = AuthService("test-secret") diff --git a/src/test/kotlin/dev/typetype/server/services/AuthSessionConfigTest.kt b/src/test/kotlin/dev/typetype/server/services/AuthSessionConfigTest.kt new file mode 100644 index 00000000..859a731e --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/AuthSessionConfigTest.kt @@ -0,0 +1,40 @@ +package dev.typetype.server.services + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AuthSessionConfigTest { + @Test + fun `defaults keep secure thirty day sessions`() { + val config = AuthSessionConfig.fromEnvironment { null } + + assertEquals(30L, config.refreshTtlDays) + assertFalse(config.allowInsecureCookies) + } + + @Test + fun `session duration is bounded`() { + val belowMinimum = AuthSessionConfig.fromEnvironment { name -> + if (name == "AUTH_SESSION_TTL_DAYS") "0" else null + } + val aboveMaximum = AuthSessionConfig.fromEnvironment { name -> + if (name == "AUTH_SESSION_TTL_DAYS") "900" else null + } + + assertEquals(1L, belowMinimum.refreshTtlDays) + assertEquals(365L, aboveMaximum.refreshTtlDays) + } + + @Test + fun `insecure cookies require an explicit enabled value`() { + val disabled = AuthSessionConfig.fromEnvironment { "unexpected" } + val enabled = AuthSessionConfig.fromEnvironment { name -> + if (name == "AUTH_ALLOW_INSECURE_COOKIES") "yes" else null + } + + assertFalse(disabled.allowInsecureCookies) + assertTrue(enabled.allowInsecureCookies) + } +} From 71d3b9268eb1879c20dcd15d144f8afeacc9f690 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 5 Aug 2026 11:24:00 +0200 Subject: [PATCH 30/33] test: make fake cache safe for concurrent refreshes --- src/test/kotlin/dev/typetype/server/FakeCacheService.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/test/kotlin/dev/typetype/server/FakeCacheService.kt b/src/test/kotlin/dev/typetype/server/FakeCacheService.kt index 89b43033..ac2319ec 100644 --- a/src/test/kotlin/dev/typetype/server/FakeCacheService.kt +++ b/src/test/kotlin/dev/typetype/server/FakeCacheService.kt @@ -1,9 +1,10 @@ package dev.typetype.server import dev.typetype.server.cache.CacheService +import java.util.concurrent.ConcurrentHashMap class FakeCacheService : CacheService { - private val values = mutableMapOf() + private val values = ConcurrentHashMap() override suspend fun get(key: String): String? = values[key] From 8895f8a61d15598c71758c7046a23804a0340084 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 5 Aug 2026 12:24:08 +0200 Subject: [PATCH 31/33] fix: filter unavailable Takeout videos --- .../YoutubeTakeoutActivitySignalService.kt | 15 +----- .../services/YoutubeTakeoutHistoryParser.kt | 1 + .../services/YoutubeTakeoutParserService.kt | 1 + .../services/YoutubeTakeoutRowParser.kt | 9 ++++ .../services/YoutubeTakeoutUnavailableItem.kt | 18 +++++++ .../server/YoutubeTakeoutParserServiceTest.kt | 47 +++++++++++++++++++ 6 files changed, 77 insertions(+), 14 deletions(-) create mode 100644 src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutUnavailableItem.kt diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutActivitySignalService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutActivitySignalService.kt index 4c3fdabe..c14db660 100644 --- a/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutActivitySignalService.kt +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutActivitySignalService.kt @@ -38,7 +38,7 @@ object YoutubeTakeoutActivitySignalService { private fun parseFavorites(html: String): List { return likedRegex.findAll(html).mapNotNull { match -> val title = decode(match.groupValues[2]) - if (isUnavailable(title)) return@mapNotNull null + if (YoutubeTakeoutUnavailableItem.matches(title)) return@mapNotNull null val source = decode(match.groupValues[1]) + " " + title val videoUrl = watchUrlRegex.find(source)?.value?.replace("http://", "https://") ?: return@mapNotNull null @@ -52,9 +52,6 @@ object YoutubeTakeoutActivitySignalService { }.toList() } - private fun isUnavailable(title: String): Boolean = - YoutubeTakeoutTextNormalizer.normalize(title) in unavailableTitles - private fun decode(value: String): String { return value .replace(" ", " ") @@ -66,14 +63,4 @@ object YoutubeTakeoutActivitySignalService { .replace(spacesRegex, " ") .trim() } - - private val unavailableTitles = setOf( - "deleted video", - "private video", - "video unavailable", - "video deleted", - "video indisponible", - "video privee", - "video supprimee", - ) } diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutHistoryParser.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutHistoryParser.kt index 472a3b34..1cee2b5a 100644 --- a/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutHistoryParser.kt +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutHistoryParser.kt @@ -13,6 +13,7 @@ object YoutubeTakeoutHistoryParser { return rowRegex.findAll(resolvedHtml).mapNotNull { match -> val url = extractUrl(match.groupValues[1]) ?: return@mapNotNull null val title = decode(match.groupValues[2]) + if (YoutubeTakeoutUnavailableItem.matches(title)) return@mapNotNull null val channelUrl = match.groupValues[3].takeIf { it.isNotBlank() }.orEmpty() val channelName = decode(match.groupValues[4]).ifBlank { "Unknown channel" } val watchedAt = parseDate(decode(match.groupValues[5])) diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutParserService.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutParserService.kt index 562e56c9..1196917a 100644 --- a/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutParserService.kt +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutParserService.kt @@ -32,6 +32,7 @@ class YoutubeTakeoutParserService { }.toMap() val rawPlaylistItems = mutableMapOf>() scan.playlistItemsRows.forEach { row -> + if (YoutubeTakeoutRowParser.isUnavailablePlaylistItem(scan.playlistItemsHeader, row)) return@forEach val parsed = runCatching { YoutubeTakeoutRowParser.parsePlaylistItem(scan.playlistItemsHeader, row) }.getOrNull() if (parsed == null) { errors += "Invalid playlist item row" diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutRowParser.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutRowParser.kt index 0fa8fd81..d9cf8d5f 100644 --- a/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutRowParser.kt +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutRowParser.kt @@ -33,6 +33,7 @@ object YoutubeTakeoutRowParser { fun parsePlaylistItem(header: List, row: List): Pair? { val values = header.zip(row).toMap() + if (isUnavailablePlaylistItem(values)) return null val playlistKey = values.pickExact("playlist source key") ?: values.pickHeader(YoutubeTakeoutSchemaHints::isPlaylistIdHeader) ?: values.pickHeader(YoutubeTakeoutSchemaHints::isPlaylistTitleHeader) @@ -63,6 +64,14 @@ object YoutubeTakeoutRowParser { ) } + fun isUnavailablePlaylistItem(header: List, row: List): Boolean = + isUnavailablePlaylistItem(header.zip(row).toMap()) + + private fun isUnavailablePlaylistItem(values: Map): Boolean = + values.pickHeader(YoutubeTakeoutSchemaHints::isVideoTitleHeader) + ?.let(YoutubeTakeoutUnavailableItem::matches) + ?: false + private fun Map.pickExact(vararg keys: String): String? { val normalized = entries.associate { YoutubeTakeoutSchemaHints.normalize(it.key) to it.value.trim() } return keys.asSequence().mapNotNull { normalized[it] }.firstOrNull { it.isNotBlank() } diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutUnavailableItem.kt b/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutUnavailableItem.kt new file mode 100644 index 00000000..808d72b7 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/YoutubeTakeoutUnavailableItem.kt @@ -0,0 +1,18 @@ +package dev.typetype.server.services + +internal object YoutubeTakeoutUnavailableItem { + fun matches(title: String): Boolean = + YoutubeTakeoutTextNormalizer.normalize(title) in TITLES + + private val TITLES = setOf( + "deleted video", + "private video", + "video deleted", + "video indisponible", + "video no disponible", + "video privee", + "video privado", + "video supprimee", + "video unavailable", + ) +} diff --git a/src/test/kotlin/dev/typetype/server/YoutubeTakeoutParserServiceTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeTakeoutParserServiceTest.kt index 55970acc..5b960a53 100644 --- a/src/test/kotlin/dev/typetype/server/YoutubeTakeoutParserServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/YoutubeTakeoutParserServiceTest.kt @@ -137,6 +137,47 @@ class YoutubeTakeoutParserServiceTest { Files.deleteIfExists(zip) } + @Test + fun `parse omits unavailable videos from takeout collections and activity`() { + val zip = Files.createTempFile("yt-takeout-unavailable-", ".zip") + ZipOutputStream(Files.newOutputStream(zip)).use { out -> + out.writeEntry( + "Takeout/YouTube/playlists/playlists.csv", + "Playlist ID,Playlist Title\nPL123456,Imported\n", + ) + out.writeEntry( + "Takeout/YouTube/playlists/Imported.csv", + "Video ID,Video Title\nkeep000001,Available title\ngone000001,Deleted video\n", + ) + out.writeEntry( + "Takeout/YouTube/playlists/Watch later.csv", + "Video ID,Video Title\nkeep000002,Another title\ngone000002,Vidéo privée\n", + ) + out.writeEntry( + "Takeout/YouTube/playlists/Liked videos.csv", + "Video ID,Video Title\nkeep000003,Liked title\ngone000003,Video no disponible\n", + ) + out.writeEntry( + "Takeout/My Activity/YouTube/watch-history.html", + """ + You watched Watched title
+ 1 Jan 2026, 12:00:00 CET
+ You watched Video unavailable
+ 1 Jan 2026, 13:00:00 CET
+ """.trimIndent(), + ) + } + + val parsed = YoutubeTakeoutParserService().parse(zip) + + assertEquals(listOf("keep000001"), parsed.playlistItems["Imported"]?.map { it.url.substringAfter("v=") }) + assertEquals(listOf("keep000002"), parsed.watchLater.map { it.url.substringAfter("v=") }) + assertEquals(listOf("keep000003"), parsed.favorites.map { it.videoUrl.substringAfter("v=") }) + assertEquals(listOf("keep000004"), parsed.history.map { it.url.substringAfter("v=") }) + assertTrue(parsed.errors.isEmpty()) + Files.deleteIfExists(zip) + } + private fun createZip(): Path { val zip = Files.createTempFile("yt-takeout-parser-", ".zip") ZipOutputStream(Files.newOutputStream(zip)).use { out -> @@ -158,4 +199,10 @@ class YoutubeTakeoutParserServiceTest { } return zip } + + private fun ZipOutputStream.writeEntry(path: String, content: String) { + putNextEntry(ZipEntry(path)) + write(content.toByteArray()) + closeEntry() + } } From 51786d2adc798e579c1abfcf1c98069857819ed0 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 6 Aug 2026 09:52:43 +0200 Subject: [PATCH 32/33] fix: align YouTube clients with PipePipeExtractor --- build.gradle.kts | 2 +- .../dev/typetype/server/ExtractionServiceRegistry.kt | 4 ++-- .../typetype/server/services/YoutubePlayerClient.kt | 2 +- src/test/kotlin/dev/typetype/server/ExtractionTest.kt | 6 +++--- .../server/YoutubeAuthenticatedExtractionProbeTest.kt | 2 +- .../YoutubePlayerClientFallbackStreamServiceTest.kt | 8 ++++---- .../services/YoutubePlayerClientStreamServiceTest.kt | 10 +++++----- 7 files changed, 17 insertions(+), 17 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index f47efe67..d4655639 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -38,7 +38,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.6.1") - implementation("com.github.Priveetee.PipePipeExtractor:extractor:c0184bf9b8534076492f0d233697af5ed5f015fe") + implementation("com.github.Priveetee.PipePipeExtractor:extractor:f156813dd4bbebf3b4dffe541fee6c27ae1dd294") compileOnly("com.github.TeamNewPipe:nanojson:1d9e1aea9049fc9f85e68b43ba39fe7be1c1f751") implementation("org.json:json:20260719") implementation("com.squareup.okhttp3:okhttp:5.4.0") diff --git a/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt index 59dcdffb..1fe29a3e 100644 --- a/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/ExtractionServiceRegistry.kt @@ -93,11 +93,11 @@ internal class ExtractionServiceRegistry( ) private val publicStreamService = YoutubePlayerClientStreamService( directPipePipeStreamService, - YoutubePlayerClient.WEB_SAFARI, + YoutubePlayerClient.VISIONOS, ) private val authenticatedStreamService = YoutubePlayerClientFallbackStreamService( directPipePipeStreamService, - listOf(YoutubePlayerClient.TV_DOWNGRADED, YoutubePlayerClient.WEB_SAFARI), + listOf(YoutubePlayerClient.TV_DOWNGRADED, YoutubePlayerClient.VISIONOS), ) private val sabrPublicStreamService = YoutubePlayerClientStreamService( sabrPipePipeStreamService, diff --git a/src/main/kotlin/dev/typetype/server/services/YoutubePlayerClient.kt b/src/main/kotlin/dev/typetype/server/services/YoutubePlayerClient.kt index 62d276cf..90fda099 100644 --- a/src/main/kotlin/dev/typetype/server/services/YoutubePlayerClient.kt +++ b/src/main/kotlin/dev/typetype/server/services/YoutubePlayerClient.kt @@ -2,6 +2,6 @@ package dev.typetype.server.services internal enum class YoutubePlayerClient(val value: String) { MWEB("mweb"), - WEB_SAFARI("web_safari"), + VISIONOS("visionos"), TV_DOWNGRADED("tv_downgraded"), } diff --git a/src/test/kotlin/dev/typetype/server/ExtractionTest.kt b/src/test/kotlin/dev/typetype/server/ExtractionTest.kt index 52483ea5..858fe6a2 100644 --- a/src/test/kotlin/dev/typetype/server/ExtractionTest.kt +++ b/src/test/kotlin/dev/typetype/server/ExtractionTest.kt @@ -27,7 +27,7 @@ private object NoOpCache : CacheService { class ExtractionTest { private val service = PipePipeStreamService(NoOpCache, YouTubeSubtitleService(OkHttpClient(), "http://localhost:8081"), BilibiliRelatedService()) - private val safariClassicService = YoutubePlayerClientStreamService(service, YoutubePlayerClient.WEB_SAFARI) + private val visionOsClassicService = YoutubePlayerClientStreamService(service, YoutubePlayerClient.VISIONOS) @BeforeAll fun setup() { @@ -35,8 +35,8 @@ class ExtractionTest { } @Test - fun `YouTube classic Safari path has playable streams without SABR`() = kotlinx.coroutines.runBlocking { - val result = safariClassicService.getStreamInfo("https://www.youtube.com/watch?v=dQw4w9WgXcQ") + fun `YouTube classic visionOS path has playable streams without SABR`() = kotlinx.coroutines.runBlocking { + val result = visionOsClassicService.getStreamInfo("https://www.youtube.com/watch?v=dQw4w9WgXcQ") assertTrue(result is ExtractionResult.Success) val data = (result as ExtractionResult.Success).data val streams = data.videoStreams + data.videoOnlyStreams diff --git a/src/test/kotlin/dev/typetype/server/YoutubeAuthenticatedExtractionProbeTest.kt b/src/test/kotlin/dev/typetype/server/YoutubeAuthenticatedExtractionProbeTest.kt index 4fb7c168..0f01f486 100644 --- a/src/test/kotlin/dev/typetype/server/YoutubeAuthenticatedExtractionProbeTest.kt +++ b/src/test/kotlin/dev/typetype/server/YoutubeAuthenticatedExtractionProbeTest.kt @@ -53,7 +53,7 @@ class YoutubeAuthenticatedExtractionProbeTest { ) val service = YoutubePlayerClientFallbackStreamService( pipePipe, - listOf(YoutubePlayerClient.TV_DOWNGRADED, YoutubePlayerClient.WEB_SAFARI), + listOf(YoutubePlayerClient.TV_DOWNGRADED, YoutubePlayerClient.VISIONOS), ) val result = YoutubeSessionTokenScope.withCredentials(credentials) { diff --git a/src/test/kotlin/dev/typetype/server/services/YoutubePlayerClientFallbackStreamServiceTest.kt b/src/test/kotlin/dev/typetype/server/services/YoutubePlayerClientFallbackStreamServiceTest.kt index be2e9474..c3545fc5 100644 --- a/src/test/kotlin/dev/typetype/server/services/YoutubePlayerClientFallbackStreamServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/YoutubePlayerClientFallbackStreamServiceTest.kt @@ -24,14 +24,14 @@ class YoutubePlayerClientFallbackStreamServiceTest { } val service = YoutubePlayerClientFallbackStreamService( delegate, - listOf(YoutubePlayerClient.WEB_SAFARI, YoutubePlayerClient.TV_DOWNGRADED), + listOf(YoutubePlayerClient.VISIONOS, YoutubePlayerClient.TV_DOWNGRADED), ) val result = service.getStreamInfo(YOUTUBE_URL) assertSame(success, result) assertEquals( - listOf(YoutubePlayerClient.WEB_SAFARI.value, YoutubePlayerClient.TV_DOWNGRADED.value), + listOf(YoutubePlayerClient.VISIONOS.value, YoutubePlayerClient.TV_DOWNGRADED.value), observed, ) assertEquals(YoutubePlayerClient.MWEB.value, NewPipe.getYoutubePlayerClient()) @@ -49,13 +49,13 @@ class YoutubePlayerClientFallbackStreamServiceTest { } val service = YoutubePlayerClientFallbackStreamService( delegate, - listOf(YoutubePlayerClient.WEB_SAFARI, YoutubePlayerClient.TV_DOWNGRADED), + listOf(YoutubePlayerClient.VISIONOS, YoutubePlayerClient.TV_DOWNGRADED), ) val result = service.getStreamInfo(YOUTUBE_URL) assertSame(success, result) - assertEquals(listOf(YoutubePlayerClient.WEB_SAFARI.value), observed) + assertEquals(listOf(YoutubePlayerClient.VISIONOS.value), observed) } private companion object { diff --git a/src/test/kotlin/dev/typetype/server/services/YoutubePlayerClientStreamServiceTest.kt b/src/test/kotlin/dev/typetype/server/services/YoutubePlayerClientStreamServiceTest.kt index edc77c56..75b7584a 100644 --- a/src/test/kotlin/dev/typetype/server/services/YoutubePlayerClientStreamServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/YoutubePlayerClientStreamServiceTest.kt @@ -18,15 +18,15 @@ import org.schabi.newpipe.extractor.NewPipe class YoutubePlayerClientStreamServiceTest { @Test - fun `classic extraction selects web safari and restores mweb`() = runBlocking { + fun `classic extraction selects visionOS and restores mweb`() = runBlocking { NewPipe.setYoutubePlayerClient(YoutubePlayerClient.MWEB.value) val observed = mutableListOf() val delegate = recordingService(observed) - val service = YoutubePlayerClientStreamService(delegate, YoutubePlayerClient.WEB_SAFARI) + val service = YoutubePlayerClientStreamService(delegate, YoutubePlayerClient.VISIONOS) service.getStreamInfo(YOUTUBE_URL) - assertEquals(listOf(YoutubePlayerClient.WEB_SAFARI.value), observed) + assertEquals(listOf(YoutubePlayerClient.VISIONOS.value), observed) assertEquals(YoutubePlayerClient.MWEB.value, NewPipe.getYoutubePlayerClient()) } @@ -52,7 +52,7 @@ class YoutubePlayerClientStreamServiceTest { } } val sabr = YoutubePlayerClientStreamService(delegate, YoutubePlayerClient.MWEB) - val classic = YoutubePlayerClientStreamService(delegate, YoutubePlayerClient.WEB_SAFARI) + val classic = YoutubePlayerClientStreamService(delegate, YoutubePlayerClient.VISIONOS) val sabrJobs = List(2) { launch { sabr.getStreamInfo(YOUTUBE_URL) } } repeat(2) { sabrEntered.receive() } @@ -65,7 +65,7 @@ class YoutubePlayerClientStreamServiceTest { assertTrue(classicEntered.get()) assertEquals(4, observations.count { it == YoutubePlayerClient.MWEB.value }) - assertEquals(2, observations.count { it == YoutubePlayerClient.WEB_SAFARI.value }) + assertEquals(2, observations.count { it == YoutubePlayerClient.VISIONOS.value }) assertEquals(YoutubePlayerClient.MWEB.value, NewPipe.getYoutubePlayerClient()) } From e5ad1a34e01c15a6d8de31c5787bf9617f162599 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 6 Aug 2026 10:58:29 +0200 Subject: [PATCH 33/33] chore: prepare server 1.4.0 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 6d84d772..99db84dc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ org.gradle.jvmargs=-Xmx2g -XX:+UseG1GC kotlin.code.style=official -appVersion=1.3.1 +appVersion=1.4.0 systemProp.sun.net.client.defaultReadTimeout=180000 systemProp.sun.net.client.defaultConnectTimeout=60000