diff --git a/docs/android-playback.md b/docs/android-playback.md index 96faed3c..4ae8252a 100644 --- a/docs/android-playback.md +++ b/docs/android-playback.md @@ -1,7 +1,7 @@ # Android playback contract TypeType-Android discovers this contract through `androidPlayback` in -`GET /api/instance`. Contract version 1 supports completed YouTube VODs only; +`GET /api/instance`. Contract version 4 supports completed YouTube VODs only; active livestreams remain explicitly unsupported. Android playback sessions use `/api/android/youtube/playback/*` and are isolated @@ -10,8 +10,34 @@ window protocol. A ready VOD manifest is a complete static DASH presentation from time zero. The server requires exact audio and video segment indexes, but continues to fetch media bytes on demand. -Creating a session may return `202` while the exact indexes are preparing. A -seek keeps the session ID and selected itags stable, increments the generation, +Creating a session may return `202` while one shared background task obtains the +exact initialization indexes. The response includes `preparationStage` and +`retryAfterMs`; manifest polling only reads that task's current state and never +starts duplicate network work. Preparation has an eight-second server deadline. +It ends with a complete static MPD, `422 android_playback_invalid_index`, or a +typed `503` (`android_playback_preparation_timeout` or +`android_playback_preparation_failed`). A client should retry only the same +manifest URL after `retryAfterMs` and create a new session after a terminal +error. + +The index task fetches only the selected formats' bounded initialization ranges. +It does not preload media. The SABR pump starts later when Media3 requests media +segments, so segment delivery remains demand-driven. + +A seek keeps the session ID and selected itags stable, increments the generation, and makes older media URLs return `409`. Unknown sessions return `404`; recently -expired sessions return `410`. Session manifests and media remain same-origin -and use `Cache-Control: no-store`. +expired sessions return `410`. Expiration cancels unfinished preparation work. +Session manifests and media remain same-origin and use `Cache-Control: no-store`. + +Every successful creation response contains the complete authoritative +`subtitles` descriptor catalog. Videos without captions return an empty list. +The catalog is copied into the playback session and remains unchanged across +seek generations, so Android can attach every `SubtitleConfiguration` before +the first Media3 preparation. + +Descriptor discovery reads caption metadata only. It does not fetch or convert +a subtitle document. Each descriptor points to a session-scoped +`/api/android/youtube/playback/{sessionId}/subtitles/{trackId}.vtt` resource, +and the server obtains that WebVTT content only when the resource is requested. +The `deferredSubtitleContent` and `bootstrapSubtitleDescriptors` capabilities +advertise these separate guarantees. diff --git a/gradle.properties b/gradle.properties index afa5c937..a1c3ea70 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,5 +1,5 @@ org.gradle.jvmargs=-Xmx2g -XX:+UseG1GC kotlin.code.style=official -appVersion=0.1.0 +appVersion=1.2.1 systemProp.sun.net.client.defaultReadTimeout=180000 systemProp.sun.net.client.defaultConnectTimeout=60000 diff --git a/openapi/components/android-playback.yaml b/openapi/components/android-playback.yaml index a7fe4c38..9ab0d961 100644 --- a/openapi/components/android-playback.yaml +++ b/openapi/components/android-playback.yaml @@ -32,8 +32,14 @@ AndroidPlaybackResponse: generation: { type: integer, format: int64, minimum: 0 } ready: { type: boolean } status: { type: string, enum: [ready, preparing] } + preparationStage: + type: string + nullable: true + enum: [audio_index, video_index, audio_video_index, media_bytes] + description: Current bounded preparation stage when ready is false. subtitles: type: array + description: Complete authoritative descriptor catalog. Subtitle content remains demand-driven. items: { $ref: '#/AndroidSubtitle' } retryAfterMs: { type: integer, format: int64, nullable: true, minimum: 100, maximum: 2000 } AndroidSubtitle: diff --git a/openapi/components/instance.yaml b/openapi/components/instance.yaml index 8cdf3b67..7d8db7f2 100644 --- a/openapi/components/instance.yaml +++ b/openapi/components/instance.yaml @@ -57,13 +57,21 @@ AndroidPlaybackCapability: required: [supported, contractVersion, youtube] properties: supported: { type: boolean, example: true } - contractVersion: { type: integer, enum: [2] } + contractVersion: { type: integer, enum: [4] } youtube: $ref: '#/AndroidYoutubePlaybackCapability' AndroidYoutubePlaybackCapability: type: object - required: [vod, live, subtitles] + required: [vod, live, subtitles, deferredSubtitleContent, bootstrapSubtitleDescriptors] properties: vod: { type: boolean, example: true } live: { type: boolean, example: false } subtitles: { type: boolean, example: true } + deferredSubtitleContent: + type: boolean + example: true + description: Subtitle documents are fetched only when a session-scoped WebVTT resource is requested. + bootstrapSubtitleDescriptors: + type: boolean + example: true + description: Every successful playback creation response contains the complete subtitle descriptor catalog. diff --git a/openapi/paths/android-playback.yaml b/openapi/paths/android-playback.yaml index 53f4919c..431a1a63 100644 --- a/openapi/paths/android-playback.yaml +++ b/openapi/paths/android-playback.yaml @@ -7,7 +7,7 @@ Create: post: tags: [android-playback] summary: Create an Android-only YouTube VOD playback session - description: Active livestreams are rejected. Media remains demand-driven after the complete virtual index is ready. The response includes the authoritative subtitle inventory before Media3 prepares the item. + description: Active livestreams are rejected. Every successful response contains the complete authoritative subtitle descriptor catalog, including an empty list when the video has no captions. Descriptor discovery does not fetch subtitle documents; media and WebVTT content remain demand-driven. A 202 response represents one shared session preparation with an eight-second server deadline. requestBody: required: false content: @@ -41,12 +41,14 @@ Seek: '409': { $ref: ../components/common.yaml#/JsonError } '410': { $ref: ../components/common.yaml#/JsonError } '422': { $ref: ../components/common.yaml#/JsonError } + '503': { $ref: ../components/common.yaml#/JsonError } Manifest: parameters: - $ref: '#/components/parameters/SessionId' get: tags: [android-playback] summary: Get the complete static DASH presentation for an Android VOD session + description: Polling reads the current session state only and does not restart index extraction. Preparation ends as a complete MPD or a typed 422 or 503 response. responses: '200': description: Complete standards-compatible static DASH MPD. @@ -60,6 +62,7 @@ Manifest: '404': { $ref: ../components/common.yaml#/JsonError } '410': { $ref: ../components/common.yaml#/JsonError } '422': { $ref: ../components/common.yaml#/JsonError } + '503': { $ref: ../components/common.yaml#/JsonError } Subtitle: parameters: - $ref: '#/components/parameters/SessionId' @@ -67,7 +70,7 @@ Subtitle: get: tags: [android-playback] summary: Get one session-scoped Android subtitle as UTF-8 WebVTT - description: The caller must use the same account context that created the playback session. The resource remains valid across seek generations. + description: The caller must use the same account context that created the playback session. The resource remains valid across seek generations and subtitle content is fetched only when this route is requested. responses: '200': description: Complete UTF-8 WebVTT subtitle document. @@ -154,7 +157,7 @@ components: application/json: schema: { $ref: ../components/android-playback.yaml#/AndroidPlaybackResponse } PreparingSession: - description: The exact virtual segment index or requested bytes are still preparing. + description: The shared exact virtual index or requested bytes are still preparing. Retry after retryAfterMs without creating another session. content: application/json: schema: { $ref: ../components/android-playback.yaml#/AndroidPlaybackResponse } diff --git a/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/TypeTypeYoutubeSabrInfoFactory.java b/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/TypeTypeYoutubeSabrInfoFactory.java index 614b2a0e..e192b2a0 100644 --- a/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/TypeTypeYoutubeSabrInfoFactory.java +++ b/src/main/java/org/schabi/newpipe/extractor/services/youtube/sabr/TypeTypeYoutubeSabrInfoFactory.java @@ -4,16 +4,18 @@ public final class TypeTypeYoutubeSabrInfoFactory { private TypeTypeYoutubeSabrInfoFactory() { } - public static YoutubeSabrInfo withPlaybackUrlAndClientVersion( + public static YoutubeSabrInfo withPlaybackIdentity( final YoutubeSabrInfo info, final String serverAbrStreamingUrl, - final String clientVersion) { + final String clientVersion, + final String cpn, + final String visitorData) { return new YoutubeSabrInfo( info.getProfile(), info.getVideoId(), - info.getCpn(), + cpn, clientVersion, - info.getVisitorData(), + visitorData, serverAbrStreamingUrl, info.getVideoPlaybackUstreamerConfig(), info.getFormats()); diff --git a/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt b/src/main/kotlin/dev/typetype/server/ApplicationRoutes.kt index 3963907e..174bbc62 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.userDataRoutes import dev.typetype.server.routes.youtubeRemoteBrowserRoutes import dev.typetype.server.services.ActiveSessionService import dev.typetype.server.services.AdminSettingsService +import dev.typetype.server.services.AndroidSubtitleInventoryCoordinator import dev.typetype.server.services.AuthService import dev.typetype.server.services.AvatarService import dev.typetype.server.services.DownloaderGatewayService @@ -62,6 +63,7 @@ internal fun Application.installApplicationRoutes( restoreService: PipePipeBackupImporterService, youtubeRemoteBrowserService: YoutubeRemoteBrowserService, ) { + val androidSubtitleCoordinator = AndroidSubtitleInventoryCoordinator(svc.androidSubtitleService, this) routing { internalObservabilityRoutes(internalHealthService::check) publicMetadataRoutes(instanceService::getInstance) @@ -85,6 +87,7 @@ internal fun Application.installApplicationRoutes( svc.androidSabrSessionStore, svc.streamService, svc.androidSubtitleService, + androidSubtitleCoordinator, authService, svc.accessControlService, adminSettingsService, diff --git a/src/main/kotlin/dev/typetype/server/models/InstanceResponse.kt b/src/main/kotlin/dev/typetype/server/models/InstanceResponse.kt index 51097404..a4cd5e69 100644 --- a/src/main/kotlin/dev/typetype/server/models/InstanceResponse.kt +++ b/src/main/kotlin/dev/typetype/server/models/InstanceResponse.kt @@ -30,7 +30,7 @@ data class InstanceResponse( @Serializable data class AndroidPlaybackCapability( val supported: Boolean = true, - val contractVersion: Int = 2, + val contractVersion: Int = 4, val youtube: AndroidYoutubePlaybackCapability = AndroidYoutubePlaybackCapability(), ) @@ -39,4 +39,6 @@ data class AndroidYoutubePlaybackCapability( val vod: Boolean = true, val live: Boolean = false, val subtitles: Boolean = true, + val deferredSubtitleContent: Boolean = true, + val bootstrapSubtitleDescriptors: Boolean = true, ) diff --git a/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackHandler.kt b/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackHandler.kt index 6b0499c8..0d25ee60 100644 --- a/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackHandler.kt +++ b/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackHandler.kt @@ -7,8 +7,8 @@ import dev.typetype.server.services.AndroidDashManifestResult import dev.typetype.server.services.AndroidPlaybackCreateResult import dev.typetype.server.services.AndroidPlaybackSeekResult import dev.typetype.server.services.AndroidPlaybackService -import dev.typetype.server.services.AndroidSubtitleInventoryResult -import dev.typetype.server.services.AndroidSubtitleService +import dev.typetype.server.services.AndroidSubtitleInventoryCoordinator +import dev.typetype.server.services.AndroidSubtitleInventorySnapshot import dev.typetype.server.services.AuthService import dev.typetype.server.services.SabrSessionStore import dev.typetype.server.services.StreamService @@ -18,8 +18,6 @@ import io.ktor.server.application.ApplicationCall import io.ktor.server.request.receiveNullable import io.ktor.server.response.respond import io.ktor.server.response.respondText -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope internal class AndroidPlaybackHandler( private val store: SabrSessionStore, @@ -27,7 +25,7 @@ internal class AndroidPlaybackHandler( private val authService: AuthService?, private val accessControlService: AccessControlService?, private val adminSettingsService: AdminSettingsService?, - private val subtitleService: AndroidSubtitleService, + private val subtitleCoordinator: AndroidSubtitleInventoryCoordinator, val service: AndroidPlaybackService = AndroidPlaybackService(store), ) { suspend fun create(call: ApplicationCall, videoId: String) { @@ -42,23 +40,24 @@ internal class AndroidPlaybackHandler( ) } val request = requestResult.getOrNull() ?: AndroidPlaybackCreateRequest() - val (prepared, subtitleInventory) = coroutineScope { - val prepared = async { store.fetchInfo(videoId, cachedFirst = true) } - val subtitles = async { subtitleService.inventory(videoId) } - prepared.await() to subtitles.await() - } + val subtitleInventory = subtitleCoordinator.start(videoId) + val prepared = store.fetchInfo(videoId, cachedFirst = true) prepared ?: return call.respondAndroidError( HttpStatusCode.UnprocessableEntity, "android_playback_probe_failed", "SABR probe failed", ) - val subtitles = (subtitleInventory as? AndroidSubtitleInventoryResult.Ready)?.tracks - ?: return call.respondAndroidError( + val subtitles = when (val inventory = subtitleInventory.await()) { + is AndroidSubtitleInventorySnapshot.Ready -> inventory.tracks + AndroidSubtitleInventorySnapshot.Preparing, + AndroidSubtitleInventorySnapshot.TemporaryFailure, + -> return call.respondAndroidError( HttpStatusCode.ServiceUnavailable, "android_subtitle_inventory_unavailable", "Android subtitle inventory is temporarily unavailable", ) + } val audio = SabrFormatSelector.audio(prepared.info, request.audioItag, request.audioTrackId, requireAac = true) ?: return call.respondAndroidError( HttpStatusCode.UnprocessableEntity, @@ -71,9 +70,18 @@ internal class AndroidPlaybackHandler( "android_playback_video_unavailable", "No compatible SABR video for this video", ) - when (val result = service.create(videoId, access.userId ?: "guest", prepared, audio, video, subtitles)) { + when ( + val result = service.create( + videoId, + access.userId ?: "guest", + prepared, + audio, + video, + subtitles, + ) + ) { is AndroidPlaybackCreateResult.Created -> call.respondSession( - result.session.holder.toAndroidPlaybackResponse(result.manifest, result.session.subtitles), + result.session.toAndroidPlaybackResponse(result.manifest), result.manifest, ) AndroidPlaybackCreateResult.UnsupportedLive -> call.respondAndroidError( @@ -103,9 +111,9 @@ internal class AndroidPlaybackHandler( "Invalid seek position", ) } - when (val result = service.seek(holder, request.generation, request.playerTimeMs)) { + when (val result = service.seek(session, request.generation, request.playerTimeMs)) { is AndroidPlaybackSeekResult.Ready -> call.respondSession( - result.holder.toAndroidPlaybackResponse(result.manifest, session.subtitles), + session.withHolder(result.holder).toAndroidPlaybackResponse(result.manifest), result.manifest, ) AndroidPlaybackSeekResult.StaleGeneration -> call.respondAndroidError( @@ -119,14 +127,13 @@ internal class AndroidPlaybackHandler( suspend fun manifest(call: ApplicationCall, sessionId: String) { call.response.headers.append("Cache-Control", "no-store") val session = call.androidPlaybackSession(service, sessionId) ?: return - val holder = session.holder - when (val result = service.manifest(holder)) { + when (val result = service.manifest(session)) { is AndroidDashManifestResult.Ready -> { call.respondText(result.manifest, DASH_CONTENT_TYPE) } - AndroidDashManifestResult.Preparing -> call.respond( + is AndroidDashManifestResult.Preparing -> call.respond( HttpStatusCode.Accepted, - holder.toAndroidPlaybackResponse(result, session.subtitles), + session.toAndroidPlaybackResponse(result), ) AndroidDashManifestResult.UnsupportedLive -> call.respondAndroidError( HttpStatusCode.UnprocessableEntity, @@ -138,6 +145,11 @@ internal class AndroidPlaybackHandler( "android_playback_invalid_index", result.reason, ) + is AndroidDashManifestResult.TemporaryFailure -> call.respondAndroidError( + HttpStatusCode.ServiceUnavailable, + result.code, + result.reason, + ) } } @@ -146,7 +158,7 @@ internal class AndroidPlaybackHandler( manifest: AndroidDashManifestResult, ): Unit = when (manifest) { is AndroidDashManifestResult.Ready -> respond(HttpStatusCode.OK, response) - AndroidDashManifestResult.Preparing -> respond(HttpStatusCode.Accepted, response) + is AndroidDashManifestResult.Preparing -> respond(HttpStatusCode.Accepted, response) AndroidDashManifestResult.UnsupportedLive -> respondAndroidError( HttpStatusCode.UnprocessableEntity, "android_live_playback_unsupported", @@ -157,6 +169,11 @@ internal class AndroidPlaybackHandler( "android_playback_invalid_index", manifest.reason, ) + is AndroidDashManifestResult.TemporaryFailure -> respondAndroidError( + HttpStatusCode.ServiceUnavailable, + manifest.code, + manifest.reason, + ) } private suspend fun validateAccess(call: ApplicationCall, videoId: String, access: AccessRouteProfile): Boolean { diff --git a/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackMediaHandler.kt b/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackMediaHandler.kt index cf212364..18faa069 100644 --- a/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackMediaHandler.kt +++ b/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackMediaHandler.kt @@ -1,6 +1,7 @@ package dev.typetype.server.routes import dev.typetype.server.services.AndroidPlaybackMediaResult +import dev.typetype.server.services.AndroidPlaybackPreparationStage import dev.typetype.server.services.AndroidPlaybackService import io.ktor.http.HttpStatusCode import io.ktor.server.application.ApplicationCall @@ -47,9 +48,10 @@ internal class AndroidPlaybackMediaHandler(private val service: AndroidPlaybackS is AndroidPlaybackMediaResult.Ready -> respondSabrMediaBytes(result.mimeType, result.bytes) AndroidPlaybackMediaResult.Preparing -> respond( HttpStatusCode.Accepted, - session.holder.toAndroidPlaybackResponse( - dev.typetype.server.services.AndroidDashManifestResult.Preparing, - session.subtitles, + session.toAndroidPlaybackResponse( + dev.typetype.server.services.AndroidDashManifestResult.Preparing( + AndroidPlaybackPreparationStage.MEDIA_BYTES, + ), ), ) AndroidPlaybackMediaResult.StaleGeneration -> respondAndroidError( diff --git a/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackModels.kt b/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackModels.kt index 19a97860..d21e3928 100644 --- a/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackModels.kt +++ b/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackModels.kt @@ -1,8 +1,8 @@ package dev.typetype.server.routes import dev.typetype.server.services.AndroidDashManifestResult +import dev.typetype.server.services.AndroidPlaybackSession import dev.typetype.server.services.AndroidSubtitleTrack -import dev.typetype.server.services.SabrSessionHolder import kotlinx.serialization.Serializable @Serializable @@ -31,6 +31,7 @@ internal data class AndroidPlaybackResponse( val status: String, val subtitles: List, val retryAfterMs: Long? = null, + val preparationStage: String? = null, ) @Serializable @@ -43,30 +44,31 @@ internal data class AndroidSubtitleResponse( val url: String, ) -internal fun SabrSessionHolder.toAndroidPlaybackResponse( +internal fun AndroidPlaybackSession.toAndroidPlaybackResponse( manifest: AndroidDashManifestResult, - subtitles: List, ): AndroidPlaybackResponse = AndroidPlaybackResponse( - sessionId = sessionToken, - videoId = key.videoId, - manifestUrl = "/api/android/youtube/playback/$sessionToken/manifest.mpd", - videoItag = videoFormat.itag, - audioItag = audioFormat.itag, - audioTrackId = audioFormat.audioTrackId, - generation = activeGeneration(), + sessionId = holder.sessionToken, + videoId = holder.key.videoId, + manifestUrl = "/api/android/youtube/playback/${holder.sessionToken}/manifest.mpd", + videoItag = holder.videoFormat.itag, + audioItag = holder.audioFormat.itag, + audioTrackId = holder.audioFormat.audioTrackId, + generation = holder.activeGeneration(), ready = manifest is AndroidDashManifestResult.Ready, status = if (manifest is AndroidDashManifestResult.Ready) "ready" else "preparing", - subtitles = subtitles.map { track -> - AndroidSubtitleResponse( - id = track.id, - mimeType = "text/vtt", - languageTag = track.languageTag, - displayLanguageName = track.displayLanguageName, - isAutoGenerated = track.isAutoGenerated, - url = "/api/android/youtube/playback/$sessionToken/subtitles/${track.id}.vtt", - ) - }, + subtitles = subtitles.map { it.toResponse(holder.sessionToken) }, retryAfterMs = if (manifest is AndroidDashManifestResult.Ready) null else ANDROID_RETRY_AFTER_MS, + preparationStage = (manifest as? AndroidDashManifestResult.Preparing)?.stage?.wireValue, ) +internal fun AndroidSubtitleTrack.toResponse(sessionId: String): AndroidSubtitleResponse = + AndroidSubtitleResponse( + id = id, + mimeType = "text/vtt", + languageTag = languageTag, + displayLanguageName = displayLanguageName, + isAutoGenerated = isAutoGenerated, + url = "/api/android/youtube/playback/$sessionId/subtitles/$id.vtt", + ) + internal const val ANDROID_RETRY_AFTER_MS = 500L diff --git a/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackRoutes.kt b/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackRoutes.kt index a7a8b5a2..ef2536b9 100644 --- a/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackRoutes.kt +++ b/src/main/kotlin/dev/typetype/server/routes/AndroidPlaybackRoutes.kt @@ -4,6 +4,7 @@ import dev.typetype.server.models.ErrorResponse import dev.typetype.server.services.AccessControlService import dev.typetype.server.services.AdminSettingsService import dev.typetype.server.services.AndroidSubtitleService +import dev.typetype.server.services.AndroidSubtitleInventoryCoordinator import dev.typetype.server.services.AuthService import dev.typetype.server.services.SabrSessionStore import dev.typetype.server.services.StreamService @@ -17,6 +18,7 @@ internal fun Route.androidPlaybackRoutes( store: SabrSessionStore, streamService: StreamService, subtitleService: AndroidSubtitleService, + subtitleCoordinator: AndroidSubtitleInventoryCoordinator, authService: AuthService?, accessControlService: AccessControlService?, adminSettingsService: AdminSettingsService?, @@ -27,7 +29,7 @@ internal fun Route.androidPlaybackRoutes( authService, accessControlService, adminSettingsService, - subtitleService = subtitleService, + subtitleCoordinator = subtitleCoordinator, ) val media = AndroidPlaybackMediaHandler(handler.service) val subtitles = AndroidSubtitleHandler( diff --git a/src/main/kotlin/dev/typetype/server/routes/AndroidSubtitleHandler.kt b/src/main/kotlin/dev/typetype/server/routes/AndroidSubtitleHandler.kt index f40f5703..af15ac13 100644 --- a/src/main/kotlin/dev/typetype/server/routes/AndroidSubtitleHandler.kt +++ b/src/main/kotlin/dev/typetype/server/routes/AndroidSubtitleHandler.kt @@ -3,6 +3,7 @@ package dev.typetype.server.routes import dev.typetype.server.services.AccessControlService import dev.typetype.server.services.AdminSettingsService import dev.typetype.server.services.AndroidPlaybackService +import dev.typetype.server.services.AndroidPlaybackSession import dev.typetype.server.services.AndroidSubtitleContentResult import dev.typetype.server.services.AndroidSubtitleService import dev.typetype.server.services.AuthService @@ -20,11 +21,7 @@ internal class AndroidSubtitleHandler( ) { suspend fun content(call: ApplicationCall, sessionId: String, trackId: String) { call.response.headers.append("Cache-Control", "no-store") - val access = call.accessProfileOrRespond(authService, accessControlService, adminSettingsService) ?: return - val session = call.androidPlaybackSession(playbackService, sessionId) ?: return - if (session.holder.key.userId != (access.userId ?: "guest")) { - return call.notFound() - } + val session = call.authorizedSession(sessionId) ?: return val track = session.subtitles.firstOrNull { it.id == trackId } ?: return call.notFound() when (val result = subtitleService.content(session.holder.key.videoId, track)) { @@ -46,6 +43,14 @@ internal class AndroidSubtitleHandler( } } + private suspend fun ApplicationCall.authorizedSession(sessionId: String): AndroidPlaybackSession? { + val access = accessProfileOrRespond(authService, accessControlService, adminSettingsService) ?: return null + val session = androidPlaybackSession(playbackService, sessionId) ?: return null + if (session.holder.key.userId == (access.userId ?: "guest")) return session + notFound() + return null + } + private suspend fun ApplicationCall.notFound() { respondAndroidError( HttpStatusCode.NotFound, diff --git a/src/main/kotlin/dev/typetype/server/services/AndroidDashManifestService.kt b/src/main/kotlin/dev/typetype/server/services/AndroidDashManifestService.kt index 86e76799..3a1eb105 100644 --- a/src/main/kotlin/dev/typetype/server/services/AndroidDashManifestService.kt +++ b/src/main/kotlin/dev/typetype/server/services/AndroidDashManifestService.kt @@ -6,8 +6,15 @@ internal class AndroidDashManifestService { val state = holder.session.streamState val audio = AndroidDashTimelineReader.read(state, holder.audioFormat) val video = AndroidDashTimelineReader.read(state, holder.videoFormat) - if (audio is AndroidDashTimelineResult.Pending || video is AndroidDashTimelineResult.Pending) { - return AndroidDashManifestResult.Preparing + val audioPending = audio is AndroidDashTimelineResult.Pending + val videoPending = video is AndroidDashTimelineResult.Pending + if (audioPending || videoPending) { + val stage = when { + audioPending && videoPending -> AndroidPlaybackPreparationStage.AUDIO_VIDEO_INDEX + audioPending -> AndroidPlaybackPreparationStage.AUDIO_INDEX + else -> AndroidPlaybackPreparationStage.VIDEO_INDEX + } + return AndroidDashManifestResult.Preparing(stage) } if (audio is AndroidDashTimelineResult.Invalid) return AndroidDashManifestResult.Invalid(audio.reason) if (video is AndroidDashTimelineResult.Invalid) return AndroidDashManifestResult.Invalid(video.reason) @@ -40,7 +47,15 @@ internal class AndroidDashManifestService { internal sealed interface AndroidDashManifestResult { data class Ready(val manifest: String, val durationMs: Long) : AndroidDashManifestResult - data object Preparing : AndroidDashManifestResult + data class Preparing(val stage: AndroidPlaybackPreparationStage) : AndroidDashManifestResult data object UnsupportedLive : AndroidDashManifestResult data class Invalid(val reason: String) : AndroidDashManifestResult + data class TemporaryFailure(val code: String, val reason: String) : AndroidDashManifestResult +} + +internal enum class AndroidPlaybackPreparationStage(val wireValue: String) { + AUDIO_INDEX("audio_index"), + VIDEO_INDEX("video_index"), + AUDIO_VIDEO_INDEX("audio_video_index"), + MEDIA_BYTES("media_bytes"), } diff --git a/src/main/kotlin/dev/typetype/server/services/AndroidPlaybackPreparation.kt b/src/main/kotlin/dev/typetype/server/services/AndroidPlaybackPreparation.kt new file mode 100644 index 00000000..a6b6daed --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/AndroidPlaybackPreparation.kt @@ -0,0 +1,115 @@ +package dev.typetype.server.services + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat +import org.slf4j.LoggerFactory +import java.io.IOException +import java.util.concurrent.atomic.AtomicReference + +internal class AndroidPlaybackPreparation { + private val job = AtomicReference() + private val failure = AtomicReference() + + fun start(scope: CoroutineScope, block: suspend () -> Unit): Unit { + val candidate = scope.launch(start = CoroutineStart.LAZY) { block() } + if (job.compareAndSet(null, candidate)) candidate.start() else candidate.cancel() + } + + fun fail(result: AndroidDashManifestResult.TemporaryFailure): Unit { + failure.compareAndSet(null, result) + } + + fun result(holder: SabrSessionHolder, manifests: AndroidDashManifestService): AndroidDashManifestResult = + failure.get() ?: manifests.build(holder) + + fun cancel(): Unit { + job.getAndSet(null)?.cancel() + } +} + +internal class AndroidPlaybackPreparationCoordinator( + private val store: SabrSessionStore, + private val manifests: AndroidDashManifestService, + private val timeoutMs: Long = PREPARATION_TIMEOUT_MS, + private val initializationTimeoutMs: Long = INITIALIZATION_TIMEOUT_MS, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + private val exactInitializer: suspend (SabrSessionHolder, YoutubeSabrFormat, Long) -> ByteArray = + store::fetchExactInitializationData, +) { + fun start(session: AndroidPlaybackSession): Unit { + if (manifests.build(session.holder) !is AndroidDashManifestResult.Preparing) return + session.preparation.start(scope) { prepare(session) } + } + + private suspend fun prepare(session: AndroidPlaybackSession): Unit { + val holder = session.holder + val startedAt = System.currentTimeMillis() + try { + withTimeout(timeoutMs) { + initializeIfMissing(holder, holder.videoFormat) + initializeIfMissing(holder, holder.audioFormat) + } + val result = manifests.build(holder) + if (result is AndroidDashManifestResult.Preparing) { + session.preparation.fail(temporaryFailure(PREPARATION_FAILED)) + } + logger.info( + "android_playback_preparation videoId={} videoItag={} audioItag={} elapsedMs={} result={}", + holder.key.videoId, + holder.videoFormat.itag, + holder.audioFormat.itag, + System.currentTimeMillis() - startedAt, + result::class.simpleName, + ) + } catch (error: TimeoutCancellationException) { + session.preparation.fail(temporaryFailure(PREPARATION_TIMEOUT)) + logFailure(holder, startedAt, error) + } catch (error: CancellationException) { + throw error + } catch (error: IOException) { + session.preparation.fail(temporaryFailure(PREPARATION_FAILED)) + logFailure(holder, startedAt, error) + } catch (error: RuntimeException) { + session.preparation.fail(temporaryFailure(PREPARATION_FAILED)) + logFailure(holder, startedAt, error) + } + } + + private suspend fun initializeIfMissing(holder: SabrSessionHolder, format: YoutubeSabrFormat): Unit { + if (holder.session.streamState.hasSegmentIndex(format)) return + exactInitializer(holder, format, initializationTimeoutMs) + if (!holder.session.streamState.hasSegmentIndex(format)) { + throw IOException("Exact initialization did not produce a segment index for itag ${format.itag}") + } + } + + private fun logFailure(holder: SabrSessionHolder, startedAt: Long, error: Throwable): Unit { + logger.warn( + "android_playback_preparation_failed videoId={} videoItag={} audioItag={} elapsedMs={} errorClass={}", + holder.key.videoId, + holder.videoFormat.itag, + holder.audioFormat.itag, + System.currentTimeMillis() - startedAt, + error::class.simpleName, + ) + } + + private fun temporaryFailure(code: String): AndroidDashManifestResult.TemporaryFailure = + AndroidDashManifestResult.TemporaryFailure(code, "Android playback preparation is temporarily unavailable") + + private companion object { + val logger = LoggerFactory.getLogger(AndroidPlaybackPreparationCoordinator::class.java) + const val PREPARATION_TIMEOUT_MS = 8_000L + const val INITIALIZATION_TIMEOUT_MS = 3_500L + const val PREPARATION_TIMEOUT = "android_playback_preparation_timeout" + const val PREPARATION_FAILED = "android_playback_preparation_failed" + } +} diff --git a/src/main/kotlin/dev/typetype/server/services/AndroidPlaybackService.kt b/src/main/kotlin/dev/typetype/server/services/AndroidPlaybackService.kt index 12a9462e..357c30ad 100644 --- a/src/main/kotlin/dev/typetype/server/services/AndroidPlaybackService.kt +++ b/src/main/kotlin/dev/typetype/server/services/AndroidPlaybackService.kt @@ -5,9 +5,11 @@ import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat internal class AndroidPlaybackService( private val store: SabrSessionStore, private val sessions: AndroidPlaybackSessionRegistry = AndroidPlaybackSessionRegistry(store), + private val manifests: AndroidDashManifestService = AndroidDashManifestService(), + private val preparation: AndroidPlaybackPreparationCoordinator = + AndroidPlaybackPreparationCoordinator(store, manifests), ) { private val playback = SabrPlaybackSessionService(store, SabrSessionPurpose.ANDROID_PLAYBACK) - private val manifests = AndroidDashManifestService() suspend fun create( videoId: String, @@ -25,29 +27,29 @@ internal class AndroidPlaybackService( audio = audio, video = video, startTimeMs = 0L, + preloadInitialization = false, + startPumpOnPrepare = false, ) val session = sessions.register(result.holder, subtitles) - return AndroidPlaybackCreateResult.Created(session, manifests.build(result.holder)) + preparation.start(session) + return AndroidPlaybackCreateResult.Created(session, session.preparation.result(result.holder, manifests)) } fun lookup(sessionId: String): AndroidPlaybackSessionLookup = sessions.lookup(sessionId) fun seek( - holder: SabrSessionHolder, + session: AndroidPlaybackSession, generation: Long, playerTimeMs: Long, ): AndroidPlaybackSeekResult { + val holder = session.holder if (generation != holder.activeGeneration()) return AndroidPlaybackSeekResult.StaleGeneration playback.seekExisting(holder, playerTimeMs.coerceAtLeast(0L)) - return AndroidPlaybackSeekResult.Ready(holder, manifests.build(holder)) + return AndroidPlaybackSeekResult.Ready(holder, session.preparation.result(holder, manifests)) } - suspend fun manifest(holder: SabrSessionHolder): AndroidDashManifestResult { - val initial = manifests.build(holder) - if (initial !is AndroidDashManifestResult.Preparing) return initial - SabrPlaybackInitializationPreloader.preload(store, holder, MANIFEST_PRELOAD_TIMEOUT_MS) - return manifests.build(holder) - } + fun manifest(session: AndroidPlaybackSession): AndroidDashManifestResult = + session.preparation.result(session.holder, manifests) suspend fun initialization( holder: SabrSessionHolder, @@ -85,7 +87,6 @@ internal class AndroidPlaybackService( } private companion object { - const val MANIFEST_PRELOAD_TIMEOUT_MS = 2_000L const val MEDIA_TIMEOUT_MS = 4_000L } } diff --git a/src/main/kotlin/dev/typetype/server/services/AndroidPlaybackSessionRegistry.kt b/src/main/kotlin/dev/typetype/server/services/AndroidPlaybackSessionRegistry.kt index ecea6306..e117d369 100644 --- a/src/main/kotlin/dev/typetype/server/services/AndroidPlaybackSessionRegistry.kt +++ b/src/main/kotlin/dev/typetype/server/services/AndroidPlaybackSessionRegistry.kt @@ -44,7 +44,7 @@ internal class AndroidPlaybackSessionRegistry( return AndroidPlaybackSessionLookup.Expired } lease.lastAccess = current - return AndroidPlaybackSessionLookup.Active(AndroidPlaybackSession(holder, lease.session.subtitles)) + return AndroidPlaybackSessionLookup.Active(lease.session.withHolder(holder)) } internal fun expire(sessionId: String): Unit { @@ -56,6 +56,7 @@ internal class AndroidPlaybackSessionRegistry( private fun expire(sessionId: String, lease: Lease, current: Instant): Unit { if (!sessions.remove(sessionId, lease)) return tombstones[sessionId] = current.plus(tombstoneTtl) + lease.session.cancelPreparation() store.release(lease.session.holder) } @@ -77,10 +78,18 @@ internal class AndroidPlaybackSessionRegistry( } } -internal data class AndroidPlaybackSession( +internal class AndroidPlaybackSession( val holder: SabrSessionHolder, - val subtitles: List, -) + subtitles: List, + val preparation: AndroidPlaybackPreparation = AndroidPlaybackPreparation(), +) { + val subtitles: List = subtitles.toList() + + fun withHolder(current: SabrSessionHolder): AndroidPlaybackSession = + AndroidPlaybackSession(current, subtitles, preparation) + + fun cancelPreparation(): Unit = preparation.cancel() +} internal sealed interface AndroidPlaybackSessionLookup { data class Active(val session: AndroidPlaybackSession) : AndroidPlaybackSessionLookup diff --git a/src/main/kotlin/dev/typetype/server/services/AndroidSubtitleInventory.kt b/src/main/kotlin/dev/typetype/server/services/AndroidSubtitleInventory.kt new file mode 100644 index 00000000..38b73343 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/AndroidSubtitleInventory.kt @@ -0,0 +1,107 @@ +package dev.typetype.server.services + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicReference + +internal sealed interface AndroidSubtitleInventorySnapshot { + data object Preparing : AndroidSubtitleInventorySnapshot + data class Ready(val tracks: List) : AndroidSubtitleInventorySnapshot + data object TemporaryFailure : AndroidSubtitleInventorySnapshot +} + +internal class AndroidSubtitleInventoryHandle private constructor( + initial: AndroidSubtitleInventorySnapshot, +) { + private val state = AtomicReference(initial) + private val completed = AtomicBoolean(initial !is AndroidSubtitleInventorySnapshot.Preparing) + private val completion = CompletableDeferred().also { + if (initial !is AndroidSubtitleInventorySnapshot.Preparing) it.complete(initial) + } + + fun snapshot(): AndroidSubtitleInventorySnapshot = state.get() + + suspend fun await(): AndroidSubtitleInventorySnapshot = completion.await() + + internal fun complete(result: AndroidSubtitleInventorySnapshot) { + if (!completed.compareAndSet(false, true)) return + state.set(result) + completion.complete(result) + } + + companion object { + fun preparing(): AndroidSubtitleInventoryHandle = + AndroidSubtitleInventoryHandle(AndroidSubtitleInventorySnapshot.Preparing) + + fun ready(tracks: List): AndroidSubtitleInventoryHandle = + AndroidSubtitleInventoryHandle(AndroidSubtitleInventorySnapshot.Ready(tracks)) + + fun temporaryFailure(): AndroidSubtitleInventoryHandle = + AndroidSubtitleInventoryHandle(AndroidSubtitleInventorySnapshot.TemporaryFailure) + } +} + +internal class AndroidSubtitleInventoryCoordinator( + private val service: AndroidSubtitleService, + private val scope: CoroutineScope, + private val maxEntries: Int = 256, + private val cacheTtlMs: Long = 120_000L, + private val nowMs: () -> Long = System::currentTimeMillis, +) { + private val lock = Any() + private val entries = LinkedHashMap(16, 0.75f, true) + + init { + require(maxEntries > 0) + require(cacheTtlMs >= 0L) + } + + fun start(videoId: String): AndroidSubtitleInventoryHandle { + var created: AndroidSubtitleInventoryHandle? = null + val handle = synchronized(lock) { + cleanupCompleted(nowMs()) + entries[videoId]?.handle ?: allocate(videoId)?.also { created = it } + ?: AndroidSubtitleInventoryHandle.temporaryFailure() + } + created?.let { launchInventory(videoId, it) } + return handle + } + + private fun allocate(videoId: String): AndroidSubtitleInventoryHandle? { + if (entries.size >= maxEntries) { + entries.entries.firstOrNull { it.value.completedAtMs != null }?.let { entries.remove(it.key) } + } + if (entries.size >= maxEntries) return null + return AndroidSubtitleInventoryHandle.preparing().also { entries[videoId] = Entry(it) } + } + + private fun launchInventory(videoId: String, handle: AndroidSubtitleInventoryHandle) { + scope.launch { + val result = runCatchingNonCancellation { service.inventory(videoId) } + .getOrDefault(AndroidSubtitleInventoryResult.Unavailable) + .toSnapshot() + handle.complete(result) + synchronized(lock) { + entries[videoId]?.takeIf { it.handle === handle }?.completedAtMs = nowMs() + } + } + } + + private fun cleanupCompleted(currentMs: Long) { + entries.entries.removeIf { (_, entry) -> + entry.completedAtMs?.let { currentMs - it >= cacheTtlMs } == true + } + } + + private fun AndroidSubtitleInventoryResult.toSnapshot(): AndroidSubtitleInventorySnapshot = when (this) { + is AndroidSubtitleInventoryResult.Ready -> AndroidSubtitleInventorySnapshot.Ready(tracks) + AndroidSubtitleInventoryResult.Unavailable -> AndroidSubtitleInventorySnapshot.TemporaryFailure + } + + private class Entry( + val handle: AndroidSubtitleInventoryHandle, + var completedAtMs: Long? = null, + ) +} diff --git a/src/main/kotlin/dev/typetype/server/services/SabrCachedSegmentLocator.kt b/src/main/kotlin/dev/typetype/server/services/SabrCachedSegmentLocator.kt index df527fc1..7862aaad 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrCachedSegmentLocator.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrCachedSegmentLocator.kt @@ -23,7 +23,7 @@ internal fun YoutubeSabrSession.findCachedMediaAt( if (!allowFollowing) return null for (distance in 1..MAX_SEQUENCE_DISTANCE) { cachedMedia(format, predictedSequence + distance) - ?.takeIf { it.startsWithinNextSegment(targetMs, fallbackDurationMs) } + ?.takeIf { it.startsWithinFollowingRange(targetMs, fallbackDurationMs, distance) } ?.let { return it } } return null @@ -42,11 +42,24 @@ private fun SabrMediaSegment.covers(targetMs: Long, fallbackDurationMs: Long?): targetMs < startMs + durationMs } -private fun SabrMediaSegment.startsWithinNextSegment(targetMs: Long, fallbackDurationMs: Long?): Boolean { +private fun SabrMediaSegment.startsWithinFollowingRange( + targetMs: Long, + fallbackDurationMs: Long?, + sequenceDistance: Int, +): Boolean { val durationMs = header.durationMs.takeIf { it > 0L } ?: fallbackDurationMs ?: return false val leadMs = header.startMs - targetMs - return header.startMs >= 0L && leadMs in -TIMING_TOLERANCE_MS..durationMs + val maximumLeadMs = maximumFollowingLeadMs(durationMs, sequenceDistance) + return header.startMs >= 0L && leadMs in -TIMING_TOLERANCE_MS..maximumLeadMs +} + +internal fun maximumFollowingLeadMs(durationMs: Long, sequenceDistance: Int): Long { + val distance = sequenceDistance.coerceAtLeast(1).toLong() + val toleranceMs = LIVE_FOLLOWING_TIMING_TOLERANCE_MS + val boundedDurationMs = durationMs.coerceAtMost((Long.MAX_VALUE - toleranceMs) / distance) + return boundedDurationMs * distance + toleranceMs } private const val MAX_SEQUENCE_DISTANCE = 24 -private const val TIMING_TOLERANCE_MS = 2L +internal const val TIMING_TOLERANCE_MS = 2L +internal const val LIVE_FOLLOWING_TIMING_TOLERANCE_MS = 250L diff --git a/src/main/kotlin/dev/typetype/server/services/SabrDemandAttemptFinisher.kt b/src/main/kotlin/dev/typetype/server/services/SabrDemandAttemptFinisher.kt index b50f7f9e..e0186344 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrDemandAttemptFinisher.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrDemandAttemptFinisher.kt @@ -15,14 +15,18 @@ internal object SabrDemandAttemptFinisher { true } - fun expireStalledInFlightDemand(holder: SabrSessionHolder, demand: SabrInFlightDemand): Boolean = + fun expireStalledInFlightDemand( + holder: SabrSessionHolder, + demand: SabrInFlightDemand, + recoverable: Boolean = demand.futureLiveRequest, + ): Boolean = synchronized(holder) { val state = holder.playbackState() if (state == SabrPlaybackState.TERMINAL || state == SabrPlaybackState.NETWORK_FAILED) return@synchronized false if (holder.inFlightSegmentDemand()?.identity != demand.identity) return@synchronized false holder.clearSegmentDemands() val message = "SABR demand stalled for ${demand.request.summary()}" - holder.failTerminal(if (demand.futureLiveRequest) sabrRecoverableFailureMessage(message) else message) + holder.failTerminal(if (recoverable) sabrRecoverableFailureMessage(message) else message) true } @@ -89,10 +93,6 @@ internal object SabrDemandAttemptFinisher { SabrPumpLogger.recovery(holder, action, request) true } - SabrDemandRecoveryAction.FAIL -> { - runtime.finishDemand(identity) - !fail(holder, request, identity) - } } private fun fail( diff --git a/src/main/kotlin/dev/typetype/server/services/SabrDemandDeadline.kt b/src/main/kotlin/dev/typetype/server/services/SabrDemandDeadline.kt index e0d55ae2..39aa90b6 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrDemandDeadline.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrDemandDeadline.kt @@ -1,10 +1,14 @@ package dev.typetype.server.services -internal class SabrDemandDeadline(private val timeoutMs: Long) { +internal class SabrDemandDeadline( + private val timeoutMs: Long, + private val maximumBackoffExtensionMs: Long = SabrPumpPolicy.MAX_DEMAND_BACKOFF_EXTENSION_MS, +) { private var identity: String? = null private var registeredAtMs = Long.MIN_VALUE private var expiresAtMs = Long.MIN_VALUE private var coveredBackoffUntilMs = Long.MIN_VALUE + private var backoffExtensionMs = 0L fun isExpired( identity: String, @@ -24,14 +28,17 @@ internal class SabrDemandDeadline(private val timeoutMs: Long) { this.registeredAtMs = registeredAtMs expiresAtMs = saturatedAdd(registeredAtMs, timeoutMs) coveredBackoffUntilMs = Long.MIN_VALUE + backoffExtensionMs = 0L } private fun extendForBackoff(nowMs: Long, remainingMs: Long) { if (remainingMs <= 0L) return val backoffUntilMs = saturatedAdd(nowMs, remainingMs) val uncoveredFromMs = maxOf(nowMs, coveredBackoffUntilMs) - val uncoveredMs = (backoffUntilMs - uncoveredFromMs).coerceAtLeast(0L) + val availableExtensionMs = (maximumBackoffExtensionMs - backoffExtensionMs).coerceAtLeast(0L) + val uncoveredMs = (backoffUntilMs - uncoveredFromMs).coerceIn(0L, availableExtensionMs) expiresAtMs = saturatedAdd(expiresAtMs, uncoveredMs) + backoffExtensionMs = saturatedAdd(backoffExtensionMs, uncoveredMs) coveredBackoffUntilMs = maxOf(coveredBackoffUntilMs, backoffUntilMs) } diff --git a/src/main/kotlin/dev/typetype/server/services/SabrDemandRecoveryAction.kt b/src/main/kotlin/dev/typetype/server/services/SabrDemandRecoveryAction.kt index 9c259398..c7525c26 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrDemandRecoveryAction.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrDemandRecoveryAction.kt @@ -3,5 +3,4 @@ package dev.typetype.server.services internal enum class SabrDemandRecoveryAction { WAIT, READVERTISE_TRACK, - FAIL, } diff --git a/src/main/kotlin/dev/typetype/server/services/SabrDemandWatchdog.kt b/src/main/kotlin/dev/typetype/server/services/SabrDemandWatchdog.kt index 29146c88..aba331af 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrDemandWatchdog.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrDemandWatchdog.kt @@ -13,6 +13,11 @@ internal class SabrDemandWatchdog( if (state == SabrPlaybackState.TERMINAL || state == SabrPlaybackState.NETWORK_FAILED) return false val inFlightDemand = holder.inFlightSegmentDemand() if (inFlightDemand != null) { + if (holder.isLiveDemandOutsideRecoverableWindow(inFlightDemand.request) && + SabrDemandAttemptFinisher.expireStalledInFlightDemand(holder, inFlightDemand, recoverable = true) + ) { + return true + } if (inFlightDemand.futureLiveRequest) holder.setPlaybackState(SabrPlaybackState.WAITING_FOR_LIVE) val nowMs = clock() val backoffRemainingMs = holder.session.demandBackoffRemainingMs @@ -40,7 +45,16 @@ internal class SabrDemandWatchdog( delay(intervalMs) continue } + val outsideLiveWindow = holder.isLiveDemandOutsideRecoverableWindow(request) val futureLiveRequest = holder.isFutureLiveRequest(request) + if (outsideLiveWindow) { + val identity = holder.segmentDemandIdentity(request) + if (identity != null && + SabrDemandAttemptFinisher.expireStalledDemand(holder, request, identity, recoverable = true) + ) { + return true + } + } if (futureLiveRequest) { holder.setPlaybackState(SabrPlaybackState.WAITING_FOR_LIVE) } diff --git a/src/main/kotlin/dev/typetype/server/services/SabrInitializationData.kt b/src/main/kotlin/dev/typetype/server/services/SabrInitializationData.kt index 966e84d0..5c8409b2 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrInitializationData.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrInitializationData.kt @@ -1,10 +1,13 @@ package dev.typetype.server.services import dev.typetype.server.cache.CacheService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.withLock 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.io.IOException import java.security.MessageDigest import java.util.Base64 import java.util.Collections @@ -76,6 +79,32 @@ internal object SabrInitializationData { return initialized.firstOrNull { (candidate) -> candidate.matches(format) }?.second } + suspend fun fetchExact( + holder: SabrSessionHolder, + format: YoutubeSabrFormat, + timeoutMs: Long, + cache: CacheService? = null, + ): ByteArray { + fetch(holder.key.videoId, format, cache)?.let { cached -> + if (holder.session.streamState.ingestInitializationData(format, cached) && + holder.session.streamState.hasSegmentIndex(format) + ) { + return cached + } + } + val poToken = holder.session.streamState.poToken + ?.takeIf { it.isNotEmpty() } + ?: holder.playerContextToken?.streamingPoTokenBytesFor(holder.info) + ?: throw IOException("Missing video-bound PO token for Android playback initialization") + val bytes = runInterruptible(Dispatchers.IO) { + holder.withPlayerContext { + fetchInitializationData(format, Localization("en", "US"), timeoutMs, poToken) + } + } + remember(holder.key.videoId, format, bytes, cache) + return bytes + } + private suspend fun CacheService.getBytes(key: String): ByteArray? = runCatching { get(key)?.let { Base64.getDecoder().decode(it) } }.getOrNull() @@ -104,3 +133,9 @@ internal object SabrInitializationData { .digest(value.toByteArray()) .joinToString("") { byte -> "%02x".format(byte) } } + +internal suspend fun SabrSessionStore.fetchExactInitializationData( + holder: SabrSessionHolder, + format: YoutubeSabrFormat, + timeoutMs: Long, +): ByteArray = SabrInitializationData.fetchExact(holder, format, timeoutMs, initCache) diff --git a/src/main/kotlin/dev/typetype/server/services/SabrLivePlaybackDiscontinuity.kt b/src/main/kotlin/dev/typetype/server/services/SabrLivePlaybackDiscontinuity.kt index fd443749..61c3ad60 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrLivePlaybackDiscontinuity.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrLivePlaybackDiscontinuity.kt @@ -1,7 +1,15 @@ package dev.typetype.server.services +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat +internal fun SabrSessionHolder.isLiveDemandOutsideRecoverableWindow(request: SabrSegmentRequest): Boolean { + if (request.isInitializationSegment) return false + val live = livePlaybackSnapshot()?.takeIf { it.active } ?: return false + val requestEndMs = playbackSegmentEndMs(request.format, request.sequenceNumber) + return requestEndMs > 0L && live.headTimeMs - requestEndMs > MAX_RECOVERABLE_LIVE_GAP_MS +} + internal fun SabrSessionHolder.failLivePlaybackDiscontinuity( format: YoutubeSabrFormat, targetMs: Long, @@ -9,9 +17,9 @@ internal fun SabrSessionHolder.failLivePlaybackDiscontinuity( hasBufferedMedia: Boolean, ): Boolean { if (!hasBufferedMedia || livePlaybackSnapshot()?.active != true) return false - if (segment.startMs <= targetMs + MAX_CONTIGUOUS_LIVE_GAP_MS) return false + if (segment.startMs <= targetMs + MAX_RECOVERABLE_LIVE_GAP_MS) return false failTerminal(sabrRecoverableFailureMessage("live ${format.itag} media discontinuity")) return true } -private const val MAX_CONTIGUOUS_LIVE_GAP_MS = 500L +internal const val MAX_RECOVERABLE_LIVE_GAP_MS = 60_000L + LIVE_FOLLOWING_TIMING_TOLERANCE_MS diff --git a/src/main/kotlin/dev/typetype/server/services/SabrPlaybackCachedSegmentLocator.kt b/src/main/kotlin/dev/typetype/server/services/SabrPlaybackCachedSegmentLocator.kt index 0ab8cb26..1943a593 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrPlaybackCachedSegmentLocator.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrPlaybackCachedSegmentLocator.kt @@ -23,7 +23,7 @@ internal suspend fun SabrSessionStore.findCachedPlaybackMediaAt( if (!allowFollowing) return null for (distance in 0..MAX_SEQUENCE_DISTANCE) { cachedMedia(holder, format, predictedSequence + distance)?.let { - if (it.startsWithinNextSegment(holder, format, targetMs)) return it + if (it.startsWithinFollowingRange(holder, format, targetMs, distance)) return it } } return null @@ -48,16 +48,16 @@ internal fun CachedSabrSegment.coversPlaybackTime( return targetMs >= effectiveStartMs - TIMING_TOLERANCE_MS && targetMs < effectiveStartMs + effectiveDurationMs } -private fun CachedSabrSegment.startsWithinNextSegment( +private fun CachedSabrSegment.startsWithinFollowingRange( holder: SabrSessionHolder, format: YoutubeSabrFormat, targetMs: Long, + sequenceDistance: Int, ): Boolean { val effectiveStartMs = startMs.takeIf { it >= 0L } ?: holder.playbackSegmentStartMs(format, sequence) val effectiveDurationMs = durationMs.takeIf { it > 0L } ?: holder.playbackSegmentDurationMs(format, sequence) val leadMs = effectiveStartMs - targetMs - return leadMs in -TIMING_TOLERANCE_MS..effectiveDurationMs + return leadMs in -TIMING_TOLERANCE_MS..maximumFollowingLeadMs(effectiveDurationMs, sequenceDistance) } private const val MAX_SEQUENCE_DISTANCE = 24 -private const val TIMING_TOLERANCE_MS = 2L diff --git a/src/main/kotlin/dev/typetype/server/services/SabrPlaybackSessionService.kt b/src/main/kotlin/dev/typetype/server/services/SabrPlaybackSessionService.kt index 2e69848c..baa1e3d4 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrPlaybackSessionService.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrPlaybackSessionService.kt @@ -4,7 +4,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.withTimeoutOrNull import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat -import org.slf4j.LoggerFactory internal class SabrPlaybackSessionService( private val sessionStore: SabrSessionStore, @@ -20,6 +19,8 @@ internal class SabrPlaybackSessionService( audioOnly: Boolean = false, isLive: Boolean = false, initialGeneration: Long = 0L, + preloadInitialization: Boolean = true, + startPumpOnPrepare: Boolean = true, ): SabrPlaybackPreparation { val holder = sessionStore.getOrCreate( videoId = videoId, @@ -38,11 +39,21 @@ internal class SabrPlaybackSessionService( if (holder.expectsLive()) { holder.setActiveTracks(videoActive = !audioOnly, audioActive = true) holder.session.streamState.setSelectVideoFormatBeforeAudio(!audioOnly) + if (startTimeMs == 0L) { + holder.session.streamState.setPlayerTimeMs(OFFICIAL_LIVE_EDGE_PLAYER_TIME_MS) + holder.session.streamState.setWriteTopLevelPlayerTimeMs(false) + } sessionStore.ensureWarmed(holder, LIVE_INITIAL_PUMPS) - } else { + } else if (preloadInitialization) { SabrPlaybackInitializationPreloader.preload(sessionStore, holder, INITIALIZATION_PRELOAD_TIMEOUT_MS) } - return prepareHolder(holder, holder.resolvePlaybackStartMs(startTimeMs), audioOnly) + return SabrPlaybackStarter.start( + sessionStore, + holder, + holder.resolvePlaybackStartMs(startTimeMs), + audioOnly, + startPumpOnPrepare, + ) } suspend fun seek( @@ -76,8 +87,8 @@ internal class SabrPlaybackSessionService( val generation = holder.advancePlaybackGeneration(playerTimeMs) holder.setActiveTracks(videoActive = !audioOnly, audioActive = true) holder.setRequestedSeekTimeMs(playerTimeMs) - holder.session.streamState.setSelectVideoFormatBeforeAudio(playerTimeMs > SEEK_FORMAT_ORDER_MS) - holder.requestReposition(playerTimeMs, generation) + holder.session.streamState.setSelectVideoFormatBeforeAudio(playerTimeMs > SABR_SEEK_FORMAT_ORDER_MS) + holder.requestPlaybackReposition(playerTimeMs, generation) sessionStore.startPump(holder) SabrPlaybackPreparation(holder, playerTimeMs, ready = false) } @@ -140,13 +151,22 @@ internal class SabrPlaybackSessionService( delay(SEGMENT_WAIT_MS) segment = sessionStore.cachedSegment(holder, request) if (segment == null && holder.livePlaybackSnapshot()?.active == true) { - segment = sessionStore.findCachedPlaybackMediaAt( + val targetMs = holder.playbackSegmentStartMs(request.format, request.sequenceNumber) + val following = sessionStore.findCachedPlaybackMediaAt( holder = holder, format = request.format, - targetMs = holder.playbackSegmentStartMs(request.format, request.sequenceNumber), + targetMs = targetMs, predictedSequence = request.sequenceNumber, allowFollowing = true, ) + segment = following?.takeUnless { + holder.failLivePlaybackDiscontinuity( + request.format, + targetMs, + it, + holder.lastServedSequence(request.format) != null, + ) + } } } segment @@ -157,64 +177,15 @@ internal class SabrPlaybackSessionService( ?.let { SabrPlaybackSegmentResult.Ready(it.mimeType, it.bytes) } ?: SabrPlaybackSegmentResult.Stale(holder) - private suspend fun prepareHolder( - holder: SabrSessionHolder, - startTimeMs: Long, - audioOnly: Boolean, - ): SabrPlaybackPreparation { - val startedAt = System.currentTimeMillis() - holder.setActiveTracks(videoActive = !audioOnly, audioActive = true) - holder.setPlayerTimeMs(startTimeMs) - holder.session.streamState.setSelectVideoFormatBeforeAudio(startTimeMs > SEEK_FORMAT_ORDER_MS) - if (startTimeMs > SEEK_FORMAT_ORDER_MS) holder.anchorReaderPositions(startTimeMs) - if (startTimeMs > 0L) { - holder.setRequestedSeekTimeMs(startTimeMs) - holder.requestReposition(startTimeMs, holder.activeGeneration()) - } - sessionStore.startPump(holder) - logger.info( - "sabr_playback_prepare videoId={} startTimeMs={} elapsedMs={} ready=false", - holder.key.videoId, - startTimeMs, - System.currentTimeMillis() - startedAt, - ) - return SabrPlaybackPreparation(holder, startTimeMs, ready = false) - } - private fun SabrSessionHolder.matches(audio: YoutubeSabrFormat, video: YoutubeSabrFormat): Boolean = audioFormat.itag == audio.itag && audioFormat.audioTrackId == audio.audioTrackId && videoFormat.itag == video.itag - private fun SabrSessionHolder.requestReposition( - playerTimeMs: Long, - generation: Long, - ): Unit { - val targetFormat = if (isVideoActive()) videoFormat else audioFormat - val sequence = playbackStartSequence(targetFormat, playerTimeMs) - val request = SabrSegmentRequest.media(targetFormat, sequence) - val companion = audioFormat.takeIf { isVideoActive() }?.let { format -> - SabrSegmentRequest.media(format, playbackStartSequence(format, playerTimeMs)) - } - val targets = listOfNotNull(request, companion) - val missing = repositionTargets(targets, playerTimeMs, generation) - if (missing.isEmpty()) return - missing.forEach { requestSegmentDemand(it, generation) } - if (livePlaybackSnapshot()?.active == true) return - val anchor = missing.first() - val startMs = playbackSegmentStartMs(anchor.format, anchor.sequenceNumber) - if (startMs < session.streamState.getMinBufferedEndMs()) { - requestRefetch(anchor) - } else { - requestForwardSeek(anchor) - } - } - private companion object { - val logger = LoggerFactory.getLogger(SabrPlaybackSessionService::class.java) const val PREPARING = "preparing" const val REPOSITIONING = "repositioning" const val INITIALIZATION_PRELOAD_TIMEOUT_MS = 6_000L - const val SEEK_FORMAT_ORDER_MS = 1_000L const val SEGMENT_WAIT_MS = 250L const val LIVE_INITIAL_PUMPS = 8 + const val OFFICIAL_LIVE_EDGE_PLAYER_TIME_MS = 9_007_199_254_740_991L } } diff --git a/src/main/kotlin/dev/typetype/server/services/SabrPlaybackStarter.kt b/src/main/kotlin/dev/typetype/server/services/SabrPlaybackStarter.kt new file mode 100644 index 00000000..bc587a5c --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/SabrPlaybackStarter.kt @@ -0,0 +1,55 @@ +package dev.typetype.server.services + +import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest +import org.slf4j.LoggerFactory + +internal object SabrPlaybackStarter { + fun start( + store: SabrSessionStore, + holder: SabrSessionHolder, + startTimeMs: Long, + audioOnly: Boolean, + startPump: Boolean, + ): SabrPlaybackPreparation { + val startedAt = System.currentTimeMillis() + holder.setActiveTracks(videoActive = !audioOnly, audioActive = true) + holder.setPlayerTimeMs(startTimeMs) + holder.session.streamState.setSelectVideoFormatBeforeAudio(startTimeMs > SABR_SEEK_FORMAT_ORDER_MS) + if (startTimeMs > SABR_SEEK_FORMAT_ORDER_MS) holder.anchorReaderPositions(startTimeMs) + if (startTimeMs > 0L) { + holder.setRequestedSeekTimeMs(startTimeMs) + holder.requestPlaybackReposition(startTimeMs, holder.activeGeneration()) + } + if (startPump) store.startPump(holder) + logger.info( + "sabr_playback_prepare videoId={} startTimeMs={} elapsedMs={} ready=false", + holder.key.videoId, + startTimeMs, + System.currentTimeMillis() - startedAt, + ) + return SabrPlaybackPreparation(holder, startTimeMs, ready = false) + } + + private val logger = LoggerFactory.getLogger(SabrPlaybackStarter::class.java) +} + +internal fun SabrSessionHolder.requestPlaybackReposition(playerTimeMs: Long, generation: Long): Unit { + val targetFormat = if (isVideoActive()) videoFormat else audioFormat + val request = SabrSegmentRequest.media(targetFormat, playbackStartSequence(targetFormat, playerTimeMs)) + val companion = audioFormat.takeIf { isVideoActive() }?.let { format -> + SabrSegmentRequest.media(format, playbackStartSequence(format, playerTimeMs)) + } + val missing = repositionTargets(listOfNotNull(request, companion), playerTimeMs, generation) + if (missing.isEmpty()) return + missing.forEach { requestSegmentDemand(it, generation) } + if (livePlaybackSnapshot()?.active == true) return + val anchor = missing.first() + val startMs = playbackSegmentStartMs(anchor.format, anchor.sequenceNumber) + if (startMs < session.streamState.getMinBufferedEndMs()) { + requestRefetch(anchor) + } else { + requestForwardSeek(anchor) + } +} + +internal const val SABR_SEEK_FORMAT_ORDER_MS = 1_000L diff --git a/src/main/kotlin/dev/typetype/server/services/SabrProtectedResponseGuard.kt b/src/main/kotlin/dev/typetype/server/services/SabrProtectedResponseGuard.kt new file mode 100644 index 00000000..05d7f326 --- /dev/null +++ b/src/main/kotlin/dev/typetype/server/services/SabrProtectedResponseGuard.kt @@ -0,0 +1,45 @@ +package dev.typetype.server.services + +internal class SabrProtectedResponseGuard( + private val maximumResponses: Int = SabrPumpPolicy.MAX_PROTECTED_NO_MEDIA_RESPONSES, +) { + private var lastResponseNumber: Int? = null + private var lastGeneration: Long? = null + private var lastPlayerTimeMs: Long? = null + private var consecutiveResponses = 0 + + fun verify(diagnosticTrace: String, generation: Long, playerTimeMs: Long): Unit { + val response = diagnosticTrace.substringAfterLast("response n=", missingDelimiterValue = "") + val responseNumber = response.substringBefore(' ').toIntOrNull() ?: return + if (responseNumber == lastResponseNumber) return + val playbackProgressed = lastGeneration?.let { it != generation } == true || + lastPlayerTimeMs?.let { generation == lastGeneration && playerTimeMs > it } == true + lastResponseNumber = responseNumber + lastGeneration = generation + lastPlayerTimeMs = playerTimeMs + if (!response.isProtectedNoMedia()) { + consecutiveResponses = 0 + return + } + if (playbackProgressed) consecutiveResponses = 0 + consecutiveResponses++ + if (consecutiveResponses >= maximumResponses) { + throw SabrProtectedNoMediaException( + "SABR protected no-media response after $consecutiveResponses bounded attempts", + ) + } + } + + private fun String.isProtectedNoMedia(): Boolean { + if (!contains("segments=count=0")) return false + val status = PROTECTION.find(this)?.groupValues?.get(1)?.toIntOrNull() ?: return false + return status >= PROTECTION_BOUNDARY + } + + private companion object { + const val PROTECTION_BOUNDARY = 2 + val PROTECTION = Regex("""protection=(\d+)/""") + } +} + +internal class SabrProtectedNoMediaException(message: String) : RuntimeException(message) diff --git a/src/main/kotlin/dev/typetype/server/services/SabrPumpPolicy.kt b/src/main/kotlin/dev/typetype/server/services/SabrPumpPolicy.kt index cf2c4dca..8a5c6eae 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrPumpPolicy.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrPumpPolicy.kt @@ -13,8 +13,9 @@ internal object SabrPumpPolicy { const val MIN_SERVER_READAHEAD_CUSHION_MS = 3_000L const val SERVER_AHEAD_MARGIN_MS = 16_000L const val DEMAND_TARGET_DEADLINE_MS = 15_000L + const val MAX_DEMAND_BACKOFF_EXTENSION_MS = 30_000L + const val MAX_PROTECTED_NO_MEDIA_RESPONSES = 5 const val COMPLETED_DEMAND_IDLE_MS = 1_000L - const val MAX_DEMAND_RESPONSES_WITHOUT_TARGET = 3 const val MAX_AHEAD_BYTES = 24L * 1024L * 1024L const val BACK_BUFFER_MS = 12_000L const val MIN_BACK_BUFFER_MS = 2_000L @@ -24,7 +25,7 @@ internal object SabrPumpPolicy { maxOf( intervalMs, backoffMs, - LIVE_EDGE_POLL_MS.takeIf { futureDemand ?: activeLive } ?: 0L, + LIVE_EDGE_POLL_MS.takeIf { futureDemand != null || activeLive } ?: 0L, ) fun backBufferMs(holder: SabrSessionHolder): Long { diff --git a/src/main/kotlin/dev/typetype/server/services/SabrPumpRuntime.kt b/src/main/kotlin/dev/typetype/server/services/SabrPumpRuntime.kt index 574e072a..dd4df550 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrPumpRuntime.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrPumpRuntime.kt @@ -2,11 +2,11 @@ package dev.typetype.server.services internal class SabrPumpRuntime(private val clock: () -> Long = System::currentTimeMillis) { private val startedAtMs = clock() + private val protectedResponseGuard = SabrProtectedResponseGuard() private var lastRequestMs = 0L private var seekModeUntilMs = 0L private var demandKey: String? = null private var demandTrackReadvertised = false - private var demandTargetTrackResponsesWithoutSegment = 0 fun activateSeekMode(): Unit { seekModeUntilMs = clock() + SabrPumpPolicy.SEEK_MODE_MS @@ -16,6 +16,13 @@ internal class SabrPumpRuntime(private val clock: () -> Long = System::currentTi lastRequestMs = clock() } + fun verifyProtectedResponse(holder: SabrSessionHolder): Unit = + protectedResponseGuard.verify( + holder.session.diagnosticTrace, + holder.activeGeneration(), + holder.playerTimeMs(), + ) + fun requestPlayerTimeMs(holder: SabrSessionHolder, edgeMs: Long): Long = if (isStartupBurst()) cappedServerAheadPlayerTimeMs(holder, edgeMs) else holder.playerTimeMs() @@ -39,10 +46,6 @@ internal class SabrPumpRuntime(private val clock: () -> Long = System::currentTi } ensureDemand(requestKey) if (targetTrackSegmentCount > 0) { - demandTargetTrackResponsesWithoutSegment++ - if (demandTargetTrackResponsesWithoutSegment >= SabrPumpPolicy.MAX_DEMAND_RESPONSES_WITHOUT_TARGET) { - return SabrDemandRecoveryAction.FAIL - } if (!demandTrackReadvertised) { demandTrackReadvertised = true return SabrDemandRecoveryAction.READVERTISE_TRACK @@ -90,12 +93,10 @@ internal class SabrPumpRuntime(private val clock: () -> Long = System::currentTi if (demandKey == requestKey) return demandKey = requestKey demandTrackReadvertised = false - demandTargetTrackResponsesWithoutSegment = 0 } private fun resetDemandRecovery(): Unit { demandKey = null demandTrackReadvertised = false - demandTargetTrackResponsesWithoutSegment = 0 } } diff --git a/src/main/kotlin/dev/typetype/server/services/SabrSessionPumpLoop.kt b/src/main/kotlin/dev/typetype/server/services/SabrSessionPumpLoop.kt index 4482c356..45ad79ef 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrSessionPumpLoop.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrSessionPumpLoop.kt @@ -40,6 +40,9 @@ internal class SabrSessionPumpLoop( } catch (error: SabrRecoverableException) { holder.failTerminal(sabrRecoverableFailureMessage(error.message)) return + } catch (error: SabrProtectedNoMediaException) { + holder.failTerminal(error.message) + return } catch (error: ExtractionException) { holder.failTerminal(error.message) return @@ -133,6 +136,7 @@ internal class SabrSessionPumpLoop( return try { runInterruptible(Dispatchers.IO) { holder.withPlayerContext { pumpOnceStreaming(localization) } } .also { unauthorizedRecovery.verify(holder) } + .also { runtime.verifyProtectedResponse(holder) } } finally { runtime.recordRequest() } @@ -147,6 +151,7 @@ internal class SabrSessionPumpLoop( return try { runInterruptible(Dispatchers.IO) { holder.withPlayerContext { pumpOnceStreamingForDemand(localization, request) } } .also { unauthorizedRecovery.verify(holder) } + .also { runtime.verifyProtectedResponse(holder) } } finally { runtime.recordRequest() } diff --git a/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt b/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt index 82e828f3..e9aa476e 100644 --- a/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt +++ b/src/main/kotlin/dev/typetype/server/services/SabrSessionStore.kt @@ -24,7 +24,7 @@ internal class SabrSessionStore( private val pumpLoopIntervalMs: Long = SabrPumpPolicy.IDLE_POLL_MS, private val tokenClient: TypetypeTokenSabrTokenClient = TypetypeTokenSabrTokenClient(tokenServiceUrl), private val sessionClient: TypetypeTokenYoutubeSessionClient = TypetypeTokenYoutubeSessionClient(tokenServiceUrl), - private val initCache: CacheService? = null, + internal val initCache: CacheService? = null, ) { private val registry = SabrSessionRegistry() private val segmentCache = SabrSegmentCache() diff --git a/src/main/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionClient.kt b/src/main/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionClient.kt index 7f19cd47..99abec11 100644 --- a/src/main/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionClient.kt +++ b/src/main/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionClient.kt @@ -33,9 +33,14 @@ internal class TypetypeTokenYoutubeSessionClient( } suspend fun fetchPlaybackSession(videoId: String): TokenYoutubeSession? = fetchSession(videoId)?.let { session -> - val info = session.toSabrInfo(videoId) ?: return@let null + session.toPlaybackSession(videoId) + } + + private fun JSONObject.toPlaybackSession(videoId: String): TokenYoutubeSession? { + val session = this + val info = session.toSabrInfo(videoId) ?: return null val metadata = session.optJSONObject("metadata") ?: JSONObject() - TokenYoutubeSession( + return TokenYoutubeSession( info = info, token = SabrTokenBundle.fromResponse(videoId, session), title = metadata.optString("title", session.optString("title")), @@ -105,14 +110,24 @@ internal class TypetypeTokenYoutubeSessionClient( JsonParser.`object`().from(playerResponse.toString()), ) val playbackUrl = optString("serverAbrStreamingUrl").takeIf { it.isNotBlank() } - val clientVersion = playbackUrl - ?.toHttpUrlOrNull() + val parsedPlaybackUrl = playbackUrl?.toHttpUrlOrNull() + val clientVersion = parsedPlaybackUrl ?.queryParameter("cver") ?.takeIf { it.isNotBlank() } + val playbackCpn = parsedPlaybackUrl + ?.queryParameter("cpn") + ?.takeIf { it.isNotBlank() } if (playbackUrl != null && clientVersion != null) { - TypeTypeYoutubeSabrInfoFactory.withPlaybackUrlAndClientVersion(info, playbackUrl, clientVersion) + TypeTypeYoutubeSabrInfoFactory.withPlaybackIdentity( + info, + playbackUrl, + clientVersion, + playbackCpn ?: info.cpn, + getString("visitorData"), + ) } else { info } }.getOrNull() + } diff --git a/src/test/kotlin/dev/typetype/server/InstanceRoutesTest.kt b/src/test/kotlin/dev/typetype/server/InstanceRoutesTest.kt index 58f5a78f..f07a2fd5 100644 --- a/src/test/kotlin/dev/typetype/server/InstanceRoutesTest.kt +++ b/src/test/kotlin/dev/typetype/server/InstanceRoutesTest.kt @@ -76,10 +76,18 @@ class InstanceRoutesTest { assertEquals(listOf(0, 3, 4, 5, 6), root["supportedServices"]?.jsonArray?.map { it.jsonPrimitive.int }) val androidPlayback = root["androidPlayback"]?.jsonObject assertEquals(true, androidPlayback?.get("supported")?.jsonPrimitive?.boolean) - assertEquals(2, androidPlayback?.get("contractVersion")?.jsonPrimitive?.int) + assertEquals(4, androidPlayback?.get("contractVersion")?.jsonPrimitive?.int) assertEquals(true, androidPlayback?.get("youtube")?.jsonObject?.get("vod")?.jsonPrimitive?.boolean) assertEquals(false, androidPlayback?.get("youtube")?.jsonObject?.get("live")?.jsonPrimitive?.boolean) assertEquals(true, androidPlayback?.get("youtube")?.jsonObject?.get("subtitles")?.jsonPrimitive?.boolean) + assertEquals( + true, + androidPlayback?.get("youtube")?.jsonObject?.get("deferredSubtitleContent")?.jsonPrimitive?.boolean, + ) + assertEquals( + true, + androidPlayback?.get("youtube")?.jsonObject?.get("bootstrapSubtitleDescriptors")?.jsonPrimitive?.boolean, + ) } @Test diff --git a/src/test/kotlin/dev/typetype/server/routes/AndroidPlaybackRouteContractTest.kt b/src/test/kotlin/dev/typetype/server/routes/AndroidPlaybackRouteContractTest.kt index 27907fec..1d598d2d 100644 --- a/src/test/kotlin/dev/typetype/server/routes/AndroidPlaybackRouteContractTest.kt +++ b/src/test/kotlin/dev/typetype/server/routes/AndroidPlaybackRouteContractTest.kt @@ -2,10 +2,11 @@ package dev.typetype.server.routes import dev.typetype.server.services.AndroidDashManifestResult import dev.typetype.server.services.AndroidPlaybackMediaResult +import dev.typetype.server.services.AndroidPlaybackPreparationStage import dev.typetype.server.services.AndroidPlaybackService import dev.typetype.server.services.AndroidPlaybackSession import dev.typetype.server.services.AndroidPlaybackSessionLookup -import dev.typetype.server.services.AndroidSubtitleService +import dev.typetype.server.services.AndroidSubtitleInventoryCoordinator import dev.typetype.server.services.SabrSessionHolder import io.ktor.client.request.get import io.ktor.client.statement.bodyAsText @@ -37,6 +38,7 @@ class AndroidPlaybackRouteContractTest { mockk(relaxed = true), mockk(), mockk(), + mockk(), null, null, null, @@ -54,7 +56,7 @@ class AndroidPlaybackRouteContractTest { fun `ready manifest returns dash xml with no store`() = testApplication { val fixture = fixture() every { fixture.service.lookup(SESSION_ID) } returns fixture.lookup - coEvery { fixture.service.manifest(fixture.holder) } returns AndroidDashManifestResult.Ready(MPD, 1_000L) + every { fixture.service.manifest(fixture.lookup.session) } returns AndroidDashManifestResult.Ready(MPD, 1_000L) application { install(ContentNegotiation) { json() } routing { get("/manifest") { fixture.handler.manifest(call, SESSION_ID) } } @@ -72,7 +74,9 @@ class AndroidPlaybackRouteContractTest { fun `preparing manifest returns bounded json retry`() = testApplication { val fixture = fixture() every { fixture.service.lookup(SESSION_ID) } returns fixture.lookup - coEvery { fixture.service.manifest(fixture.holder) } returns AndroidDashManifestResult.Preparing + every { fixture.service.manifest(fixture.lookup.session) } returns AndroidDashManifestResult.Preparing( + AndroidPlaybackPreparationStage.AUDIO_VIDEO_INDEX, + ) application { install(ContentNegotiation) { json() } routing { get("/manifest") { fixture.handler.manifest(call, SESSION_ID) } } @@ -82,6 +86,7 @@ class AndroidPlaybackRouteContractTest { assertEquals(HttpStatusCode.Accepted, response.status) assertTrue(response.bodyAsText().contains("\"retryAfterMs\":500")) + assertTrue(response.bodyAsText().contains("\"preparationStage\":\"audio_video_index\"")) } @Test @@ -124,7 +129,7 @@ class AndroidPlaybackRouteContractTest { fun `invalid complete index returns unprocessable entity`() = testApplication { val fixture = fixture() every { fixture.service.lookup(SESSION_ID) } returns fixture.lookup - coEvery { fixture.service.manifest(fixture.holder) } returns AndroidDashManifestResult.Invalid("invalid index") + every { fixture.service.manifest(fixture.lookup.session) } returns AndroidDashManifestResult.Invalid("invalid index") application { install(ContentNegotiation) { json() } routing { get("/manifest") { fixture.handler.manifest(call, SESSION_ID) } } @@ -136,6 +141,25 @@ class AndroidPlaybackRouteContractTest { assertTrue(response.bodyAsText().contains("android_playback_invalid_index")) } + @Test + fun `preparation failure returns a typed service unavailable response`() = testApplication { + val fixture = fixture() + every { fixture.service.lookup(SESSION_ID) } returns fixture.lookup + every { fixture.service.manifest(fixture.lookup.session) } returns AndroidDashManifestResult.TemporaryFailure( + "android_playback_preparation_timeout", + "Android playback preparation is temporarily unavailable", + ) + application { + install(ContentNegotiation) { json() } + routing { get("/manifest") { fixture.handler.manifest(call, SESSION_ID) } } + } + + val response = client.get("/manifest") + + assertEquals(HttpStatusCode.ServiceUnavailable, response.status) + assertTrue(response.bodyAsText().contains("android_playback_preparation_timeout")) + } + private fun fixture(): Fixture { val service = mockk() val audio = format(140, true) @@ -153,7 +177,7 @@ class AndroidPlaybackRouteContractTest { null, null, null, - mockk(), + mockk(), service, ) return Fixture(service, holder, handler, AndroidPlaybackSessionLookup.Active(AndroidPlaybackSession(holder, emptyList()))) diff --git a/src/test/kotlin/dev/typetype/server/routes/AndroidPlaybackSubtitleBootstrapTest.kt b/src/test/kotlin/dev/typetype/server/routes/AndroidPlaybackSubtitleBootstrapTest.kt new file mode 100644 index 00000000..526ae548 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/routes/AndroidPlaybackSubtitleBootstrapTest.kt @@ -0,0 +1,202 @@ +package dev.typetype.server.routes + +import dev.typetype.server.services.AndroidDashManifestResult +import dev.typetype.server.services.AndroidPlaybackPreparationStage +import dev.typetype.server.services.AndroidPlaybackCreateResult +import dev.typetype.server.services.AndroidPlaybackService +import dev.typetype.server.services.AndroidPlaybackSession +import dev.typetype.server.services.AndroidSubtitleInventoryCoordinator +import dev.typetype.server.services.AndroidSubtitleInventoryHandle +import dev.typetype.server.services.AndroidSubtitleInventorySnapshot +import dev.typetype.server.services.AndroidSubtitleTrack +import dev.typetype.server.services.SabrPreparedInfo +import dev.typetype.server.services.SabrSessionHolder +import dev.typetype.server.services.SabrSessionStore +import dev.typetype.server.services.StreamService +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.http.contentType +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.post +import io.ktor.server.routing.routing +import io.ktor.server.testing.testApplication +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.yield +import kotlinx.serialization.json.Json +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 +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrInfo + +class AndroidPlaybackSubtitleBootstrapTest { + @Test + fun `creation waits for the authoritative descriptor catalog`() = testApplication { + val inventory = AndroidSubtitleInventoryHandle.preparing() + val fixture = fixture(inventory) + fixture.stubCreated(listOf(TRACK), AndroidDashManifestResult.Ready(MPD, 213_000L)) + application { installRoute(fixture.handler) } + + coroutineScope { + val pending = async { + client.post("/playback") { + contentType(ContentType.Application.Json) + setBody("""{"subtitleMode":"deferred"}""") + } + } + yield() + assertFalse(pending.isCompleted) + + inventory.complete(AndroidSubtitleInventorySnapshot.Ready(listOf(TRACK))) + val response = pending.await() + val body = response.bodyAsText() + + assertEquals(HttpStatusCode.OK, response.status) + assertTrue(body.contains("\"subtitles\":[{\"id\":\"track\"")) + assertTrue(body.contains("/subtitles/track.vtt")) + assertFalse(body.contains("subtitleInventory")) + } + } + + @Test + fun `creation fails when a complete descriptor catalog is unavailable`() = testApplication { + val fixture = fixture(AndroidSubtitleInventoryHandle.temporaryFailure()) + application { installRoute(fixture.handler) } + + val response = client.post("/playback") { + contentType(ContentType.Application.Json) + setBody("{}") + } + + assertEquals(HttpStatusCode.ServiceUnavailable, response.status) + assertTrue(response.bodyAsText().contains("android_subtitle_inventory_unavailable")) + coVerify(exactly = 0) { fixture.service.create(any(), any(), any(), any(), any(), any()) } + } + + @Test + fun `video without captions returns an authoritative empty catalog`() = testApplication { + val fixture = fixture(AndroidSubtitleInventoryHandle.ready(emptyList())) + fixture.stubCreated(emptyList(), AndroidDashManifestResult.Ready(MPD, 213_000L)) + application { installRoute(fixture.handler) } + + val response = client.post("/playback") { + contentType(ContentType.Application.Json) + setBody("{}") + } + + assertEquals(HttpStatusCode.OK, response.status) + assertTrue(response.bodyAsText().contains("\"subtitles\":[]")) + } + + @Test + fun `preparing media response still contains every subtitle descriptor`() = testApplication { + val fixture = fixture(AndroidSubtitleInventoryHandle.ready(listOf(TRACK))) + fixture.stubCreated( + listOf(TRACK), + AndroidDashManifestResult.Preparing(AndroidPlaybackPreparationStage.AUDIO_VIDEO_INDEX), + ) + application { installRoute(fixture.handler) } + + val response = client.post("/playback") { + contentType(ContentType.Application.Json) + setBody("{}") + } + + assertEquals(HttpStatusCode.Accepted, response.status) + assertTrue(response.bodyAsText().contains("\"id\":\"track\"")) + assertTrue(response.bodyAsText().contains("\"retryAfterMs\":500")) + } + + private fun io.ktor.server.application.Application.installRoute(handler: AndroidPlaybackHandler) { + install(ContentNegotiation) { + json(Json { ignoreUnknownKeys = true; encodeDefaults = true }) + } + routing { post("/playback") { handler.create(call, VIDEO_ID) } } + } + + private fun fixture(inventory: AndroidSubtitleInventoryHandle): Fixture { + val audio = format(140, audio = true, "audio/mp4; codecs=\"mp4a.40.2\"") + val video = format(137, audio = false, "video/mp4; codecs=\"avc1.4d4028\"") + val info = mockk { + every { formats } returns listOf(audio, video) + } + val prepared = SabrPreparedInfo(info, null) + val store = mockk() + coEvery { store.fetchInfo(VIDEO_ID, cachedFirst = true) } returns prepared + val coordinator = mockk() + every { coordinator.start(VIDEO_ID) } returns inventory + val service = mockk() + val holder = mockk { + every { sessionToken } returns SESSION_ID + every { key.videoId } returns VIDEO_ID + every { audioFormat } returns audio + every { videoFormat } returns video + every { activeGeneration() } returns 0L + } + val handler = AndroidPlaybackHandler( + store, + mockk(), + null, + null, + null, + coordinator, + service, + ) + return Fixture(handler, service, prepared, holder, audio, video) + } + + private fun format(itag: Int, audio: Boolean, mimeType: String): YoutubeSabrFormat = + mockk(relaxed = true) { + every { this@mockk.itag } returns itag + every { isAudio } returns audio + every { isVideo } returns !audio + every { this@mockk.mimeType } returns mimeType + every { height } returns if (audio) 0 else 1080 + every { bitrate } returns if (audio) 128_000 else 4_000_000 + every { audioTrackId } returns null + } + + private data class Fixture( + val handler: AndroidPlaybackHandler, + val service: AndroidPlaybackService, + val prepared: SabrPreparedInfo, + val holder: SabrSessionHolder, + val audio: YoutubeSabrFormat, + val video: YoutubeSabrFormat, + ) { + fun stubCreated( + subtitles: List, + manifest: AndroidDashManifestResult, + ) { + coEvery { + service.create(VIDEO_ID, "guest", prepared, audio, video, subtitles) + } returns AndroidPlaybackCreateResult.Created( + AndroidPlaybackSession(holder, subtitles), + manifest, + ) + } + } + + private companion object { + const val VIDEO_ID = "dQw4w9WgXcQ" + const val SESSION_ID = "android-session" + const val MPD = "" + val TRACK = mockk(relaxed = true) { + every { id } returns "track" + every { languageTag } returns "en" + every { displayLanguageName } returns "English" + every { isAutoGenerated } returns false + } + } +} diff --git a/src/test/kotlin/dev/typetype/server/routes/AndroidPlaybackSubtitleModelsTest.kt b/src/test/kotlin/dev/typetype/server/routes/AndroidPlaybackSubtitleModelsTest.kt index 3860d7d4..880232dc 100644 --- a/src/test/kotlin/dev/typetype/server/routes/AndroidPlaybackSubtitleModelsTest.kt +++ b/src/test/kotlin/dev/typetype/server/routes/AndroidPlaybackSubtitleModelsTest.kt @@ -2,6 +2,7 @@ package dev.typetype.server.routes import dev.typetype.server.services.AndroidDashManifestResult import dev.typetype.server.services.AndroidSubtitleTrack +import dev.typetype.server.services.AndroidPlaybackSession import dev.typetype.server.services.SabrSessionHolder import io.mockk.every import io.mockk.mockk @@ -29,9 +30,10 @@ class AndroidPlaybackSubtitleModelsTest { "https://www.youtube.com/api/timedtext?v=video&lang=en".toHttpUrl(), ) - val initial = holder.toAndroidPlaybackResponse(MANIFEST, listOf(track)) + val session = AndroidPlaybackSession(holder, listOf(track)) + val initial = session.toAndroidPlaybackResponse(MANIFEST) generation = 1L - val afterSeek = holder.toAndroidPlaybackResponse(MANIFEST, listOf(track)) + val afterSeek = session.toAndroidPlaybackResponse(MANIFEST) assertEquals(0L, initial.generation) assertEquals(1L, afterSeek.generation) diff --git a/src/test/kotlin/dev/typetype/server/routes/AndroidSubtitleRouteTest.kt b/src/test/kotlin/dev/typetype/server/routes/AndroidSubtitleRouteTest.kt index 684d07c6..5574d3cf 100644 --- a/src/test/kotlin/dev/typetype/server/routes/AndroidSubtitleRouteTest.kt +++ b/src/test/kotlin/dev/typetype/server/routes/AndroidSubtitleRouteTest.kt @@ -73,10 +73,15 @@ class AndroidSubtitleRouteTest { private fun io.ktor.server.application.Application.installRoute(handler: AndroidSubtitleHandler) { install(ContentNegotiation) { json() } - routing { get("/subtitle") { handler.content(call, SESSION_ID, TRACK.id) } } + routing { + get("/subtitle") { handler.content(call, SESSION_ID, TRACK.id) } + } } - private fun fixture(owner: String, authenticatedUser: String = owner): Fixture { + private fun fixture( + owner: String, + authenticatedUser: String = owner, + ): Fixture { val playback = mockk() val holder = mockk { every { key } returns SabrSessionKey( @@ -99,11 +104,12 @@ class AndroidSubtitleRouteTest { null, null, ) - return Fixture(handler, subtitles) + return Fixture(handler, playback, subtitles) } private data class Fixture( val handler: AndroidSubtitleHandler, + val playback: AndroidPlaybackService, val subtitles: AndroidSubtitleService, ) diff --git a/src/test/kotlin/dev/typetype/server/routes/SabrLiveGapWindowTest.kt b/src/test/kotlin/dev/typetype/server/routes/SabrLiveGapWindowTest.kt index ee962c7f..26fbb214 100644 --- a/src/test/kotlin/dev/typetype/server/routes/SabrLiveGapWindowTest.kt +++ b/src/test/kotlin/dev/typetype/server/routes/SabrLiveGapWindowTest.kt @@ -11,6 +11,7 @@ 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.assertNull +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat @@ -21,7 +22,7 @@ import java.time.Instant class SabrLiveGapWindowTest { @Test - fun `live gap requests a fresh session before playback reaches it`() = runTest { + fun `small live gap extends the current playback window`() = runTest { val audio = format(140, isAudio = true) val video = format(299, isAudio = false) val session = mockk(relaxed = true) @@ -29,7 +30,7 @@ class SabrLiveGapWindowTest { every { session.streamState } returns state every { session.isLive } returns true every { state.isLive } returns true - every { state.liveHeadTimeMs } returns 510_000L + every { state.liveHeadTimeMs } returns 600_000L every { state.getSegmentStartMs(video, 92) } returns 475_000L val holder = holder(session, audio, video) holder.setLastServedSequence(video.itag, 91) @@ -54,13 +55,20 @@ class SabrLiveGapWindowTest { SabrPlaybackWindowRequest(0L, 470_000L, 299, 140, bufferGoalMs = 8_000L, bufferedRanges = ranges), ) - assertFalse(result.isReady) - assertEquals("SABR recoverable failure: live 299 media discontinuity", holder.terminalFailure()) - assertNull(result.blockedRequests.firstOrNull { it.format.itag == video.itag }) + assertTrue(result.isReady) + assertEquals(480_000L, result.response.startTimeMs) + assertEquals( + listOf( + "/api/sabr/playback/session/299/segment/93?generation=0", + "/api/sabr/playback/session/299/segment/94?generation=0", + ), + requireNotNull(result.response.video).segments.map { it.url }, + ) + assertNull(holder.terminalFailure()) } @Test - fun `live gap requests a fresh session when playhead remains behind`() = runTest { + fun `small live gap keeps the current session when playhead remains behind`() = runTest { val audio = format(140, isAudio = true) val video = format(299, isAudio = false) val session = mockk(relaxed = true) @@ -95,9 +103,55 @@ class SabrLiveGapWindowTest { SabrPlaybackWindowRequest(0L, 477_942L, 299, 140, bufferGoalMs = 8_000L, bufferedRanges = ranges), ) - assertFalse(result.isReady) + assertTrue(result.isReady) assertFalse(result.blockedRequests.any { it.sequenceNumber == 92 }) + assertEquals( + "/api/sabr/playback/session/299/segment/94?generation=0", + requireNotNull(result.response.video).segments.first().url, + ) + assertEquals( + "/api/sabr/playback/session/140/segment/50?generation=0", + result.response.audio.segments.first().url, + ) + assertNull(holder.terminalFailure()) + } + + @Test + fun `large live gap requests a fresh playback session`() = runTest { + val audio = format(140, isAudio = true) + val video = format(299, isAudio = false) + val session = mockk(relaxed = true) + val state = mockk(relaxed = true) + every { session.streamState } returns state + every { session.isLive } returns true + every { state.isLive } returns true + every { state.liveHeadTimeMs } returns 510_000L + every { state.getSegmentStartMs(video, 92) } returns 475_000L + val holder = holder(session, audio, video) + holder.setLastServedSequence(video.itag, 91) + holder.setLastServedSequence(audio.itag, 48) + val store = mockk() + coEvery { store.cachedSegment(holder, any()) } answers { + val request = secondArg() + when { + request.format.itag == 299 && request.sequenceNumber == 114 -> cached(299, 114, 585_000L, 5_000L) + request.format.itag == 140 && request.sequenceNumber == 49 -> cached(140, 49, 479_259L, 9_985L) + else -> null + } + } + val ranges = listOf( + SabrPlaybackBufferedRange(video.itag, 450_000L, 475_000L), + SabrPlaybackBufferedRange(audio.itag, 450_000L, 479_259L), + ) + + val result = SabrPlaybackWindowBuilder(store).build( + holder, + SabrPlaybackWindowRequest(0L, 470_000L, 299, 140, bufferGoalMs = 8_000L, bufferedRanges = ranges), + ) + + assertFalse(result.isReady) assertEquals("SABR recoverable failure: live 299 media discontinuity", holder.terminalFailure()) + assertNull(result.blockedRequests.firstOrNull { it.format.itag == video.itag }) } private fun holder( diff --git a/src/test/kotlin/dev/typetype/server/services/AndroidDashManifestServiceTest.kt b/src/test/kotlin/dev/typetype/server/services/AndroidDashManifestServiceTest.kt index deed2d3a..3870304a 100644 --- a/src/test/kotlin/dev/typetype/server/services/AndroidDashManifestServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/AndroidDashManifestServiceTest.kt @@ -40,7 +40,22 @@ class AndroidDashManifestServiceTest { val fixture = fixture(audioCount = 5, videoCount = 5, segmentMs = 1_000L) every { fixture.state.hasSegmentIndex(fixture.audio) } returns false - assertEquals(AndroidDashManifestResult.Preparing, AndroidDashManifestService().build(fixture.holder)) + assertEquals( + AndroidDashManifestResult.Preparing(AndroidPlaybackPreparationStage.AUDIO_INDEX), + AndroidDashManifestService().build(fixture.holder), + ) + } + + @Test + fun `missing indexes expose the current preparation stage`() { + val fixture = fixture(audioCount = 5, videoCount = 5, segmentMs = 1_000L) + every { fixture.state.hasSegmentIndex(fixture.audio) } returns false + every { fixture.state.hasSegmentIndex(fixture.video) } returns false + + assertEquals( + AndroidDashManifestResult.Preparing(AndroidPlaybackPreparationStage.AUDIO_VIDEO_INDEX), + AndroidDashManifestService().build(fixture.holder), + ) } @Test @@ -80,7 +95,7 @@ class AndroidDashManifestServiceTest { every { activeGeneration() } returns 0L every { expectsLive() } returns false } - return Fixture(holder, state, audio) + return Fixture(holder, state, audio, video) } private fun format(itag: Int, audio: Boolean): YoutubeSabrFormat = mockk { @@ -97,5 +112,6 @@ class AndroidDashManifestServiceTest { val holder: SabrSessionHolder, val state: YoutubeSabrStreamState, val audio: YoutubeSabrFormat, + val video: YoutubeSabrFormat, ) } diff --git a/src/test/kotlin/dev/typetype/server/services/AndroidPlaybackPreparationTest.kt b/src/test/kotlin/dev/typetype/server/services/AndroidPlaybackPreparationTest.kt new file mode 100644 index 00000000..a2437d64 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/AndroidPlaybackPreparationTest.kt @@ -0,0 +1,166 @@ +package dev.typetype.server.services + +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +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 org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrSession +import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrStreamState +import java.io.IOException +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean + +@OptIn(ExperimentalCoroutinesApi::class) +class AndroidPlaybackPreparationTest { + @Test + fun `one shared preparation publishes the complete manifest`() = runTest { + val fixture = fixture() + val calls = mutableListOf() + val coordinator = coordinator(fixture, this) { _, format, _ -> + calls += format.itag + fixture.ready[format.itag] = true + byteArrayOf(1) + } + + coordinator.start(fixture.session) + coordinator.start(fixture.session) + advanceUntilIdle() + + assertEquals(listOf(137, 140), calls) + assertInstanceOf( + AndroidDashManifestResult.Ready::class.java, + fixture.session.preparation.result(fixture.holder, fixture.manifests), + ) + } + + @Test + fun `preparation deadline becomes a typed temporary failure`() = runTest { + val fixture = fixture() + val coordinator = coordinator(fixture, this, timeoutMs = 100L) { _, _, _ -> + delay(1_000L) + byteArrayOf(1) + } + + coordinator.start(fixture.session) + advanceUntilIdle() + + val result = fixture.session.preparation.result(fixture.holder, fixture.manifests) + as AndroidDashManifestResult.TemporaryFailure + assertEquals("android_playback_preparation_timeout", result.code) + } + + @Test + fun `initialization failure becomes a typed temporary failure`() = runTest { + val fixture = fixture() + val coordinator = coordinator(fixture, this) { _, _, _ -> throw IOException("upstream failed") } + + coordinator.start(fixture.session) + advanceUntilIdle() + + val result = fixture.session.preparation.result(fixture.holder, fixture.manifests) + as AndroidDashManifestResult.TemporaryFailure + assertEquals("android_playback_preparation_failed", result.code) + } + + @Test + fun `session cancellation stops preparation work`() = runTest { + val fixture = fixture() + val cancelled = AtomicBoolean() + val coordinator = coordinator(fixture, this) { _, _, _ -> + try { + awaitCancellation() + } finally { + cancelled.set(true) + } + } + + coordinator.start(fixture.session) + runCurrent() + fixture.session.cancelPreparation() + runCurrent() + + assertTrue(cancelled.get()) + } + + private fun coordinator( + fixture: Fixture, + scope: CoroutineScope, + timeoutMs: Long = 1_000L, + initializer: suspend (SabrSessionHolder, YoutubeSabrFormat, Long) -> ByteArray, + ): AndroidPlaybackPreparationCoordinator = AndroidPlaybackPreparationCoordinator( + store = mockk(relaxed = true), + manifests = fixture.manifests, + timeoutMs = timeoutMs, + initializationTimeoutMs = 500L, + scope = scope, + exactInitializer = initializer, + ) + + private fun fixture(): Fixture { + val audio = format(140, true) + val video = format(137, false) + val ready = mutableMapOf(140 to false, 137 to false) + val state = mockk(relaxed = true) { + every { hasSegmentIndex(any()) } answers { ready[firstArg().itag] == true } + every { getEndSegment(any()) } returns 3L + every { getSegmentStartMs(any(), any()) } answers { (secondArg() - 1L) * 1_000L } + every { getSegmentEndMs(any(), any()) } answers { secondArg() * 1_000L } + } + val sabr = mockk { every { streamState } returns state } + val holder = SabrSessionHolder( + sabr, + mockk(), + audio, + video, + "android-session", + SabrSessionKey( + "video", + "user", + 140, + null, + 137, + 0L, + SabrSessionPurpose.ANDROID_PLAYBACK, + ), + Instant.EPOCH, + ) + return Fixture( + holder, + AndroidPlaybackSession(holder, emptyList()), + AndroidDashManifestService(), + ready, + ) + } + + private fun format(itag: Int, audio: Boolean): YoutubeSabrFormat = mockk { + every { this@mockk.itag } returns itag + every { isAudio } returns audio + every { isVideo } returns !audio + every { audioTrackId } returns null + every { mimeType } returns if (audio) { + "audio/mp4; codecs=\"mp4a.40.2\"" + } else { + "video/mp4; codecs=\"avc1.640028\"" + } + every { bitrate } returns if (audio) 128_000 else 2_000_000 + every { width } returns if (audio) 0 else 1920 + every { height } returns if (audio) 0 else 1080 + } + + private data class Fixture( + val holder: SabrSessionHolder, + val session: AndroidPlaybackSession, + val manifests: AndroidDashManifestService, + val ready: MutableMap, + ) +} diff --git a/src/test/kotlin/dev/typetype/server/services/AndroidPlaybackServiceTest.kt b/src/test/kotlin/dev/typetype/server/services/AndroidPlaybackServiceTest.kt index 6077f322..8c07e64c 100644 --- a/src/test/kotlin/dev/typetype/server/services/AndroidPlaybackServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/AndroidPlaybackServiceTest.kt @@ -26,7 +26,11 @@ class AndroidPlaybackServiceTest { } val service = AndroidPlaybackService(store, mockk(relaxed = true)) - val result = service.seek(holder, generation = 0L, playerTimeMs = 45_000L) as AndroidPlaybackSeekResult.Ready + val result = service.seek( + AndroidPlaybackSession(holder, emptyList()), + generation = 0L, + playerTimeMs = 45_000L, + ) as AndroidPlaybackSeekResult.Ready assertSame(holder, result.holder) assertEquals(1L, holder.activeGeneration()) @@ -45,7 +49,10 @@ class AndroidPlaybackServiceTest { holder.advancePlaybackGeneration(1_000L) val service = AndroidPlaybackService(mockk(relaxed = true), mockk(relaxed = true)) - assertEquals(AndroidPlaybackSeekResult.StaleGeneration, service.seek(holder, 0L, 30_000L)) + assertEquals( + AndroidPlaybackSeekResult.StaleGeneration, + service.seek(AndroidPlaybackSession(holder, emptyList()), 0L, 30_000L), + ) assertEquals(1L, holder.activeGeneration()) } diff --git a/src/test/kotlin/dev/typetype/server/services/AndroidPlaybackSessionRegistryTest.kt b/src/test/kotlin/dev/typetype/server/services/AndroidPlaybackSessionRegistryTest.kt index 2cb1e0d2..60a3acb7 100644 --- a/src/test/kotlin/dev/typetype/server/services/AndroidPlaybackSessionRegistryTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/AndroidPlaybackSessionRegistryTest.kt @@ -27,6 +27,23 @@ class AndroidPlaybackSessionRegistryTest { verify(exactly = 1) { store.lookupByToken("android-session") } } + @Test + fun `registered subtitle catalog is an immutable snapshot`() { + val current = MutableNow(Instant.parse("2026-07-20T10:00:00Z")) + val store = mockk() + val holder = holder() + val track = mockk() + every { store.lookupByToken("android-session") } returns holder + val source = mutableListOf(track) + val registry = registry(store, current) + registry.register(holder, source) + + source.clear() + val active = registry.lookup("android-session") as AndroidPlaybackSessionLookup.Active + + assertSame(track, active.session.subtitles.single()) + } + @Test fun `idle session returns gone while unknown session returns not found`() { val current = MutableNow(Instant.parse("2026-07-20T10:00:00Z")) diff --git a/src/test/kotlin/dev/typetype/server/services/AndroidSubtitleInventoryCoordinatorTest.kt b/src/test/kotlin/dev/typetype/server/services/AndroidSubtitleInventoryCoordinatorTest.kt new file mode 100644 index 00000000..23a44f04 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/AndroidSubtitleInventoryCoordinatorTest.kt @@ -0,0 +1,79 @@ +package dev.typetype.server.services + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class AndroidSubtitleInventoryCoordinatorTest { + @Test + fun `concurrent requests share one in flight inventory extraction`() = runTest { + val release = CompletableDeferred() + val track = mockk() + val service = mockk() + coEvery { service.inventory(VIDEO_ID) } coAnswers { + release.await() + AndroidSubtitleInventoryResult.Ready(listOf(track)) + } + val coordinator = AndroidSubtitleInventoryCoordinator(service, this) + + val first = coordinator.start(VIDEO_ID) + val second = coordinator.start(VIDEO_ID) + runCurrent() + + assertSame(first, second) + assertEquals(AndroidSubtitleInventorySnapshot.Preparing, first.snapshot()) + release.complete(Unit) + val completed = async { first.await() }.await() as AndroidSubtitleInventorySnapshot.Ready + assertSame(track, completed.tracks.single()) + coVerify(exactly = 1) { service.inventory(VIDEO_ID) } + } + + @Test + fun `completed inventory is reused until its cache entry expires`() = runTest { + var now = 1_000L + val service = mockk() + coEvery { service.inventory(VIDEO_ID) } returns AndroidSubtitleInventoryResult.Ready(emptyList()) + val coordinator = AndroidSubtitleInventoryCoordinator( + service, + this, + cacheTtlMs = 500L, + nowMs = { now }, + ) + + val first = coordinator.start(VIDEO_ID) + runCurrent() + assertEquals(AndroidSubtitleInventorySnapshot.Ready(emptyList()), first.await()) + assertSame(first, coordinator.start(VIDEO_ID)) + now += 501L + val refreshed = coordinator.start(VIDEO_ID) + runCurrent() + + refreshed.await() + coVerify(exactly = 2) { service.inventory(VIDEO_ID) } + } + + @Test + fun `unavailable inventory completes with a typed temporary failure`() = runTest { + val service = mockk() + coEvery { service.inventory(VIDEO_ID) } returns AndroidSubtitleInventoryResult.Unavailable + val coordinator = AndroidSubtitleInventoryCoordinator(service, this) + + val result = coordinator.start(VIDEO_ID) + runCurrent() + + assertEquals(AndroidSubtitleInventorySnapshot.TemporaryFailure, result.await()) + } + + private companion object { + const val VIDEO_ID = "dQw4w9WgXcQ" + } +} diff --git a/src/test/kotlin/dev/typetype/server/services/AndroidSubtitleServiceTest.kt b/src/test/kotlin/dev/typetype/server/services/AndroidSubtitleServiceTest.kt index 1ad03c24..a996a2b9 100644 --- a/src/test/kotlin/dev/typetype/server/services/AndroidSubtitleServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/AndroidSubtitleServiceTest.kt @@ -4,6 +4,7 @@ import dev.typetype.server.models.SubtitleItem import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals @@ -11,16 +12,19 @@ import org.junit.jupiter.api.Test class AndroidSubtitleServiceTest { @Test - fun `inventory exposes all Token tracks with stable identities`() = runTest { + fun `inventory exposes all Token tracks without fetching slow subtitle content`() = runTest { val source = mockk() + val http = mockk() val items = listOf(item("en", false), item("en", true), item("fr", false)) coEvery { source.fetchSubtitleInventory(VIDEO_ID) } returns YouTubeSubtitleInventoryResult.Ready(items) - val service = AndroidSubtitleService(source, mockk()) + coEvery { http.fetch(any()) } coAnswers { awaitCancellation() } + val service = AndroidSubtitleService(source, http) val result = service.inventory(VIDEO_ID) as AndroidSubtitleInventoryResult.Ready assertEquals(3, result.tracks.size) assertEquals(3, result.tracks.map { it.id }.distinct().size) + coVerify(exactly = 0) { http.fetch(any()) } } @Test diff --git a/src/test/kotlin/dev/typetype/server/services/SabrCachedSegmentLocatorTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrCachedSegmentLocatorTest.kt index 89e70747..4cf003ee 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrCachedSegmentLocatorTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrCachedSegmentLocatorTest.kt @@ -2,6 +2,7 @@ package dev.typetype.server.services import io.mockk.every import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertSame import org.junit.jupiter.api.Test import org.schabi.newpipe.extractor.services.youtube.sabr.SabrMediaHeader @@ -70,6 +71,90 @@ class SabrCachedSegmentLocatorTest { assertSame(segment, found) } + @Test + fun `finds next live segment across a multi-sequence gap`() { + val format = mockk() + val session = mockk() + val segment = segment(sequence = 3_909, startMs = 19_545_066L, durationMs = 5_000L) + every { format.itag } returns 137 + every { session.getCachedSegment(any()) } answers { + firstArg().takeIf { it.sequenceNumber == 3_909 }?.let { segment } + } + + val found = session.findCachedMediaAt( + format = format, + targetMs = 19_530_066L, + predictedSequence = 3_906, + fallbackDurationMs = 5_000L, + allowFollowing = true, + ) + + assertSame(segment, found) + } + + @Test + fun `accepts cumulative audio rounding across a live sequence gap`() { + val format = mockk() + val session = mockk() + val segment = segment(sequence = 3_980, startMs = 19_900_081L, durationMs = -1L) + every { format.itag } returns 140 + every { session.getCachedSegment(any()) } answers { + firstArg().takeIf { it.sequenceNumber == 3_980 }?.let { segment } + } + + val found = session.findCachedMediaAt( + format = format, + targetMs = 19_865_073L, + predictedSequence = 3_973, + fallbackDurationMs = 5_000L, + allowFollowing = true, + ) + + assertSame(segment, found) + } + + @Test + fun `accepts live segment just beyond exact sequence duration`() { + val format = mockk() + val session = mockk() + val segment = segment(sequence = 5_947, startMs = 29_735_079L, durationMs = -1L) + every { format.itag } returns 140 + every { session.getCachedSegment(any()) } answers { + firstArg().takeIf { it.sequenceNumber == 5_947 }?.let { segment } + } + + val found = session.findCachedMediaAt( + format = format, + targetMs = 29_720_066L, + predictedSequence = 5_944, + fallbackDurationMs = 5_000L, + allowFollowing = true, + ) + + assertSame(segment, found) + } + + @Test + fun `rejects following live segment beyond its sequence range`() { + val format = mockk() + val session = mockk() + val segment = segment(sequence = 3_909, startMs = 19_550_067L, durationMs = 5_000L) + every { format.itag } returns 137 + every { session.getCachedSegment(any()) } answers { + firstArg().takeIf { it.sequenceNumber == 3_909 }?.let { segment } + } + + val found = session.findCachedMediaAt( + format = format, + targetMs = 19_530_066L, + predictedSequence = 3_906, + fallbackDurationMs = 5_000L, + allowFollowing = true, + ) + + assertNull(found) + } + private fun segment(sequence: Int, startMs: Long, durationMs: Long): SabrMediaSegment { val header = mockk() every { header.sequenceNumber } returns sequence diff --git a/src/test/kotlin/dev/typetype/server/services/SabrDemandFailurePumpTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrDemandFailurePumpTest.kt index 5fb4e73e..1496c267 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrDemandFailurePumpTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrDemandFailurePumpTest.kt @@ -12,9 +12,11 @@ import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest import kotlinx.coroutines.withTimeout 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.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 @@ -22,11 +24,12 @@ 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.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger @OptIn(ExperimentalCoroutinesApi::class) class SabrDemandFailurePumpTest { @Test - fun `three target omissions terminate demand pump`() = runTest { + fun `later target response resolves demand without replacing session`() = runTest { SabrSegmentDemandTracker.clearAll() try { val audio = format(140, true) @@ -34,21 +37,37 @@ class SabrDemandFailurePumpTest { val request = SabrSegmentRequest.media(audio, 39) val session = mockk(relaxed = true) val streamState = mockk(relaxed = true) + val responseCount = AtomicInteger() + val segment = mockk(relaxed = true) every { session.streamState } returns streamState - every { session.getCachedSegment(any()) } returns null + every { session.getCachedSegment(any()) } answers { + segment.takeIf { + firstArg().format.itag == audio.itag && + firstArg().sequenceNumber == request.sequenceNumber && + responseCount.get() >= 4 + } + } every { streamState.getSegmentStartMs(audio, 39) } returns 379_414L every { streamState.getMinBufferedEndMs() } returns 379_233L every { streamState.getBufferedEndMs(audio) } returns 379_233L - every { session.pumpOnceStreamingForDemand(any(), request) } returns result() + every { session.pumpOnceStreamingForDemand(any(), request) } answers { + responseCount.incrementAndGet() + result() + } val holder = holder(session, audio, video) holder.requestSegmentDemand(request) - SabrSessionPump().pumpLoop({ true }, holder, intervalMs = 100L) + SabrSessionPump().pumpLoop( + isAlive = { holder.pendingSegmentDemandSummary() != null }, + holder = holder, + intervalMs = 100L, + ) - assertEquals(SabrPlaybackState.TERMINAL, holder.playbackState()) - assertEquals("SABR demand stalled for 140:39", holder.terminalFailure()) + assertFalse(holder.playbackState() == SabrPlaybackState.TERMINAL) + assertNull(holder.terminalFailure()) assertNull(holder.pendingSegmentDemandSummary()) - verify(exactly = 3) { session.pumpOnceStreamingForDemand(any(), request) } + verify(exactly = 4) { session.pumpOnceStreamingForDemand(any(), request) } + verify(exactly = 1) { session.prepareForMissingSegment(request) } } finally { SabrSegmentDemandTracker.clearAll() } diff --git a/src/test/kotlin/dev/typetype/server/services/SabrDemandWatchdogBackoffTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrDemandWatchdogBackoffTest.kt index fa6aa889..6607bafb 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrDemandWatchdogBackoffTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrDemandWatchdogBackoffTest.kt @@ -76,6 +76,30 @@ class SabrDemandWatchdogBackoffTest { } } + @Test + fun `renewed backoffs cannot extend a demand forever`() = runTest { + withTracker { holder -> + val request = SabrSegmentRequest.media(holder.videoFormat, 50) + every { holder.session.demandBackoffRemainingMs } returns 2_000L + holder.requestSegmentDemand(request, registeredAtMs = 0L) + var expired = false + val job = launch { + expired = watchdog { testScheduler.currentTime }.monitor({ true }, holder) + } + runCurrent() + + advanceTimeBy(45_900L) + runCurrent() + assertFalse(job.isCompleted) + + advanceTimeBy(100L) + runCurrent() + assertTrue(job.isCompleted) + assertTrue(expired) + assertEquals("SABR demand stalled for 299:50", holder.terminalFailure()) + } + } + private fun watchdog(clock: () -> Long): SabrDemandWatchdog = SabrDemandWatchdog( clock = clock, intervalMs = 100L, diff --git a/src/test/kotlin/dev/typetype/server/services/SabrDemandWatchdogLifecycleTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrDemandWatchdogLifecycleTest.kt index e84a32b2..a0be4993 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrDemandWatchdogLifecycleTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrDemandWatchdogLifecycleTest.kt @@ -114,6 +114,43 @@ class SabrDemandWatchdogLifecycleTest { } } + @Test + fun `historical live demand outside recoverable window ignores backoff`() = runTest { + withTracker { holder -> + val request = SabrSegmentRequest.media(holder.videoFormat, 50) + var liveHeadTimeMs = 470_250L + every { holder.session.isLive } returns true + every { holder.session.streamState.isLive } returns true + every { holder.session.streamState.liveHeadTimeMs } answers { liveHeadTimeMs } + every { holder.session.streamState.getSegmentStartMs(holder.videoFormat, 50) } returns 400_000L + every { holder.session.streamState.getSegmentEndMs(holder.videoFormat, 50) } returns 410_000L + every { holder.session.demandBackoffRemainingMs } returns 30_000L + assertFalse(holder.isLiveDemandOutsideRecoverableWindow(request)) + liveHeadTimeMs++ + assertTrue(holder.isLiveDemandOutsideRecoverableWindow(request)) + holder.requestSegmentDemand(request, registeredAtMs = 0L) + val identity = requireNotNull(holder.segmentDemandIdentity(request)) + assertTrue(holder.beginInFlightSegmentDemand(request, identity, futureLiveRequest = false)) + holder.clearSegmentDemand(request) + var expired = false + + val job = launch { + expired = SabrDemandWatchdog( + clock = { testScheduler.currentTime }, + intervalMs = 100L, + ).monitor({ true }, holder) + } + runCurrent() + + assertTrue(job.isCompleted) + assertTrue(expired) + assertEquals( + "$SABR_RECOVERABLE_FAILURE_PREFIX SABR demand stalled for 299:50", + holder.terminalFailure(), + ) + } + } + @Test fun `completed in flight demand interrupts without terminal failure`() = runTest { withTracker { holder -> diff --git a/src/test/kotlin/dev/typetype/server/services/SabrExactInitializationDataTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrExactInitializationDataTest.kt new file mode 100644 index 00000000..59df2907 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/SabrExactInitializationDataTest.kt @@ -0,0 +1,74 @@ +package dev.typetype.server.services + +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertArrayEquals +import org.junit.jupiter.api.Assertions.assertTrue +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 +import java.time.Instant +import java.util.concurrent.atomic.AtomicBoolean + +class SabrExactInitializationDataTest { + @Test + fun `exact Android initialization uses the video-bound token and publishes the index`() = runTest { + val audio = format(140, true) + val video = format(137, false) + val token = byteArrayOf(1, 2, 3) + val bytes = byteArrayOf(4, 5, 6) + val indexed = AtomicBoolean() + val state = mockk(relaxed = true) { + every { poToken } returns token + every { hasSegmentIndex(video) } answers { indexed.get() } + } + val sabr = mockk(relaxed = true) { + every { streamState } returns state + every { + fetchInitializationData(video, any(), 3_500L, match { it.contentEquals(token) }) + } answers { + indexed.set(true) + bytes + } + } + val holder = SabrSessionHolder( + sabr, + mockk(), + audio, + video, + "android-session", + SabrSessionKey( + "exact-init-video", + "user", + 140, + null, + 137, + 0L, + SabrSessionPurpose.ANDROID_PLAYBACK, + ), + Instant.EPOCH, + ) + + val result = SabrInitializationData.fetchExact(holder, video, 3_500L) + + assertArrayEquals(bytes, result) + assertTrue(state.hasSegmentIndex(video)) + verify(exactly = 1) { + sabr.fetchInitializationData(video, any(), 3_500L, match { it.contentEquals(token) }) + } + } + + private fun format(itag: Int, audio: Boolean): YoutubeSabrFormat = mockk { + every { this@mockk.itag } returns itag + every { isAudio } returns audio + every { lastModified } returns 1L + every { xtags } returns null + every { mimeType } returns if (audio) "audio/mp4" else "video/mp4" + every { audioTrackId } returns null + } +} diff --git a/src/test/kotlin/dev/typetype/server/services/SabrLivePlaybackSessionServiceTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrLivePlaybackSessionServiceTest.kt index bde2a853..da2976fb 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrLivePlaybackSessionServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrLivePlaybackSessionServiceTest.kt @@ -88,6 +88,8 @@ class SabrLivePlaybackSessionServiceTest { assertEquals(985_000L, result.startTimeMs) assertEquals(985_000L, holder.playerTimeMs()) assertTrue(holder.expectsLive()) + verify(exactly = 1) { state.setPlayerTimeMs(9_007_199_254_740_991L) } + verify(exactly = 1) { state.setWriteTopLevelPlayerTimeMs(false) } coVerify(exactly = 1) { store.ensureWarmed(holder, 8) } coVerify(exactly = 0) { store.fetchInitializationData(any(), any()) } verify(exactly = 1) { store.startPump(holder) } @@ -148,6 +150,8 @@ class SabrLivePlaybackSessionServiceTest { assertEquals(1_000_000L, replacement.readerPosition(video)) assertFalse(replacement.mediaRequestsAt(995_000L).any { it.format.itag == audio.itag }) assertFalse(replacement.mediaRequestsAt(995_000L).any { it.format.itag == video.itag }) + verify(exactly = 0) { replacementState.setPlayerTimeMs(9_007_199_254_740_991L) } + verify(exactly = 0) { replacementState.setWriteTopLevelPlayerTimeMs(false) } coVerify(exactly = 1) { store.ensureWarmed(replacement, 8) } verify(exactly = 1) { store.startPump(replacement) } } diff --git a/src/test/kotlin/dev/typetype/server/services/SabrPlaybackCachedSegmentLocatorTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrPlaybackCachedSegmentLocatorTest.kt index de9c2f6c..5d7617f2 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrPlaybackCachedSegmentLocatorTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrPlaybackCachedSegmentLocatorTest.kt @@ -44,6 +44,66 @@ class SabrPlaybackCachedSegmentLocatorTest { assertEquals(1841446, segment?.sequence) } + @Test + fun `finds cached audio after a rounded multi-sequence live gap`() = runTest { + val store = mockk() + val holder = mockk() + val format = mockk() + val session = mockk() + val state = mockk() + every { format.itag } returns 140 + every { holder.observedMediaSegment(format) } returns null + every { holder.session } returns session + every { session.streamState } returns state + every { state.getSegmentStartMs(format, any()) } returns 0L + every { state.getSegmentEndMs(format, any()) } returns 5_000L + coEvery { store.cachedSegment(holder, any()) } answers { + secondArg().sequenceNumber + .takeIf { it == 3_980 } + ?.let { cached(it, 19_900_081L) } + } + + val segment = store.findCachedPlaybackMediaAt( + holder, + format, + targetMs = 19_865_073L, + predictedSequence = 3_973, + allowFollowing = true, + ) + + assertEquals(3_980, segment?.sequence) + } + + @Test + fun `uses the same live timing tolerance as demand resolution`() = runTest { + val store = mockk() + val holder = mockk() + val format = mockk() + val session = mockk() + val state = mockk() + every { format.itag } returns 140 + every { holder.observedMediaSegment(format) } returns null + every { holder.session } returns session + every { session.streamState } returns state + every { state.getSegmentStartMs(format, any()) } returns 0L + every { state.getSegmentEndMs(format, any()) } returns 5_000L + coEvery { store.cachedSegment(holder, any()) } answers { + secondArg().sequenceNumber + .takeIf { it == 5_947 } + ?.let { cached(it, 29_735_079L) } + } + + val segment = store.findCachedPlaybackMediaAt( + holder, + format, + targetMs = 29_720_066L, + predictedSequence = 5_944, + allowFollowing = true, + ) + + assertEquals(5_947, segment?.sequence) + } + private fun cached(sequence: Int, startMs: Long): CachedSabrSegment = CachedSabrSegment( itag = 140, sequence = sequence, diff --git a/src/test/kotlin/dev/typetype/server/services/SabrPlaybackDeferredPreparationTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrPlaybackDeferredPreparationTest.kt new file mode 100644 index 00000000..00f055b5 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/SabrPlaybackDeferredPreparationTest.kt @@ -0,0 +1,75 @@ +package dev.typetype.server.services + +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertSame +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 + +class SabrPlaybackDeferredPreparationTest { + @Test + fun `android preparation can defer initialization and pump startup`() = runTest { + val audio = mockk { every { itag } returns 140 } + val video = mockk { every { itag } returns 137 } + val state = mockk(relaxed = true) + val session = mockk(relaxed = true) { every { streamState } returns state } + val holder = SabrSessionHolder( + session, + mockk(), + audio, + video, + "android-session", + SabrSessionKey( + "video", + "user", + 140, + null, + 137, + 0L, + SabrSessionPurpose.ANDROID_PLAYBACK, + ), + Instant.EPOCH, + ) + val info = mockk() + val prepared = SabrPreparedInfo(info, null) + val store = mockk { + every { + getOrCreate( + "video", + "user", + info, + audio, + video, + null, + 0L, + false, + SabrSessionPurpose.ANDROID_PLAYBACK, + false, + 0L, + ) + } returns holder + } + + val result = SabrPlaybackSessionService(store, SabrSessionPurpose.ANDROID_PLAYBACK).prepare( + "video", + "user", + prepared, + audio, + video, + 0L, + preloadInitialization = false, + startPumpOnPrepare = false, + ) + + assertSame(holder, result.holder) + coVerify(exactly = 0) { store.fetchInitializationData(any(), any()) } + verify(exactly = 0) { store.startPump(any()) } + } +} diff --git a/src/test/kotlin/dev/typetype/server/services/SabrPlaybackLiveGapServiceTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrPlaybackLiveGapServiceTest.kt index 0339b356..36495ca9 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrPlaybackLiveGapServiceTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrPlaybackLiveGapServiceTest.kt @@ -6,6 +6,7 @@ import io.mockk.mockk import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertArrayEquals import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.schabi.newpipe.extractor.services.youtube.sabr.SabrSegmentRequest import org.schabi.newpipe.extractor.services.youtube.sabr.YoutubeSabrFormat @@ -46,6 +47,41 @@ class SabrPlaybackLiveGapServiceTest { assertEquals(93, holder.lastServedSequence(video)) } + @Test + fun `rejects a following live segment outside the recoverable window`() = runTest { + val video = format(299, isAudio = false) + val audio = format(140, isAudio = true) + val session = mockk(relaxed = true) + val state = mockk(relaxed = true) + every { session.streamState } returns state + every { session.isLive } returns true + every { state.isLive } returns true + every { state.liveHeadTimeMs } returns 31_680_059L + every { state.getSegmentStartMs(video, 6_320) } returns 31_600_059L + every { state.getSegmentEndMs(video, 6_320) } returns 31_605_059L + val holder = holder(session, audio, video) + holder.markExpectedLive() + holder.setLastServedSequence(video.itag, 6_319) + val replacement = cachedSegment(299, 6_335, 31_675_059L, byteArrayOf(1, 2, 3)) + val store = mockk() + coEvery { store.cachedSegment(holder, any()) } answers { + secondArg().takeIf { it.sequenceNumber == 6_335 }?.let { replacement } + } + every { store.requestSegmentDemand(holder, any(), 0L) } returns Unit + + val result = SabrPlaybackSessionService(store).fetchMedia( + holder = holder, + format = video, + sequence = 6_320, + timeoutMs = 1_000L, + generation = 0L, + ) + + assertEquals(SabrPlaybackSegmentResult.Retry(holder, "repositioning"), result) + assertEquals(6_319, holder.lastServedSequence(video)) + assertTrue(holder.terminalFailure().orEmpty().contains("live 299 media discontinuity")) + } + private fun holder( session: YoutubeSabrSession, audio: YoutubeSabrFormat, diff --git a/src/test/kotlin/dev/typetype/server/services/SabrProtectedResponseGuardTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrProtectedResponseGuardTest.kt new file mode 100644 index 00000000..55074b24 --- /dev/null +++ b/src/test/kotlin/dev/typetype/server/services/SabrProtectedResponseGuardTest.kt @@ -0,0 +1,74 @@ +package dev.typetype.server.services + +import org.junit.jupiter.api.Assertions.assertDoesNotThrow +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class SabrProtectedResponseGuardTest { + @Test + fun `bounded protection responses become terminal`() { + val guard = SabrProtectedResponseGuard(maximumResponses = 5) + + repeat(4) { index -> + assertDoesNotThrow { guard.verify(response(index + 1, protection = 3, segments = 0), 0, 0) } + } + + assertThrows(SabrProtectedNoMediaException::class.java) { + guard.verify(response(5, protection = 3, segments = 0), 0, 0) + } + } + + @Test + fun `media progress resets protection response budget`() { + val guard = SabrProtectedResponseGuard(maximumResponses = 3) + + guard.verify(response(1, protection = 3, segments = 0), 0, 0) + guard.verify(response(2, protection = 3, segments = 0), 0, 0) + guard.verify(response(3, protection = 1, segments = 1), 0, 0) + guard.verify(response(4, protection = 3, segments = 0), 0, 0) + assertDoesNotThrow { guard.verify(response(5, protection = 3, segments = 0), 0, 0) } + } + + @Test + fun `advancing playback resets protection response budget`() { + val guard = SabrProtectedResponseGuard(maximumResponses = 3) + + guard.verify(response(1, protection = 3, segments = 0), 0, 53_000) + guard.verify(response(2, protection = 3, segments = 0), 0, 53_000) + guard.verify(response(3, protection = 3, segments = 0), 0, 55_000) + assertDoesNotThrow { + guard.verify(response(4, protection = 3, segments = 0), 0, 55_000) + } + assertThrows(SabrProtectedNoMediaException::class.java) { + guard.verify(response(5, protection = 3, segments = 0), 0, 55_000) + } + } + + @Test + fun `new playback generation resets protection response budget`() { + val guard = SabrProtectedResponseGuard(maximumResponses = 3) + + guard.verify(response(1, protection = 3, segments = 0), 0, 53_000) + guard.verify(response(2, protection = 3, segments = 0), 0, 53_000) + guard.verify(response(3, protection = 3, segments = 0), 1, 370_000) + assertDoesNotThrow { + guard.verify(response(4, protection = 3, segments = 0), 1, 370_000) + } + assertThrows(SabrProtectedNoMediaException::class.java) { + guard.verify(response(5, protection = 3, segments = 0), 1, 370_000) + } + } + + @Test + fun `same response is counted once`() { + val guard = SabrProtectedResponseGuard(maximumResponses = 2) + val response = response(8, protection = 3, segments = 0) + + guard.verify(response, 0, 0) + assertDoesNotThrow { guard.verify(response, 0, 0) } + } + + private fun response(number: Int, protection: Int, segments: Int): String = + "request n=$number | response n=$number http=200 segments=count=$segments " + + "decoded={protection=$protection/20, backoffMs=2000}" +} diff --git a/src/test/kotlin/dev/typetype/server/services/SabrPumpPolicyTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrPumpPolicyTest.kt index cdb17d1f..b678c208 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrPumpPolicyTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrPumpPolicyTest.kt @@ -6,8 +6,10 @@ import org.junit.jupiter.api.Test class SabrPumpPolicyTest { @Test fun `live demand only waits at the actual edge`() { - assertEquals(100L, SabrPumpPolicy.demandDelayMs(100L, 0L, activeLive = true, futureDemand = false)) + assertEquals(LIVE_EDGE_POLL_MS, SabrPumpPolicy.demandDelayMs(100L, 0L, activeLive = true, futureDemand = false)) assertEquals(LIVE_EDGE_POLL_MS, SabrPumpPolicy.demandDelayMs(100L, 0L, activeLive = true, futureDemand = true)) assertEquals(LIVE_EDGE_POLL_MS, SabrPumpPolicy.demandDelayMs(100L, 0L, activeLive = true, futureDemand = null)) + assertEquals(LIVE_EDGE_POLL_MS, SabrPumpPolicy.demandDelayMs(100L, 0L, activeLive = false, futureDemand = false)) + assertEquals(100L, SabrPumpPolicy.demandDelayMs(100L, 0L, activeLive = false, futureDemand = null)) } } diff --git a/src/test/kotlin/dev/typetype/server/services/SabrPumpRuntimeTest.kt b/src/test/kotlin/dev/typetype/server/services/SabrPumpRuntimeTest.kt index 4142cc1c..98678ed5 100644 --- a/src/test/kotlin/dev/typetype/server/services/SabrPumpRuntimeTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/SabrPumpRuntimeTest.kt @@ -69,13 +69,14 @@ class SabrPumpRuntimeTest { } @Test - fun `three responses without demanded segment fail demand`() { + fun `repeated responses without demanded segment stay retryable`() { val runtime = SabrPumpRuntime { 1_000L } runtime.beginDemand("140:44") assertEquals(SabrDemandRecoveryAction.READVERTISE_TRACK, runtime.demandRecoveryAction("140:44", 1, false)) - assertEquals(SabrDemandRecoveryAction.WAIT, runtime.demandRecoveryAction("140:44", 1, false)) - assertEquals(SabrDemandRecoveryAction.FAIL, runtime.demandRecoveryAction("140:44", 1, false)) + repeat(5) { + assertEquals(SabrDemandRecoveryAction.WAIT, runtime.demandRecoveryAction("140:44", 1, false)) + } } private fun holder( diff --git a/src/test/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionClientTest.kt b/src/test/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionClientTest.kt index ff5366b5..15694f8a 100644 --- a/src/test/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionClientTest.kt +++ b/src/test/kotlin/dev/typetype/server/services/TypetypeTokenYoutubeSessionClientTest.kt @@ -43,7 +43,7 @@ class TypetypeTokenYoutubeSessionClientTest { "visitorData":"visitor-data", "poToken":"AQ", "streamingPot":"Ag", - "serverAbrStreamingUrl":"https://example.com/sabr?cver=2.20260205.04.01", + "serverAbrStreamingUrl":"https://example.com/sabr?cver=2.20260205.04.01&cpn=token-cpn", "rawServerAbrStreamingUrl":"https://example.com/raw-sabr", "hlsManifestUrl":"https://example.com/live.m3u8", "videoPlaybackUstreamerConfig":"ustreamer-config", @@ -106,8 +106,12 @@ class TypetypeTokenYoutubeSessionClientTest { assertNotNull(info) assertEquals("video-id", info?.videoId) - assertEquals("https://example.com/sabr?cver=2.20260205.04.01", info?.serverAbrStreamingUrl) + assertEquals( + "https://example.com/sabr?cver=2.20260205.04.01&cpn=token-cpn", + info?.serverAbrStreamingUrl, + ) assertEquals("2.20260205.04.01", info?.clientVersion) + assertEquals("token-cpn", info?.cpn) assertEquals(2, info?.formats?.size) assertTrue(info?.formats?.any { it.itag == 137 && it.contentLength == 1_000_000L } == true) assertTrue(info?.formats?.any { it.itag == 140 && it.audioTrackId == "fr-FR.4" } == true)