From 7b3d7818e64fae58b9a95de39db4dc6b2bad36a9 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 30 Jul 2026 18:46:03 +0200 Subject: [PATCH 1/5] 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 f83b3a4f924b2016752f37d6e38a24952bb0475c Mon Sep 17 00:00:00 2001 From: Priveetee Date: Thu, 30 Jul 2026 18:46:18 +0200 Subject: [PATCH 2/5] 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 9a4a8a55661906a5fdc70a4fdc08166832427580 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 31 Jul 2026 11:04:36 +0200 Subject: [PATCH 3/5] 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 1126b46f6d3a53aa7445fc7d8f97423254d85bf7 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 31 Jul 2026 11:19:30 +0200 Subject: [PATCH 4/5] 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 7a1aee30bd205cefa3de1b0754af5a32520fe3e2 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Fri, 31 Jul 2026 11:47:16 +0200 Subject: [PATCH 5/5] chore: prepare server 1.3.1 --- gradle.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle.properties b/gradle.properties index 0a6bd48a..6d84d772 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ org.gradle.jvmargs=-Xmx2g -XX:+UseG1GC kotlin.code.style=official -appVersion=1.3.0 +appVersion=1.3.1 systemProp.sun.net.client.defaultReadTimeout=180000 systemProp.sun.net.client.defaultConnectTimeout=60000