From 816b903b556ee2363625dae06c75bdb0ac58d018 Mon Sep 17 00:00:00 2001 From: Quick <31828688+Quick104@users.noreply.github.com> Date: Sun, 26 Jul 2026 02:14:28 +0000 Subject: [PATCH] fix(playback): take the media runtime from the v3 plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v3 plan carries a source descriptor the client never decoded, and toSessionResponse never set durationSeconds at all. With no runtime from the server, the duration fell through to the catalog value and then to the playback engine's report. On an HLS copy remux the engine reports the window FFmpeg has produced so far, so a feature film displayed as about a minute. Decode source.duration_seconds and carry it into the session response. It stays nullable end to end: SiloJson sets coerceInputValues, so a non-nullable Double would turn an unknown runtime into 0.0 — the value the grow-only ratchet then has no floor to defend against, which is how the engine's window won in the first place. The descriptor and every field default, so a server predating it still decodes and simply reports an unknown runtime. Co-Authored-By: Claude Opus 5 (1M context) --- .../silo/common/player/PlaybackV3Session.kt | 6 +++ .../common/player/PlaybackV3SessionTest.kt | 52 +++++++++++++++++++ .../silo/model/playback/PlaybackProtocolV3.kt | 24 +++++++++ 3 files changed, 82 insertions(+) diff --git a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt index daa76ece6..d045d087d 100644 --- a/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt +++ b/android-shared/src/androidMain/kotlin/org/siloserver/silo/common/player/PlaybackV3Session.kt @@ -134,6 +134,12 @@ internal fun PlaybackPlanV3.toSessionResponse( position = timeline.sourceStartSeconds, streamUrl = stream.url, audioTrackIndex = selectedTracks.audio?.index ?: 0, + // The server's runtime for the effective file, or null when it does + // not know. Callers must keep null as null rather than substituting + // the engine's duration: on an HLS copy remux the engine reports the + // window produced so far, so adopting it shows a feature film as a + // couple of minutes. + durationSeconds = source.durationSeconds, subtitleUrls = subtitles, playbackPlan = legacyPlan, ) diff --git a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackV3SessionTest.kt b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackV3SessionTest.kt index 731b6d08f..4d563a6ae 100644 --- a/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackV3SessionTest.kt +++ b/android-shared/src/androidUnitTest/kotlin/org/siloserver/silo/common/player/PlaybackV3SessionTest.kt @@ -3,6 +3,7 @@ package org.siloserver.silo.common.player import org.siloserver.silo.model.playback.PlaybackDelivery import org.siloserver.silo.model.playback.PlaybackEngineKind import org.siloserver.silo.model.playback.PlaybackPlanV3 +import org.siloserver.silo.model.playback.PlaybackSourceDescriptorV3 import org.siloserver.silo.model.playback.PlaybackStreamProtocol import org.siloserver.silo.model.playback.PlaybackStreamV3 import org.siloserver.silo.model.playback.PlaybackSubtitleArtifactV3 @@ -11,8 +12,10 @@ import org.siloserver.silo.model.playback.PlaybackSubtitleModeV3 import org.siloserver.silo.model.playback.PlaybackTimelineV3 import org.siloserver.silo.model.playback.PlaybackTrackIdentityV3 import org.siloserver.silo.model.playback.SelectedPlaybackTracksV3 +import org.siloserver.silo.network.SiloJson import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class PlaybackV3SessionTest { @Test @@ -75,12 +78,61 @@ class PlaybackV3SessionTest { assertEquals(timeline.seekRestoration, converted.seekRestoration) } + @Test + fun sourceRuntimeReachesTheSessionResponse() { + val response = plan( + mode = PlaybackSubtitleModeV3.OFF, + format = "", + url = "", + source = PlaybackSourceDescriptorV3(mediaFileId = 482, durationSeconds = 5400.0), + ).toSessionResponse("session", "profile", 482) + + assertEquals(5400.0, response.durationSeconds) + } + + // A server that does not know the runtime must leave the client knowing it + // does not know. Substituting 0.0 here is what let the playback engine's + // growing-HLS-window duration win and show a feature film as a minute. + @Test + fun unknownSourceRuntimeStaysUnknown() { + val response = plan( + mode = PlaybackSubtitleModeV3.OFF, + format = "", + url = "", + source = PlaybackSourceDescriptorV3(mediaFileId = 482), + ).toSessionResponse("session", "profile", 482) + + assertNull(response.durationSeconds) + } + + // Servers predating the descriptor omit it entirely; decoding must not fail + // and the runtime must read as unknown rather than zero. + @Test + fun planWithoutASourceDescriptorDecodesWithAnUnknownRuntime() { + val decoded = SiloJson.decodeFromString( + """ + { + "plan_id": "plan", + "delivery": "original_http", + "engine": "media3_direct", + "stream": {"url": "/stream/session", "protocol": "http_progressive"}, + "decision_reason": "test" + } + """.trimIndent(), + ) + + assertNull(decoded.source.durationSeconds) + assertNull(decoded.toSessionResponse("session", "profile", 482).durationSeconds) + } + private fun plan( mode: PlaybackSubtitleModeV3, format: String, url: String, timeline: PlaybackTimelineV3 = PlaybackTimelineV3(), + source: PlaybackSourceDescriptorV3 = PlaybackSourceDescriptorV3(), ) = PlaybackPlanV3( + source = source, planId = "plan", delivery = PlaybackDelivery.ORIGINAL_HTTP, engine = PlaybackEngineKind.MEDIA3_DIRECT, diff --git a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt index 6de30d3c2..70d92606d 100644 --- a/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt +++ b/shared/src/commonMain/kotlin/org/siloserver/silo/model/playback/PlaybackProtocolV3.kt @@ -134,6 +134,30 @@ data class PlaybackPlanV3( @SerialName("decision_reason") val decisionReason: String, @SerialName("requested_media_file_id") val requestedMediaFileId: Int? = null, @SerialName("effective_media_file_id") val effectiveMediaFileId: Int? = null, + val source: PlaybackSourceDescriptorV3 = PlaybackSourceDescriptorV3(), +) + +/** + * Facts about the media file the plan resolved to, as opposed to the transport + * carrying it. Defaulted throughout so a server that predates the descriptor + * still decodes. + */ +@Serializable +data class PlaybackSourceDescriptorV3( + @SerialName("media_file_id") val mediaFileId: Int? = null, + /** + * Full runtime of the source, or null when the server does not know it. + * + * Null must survive as null: `SiloJson` sets `coerceInputValues`, so a + * non-nullable `Double` here would silently become 0.0 — the very value + * this field exists to stop the player inventing. + * + * This is the whole file, never `total - sourceStartSeconds`, and it is + * never adjusted by `timelineOffsetSeconds`. Do not substitute the + * engine's reported duration for it: on an HLS copy remux the engine + * reports the window produced so far, not the runtime. + */ + @SerialName("duration_seconds") val durationSeconds: Double? = null, ) @Serializable