diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerStatsEvents.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerStatsEvents.kt new file mode 100644 index 000000000..1022bbbb0 --- /dev/null +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerStatsEvents.kt @@ -0,0 +1,97 @@ +package org.prairieserver.prairie.common.player + +import androidx.media3.common.Player +import org.prairieserver.prairie.playback.stats.PlayerEventSeverity +import org.prairieserver.prairie.playback.stats.StatsRow +import org.prairieserver.prairie.playback.stats.audioChannelsLabel +import org.prairieserver.prairie.playback.stats.bufferHealthLabel +import org.prairieserver.prairie.playback.stats.formatStatsBitrate + +/** + * Prairie stats-for-nerds: turns [PlaybackAnalyticsListener] events into the + * short, timestamped lines the phone sheet and TV HUD list under "Recent + * events" (parity with prairie-smarttv #116). Chatty events (bandwidth + * estimates, track snapshots) are skipped so errors stay in the window. + */ +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +internal fun describePlayerEvent( + event: PlaybackAnalyticsListener.Event, +): Pair? = when (event) { + is PlaybackAnalyticsListener.Event.VideoDecoderInitialized -> + PlayerEventSeverity.INFO to buildString { + append("Video decoder ").append(event.decoderName) + event.initializationDurationMs?.let { append(" (").append(it).append(" ms)") } + } + is PlaybackAnalyticsListener.Event.AudioDecoderInitialized -> + PlayerEventSeverity.INFO to "Audio decoder ${event.decoderName}" + is PlaybackAnalyticsListener.Event.VideoFormatChanged -> { + val f = event.format + val size = if (f.width > 0 && f.height > 0) " ${f.width}x${f.height}" else "" + PlayerEventSeverity.INFO to "Video format ${f.codecs ?: f.sampleMimeType ?: "unknown"}$size" + } + is PlaybackAnalyticsListener.Event.AudioFormatChanged -> { + val f = event.format + val channels = audioChannelsLabel(f.channelCount.takeIf { it > 0 })?.let { " $it" } ?: "" + PlayerEventSeverity.INFO to "Audio format ${f.codecs ?: f.sampleMimeType ?: "unknown"}$channels" + } + is PlaybackAnalyticsListener.Event.DroppedFrames -> + PlayerEventSeverity.WARNING to "Dropped ${event.count} frames" + is PlaybackAnalyticsListener.Event.AudioUnderrun -> + PlayerEventSeverity.WARNING to "Audio underrun" + is PlaybackAnalyticsListener.Event.LoadError -> + PlayerEventSeverity.ERROR to "Load error: ${event.throwable.summary()}" + is PlaybackAnalyticsListener.Event.PlayerError -> + PlayerEventSeverity.ERROR to buildString { + append("Player error ").append(event.error.errorCodeName) + event.error.message?.takeIf { it.isNotBlank() }?.let { append(": ").append(it) } + event.error.cause?.let { append(" (").append(it.summary()).append(')') } + } + is PlaybackAnalyticsListener.Event.PlaybackStateChanged -> when (event.state) { + Player.STATE_BUFFERING -> PlayerEventSeverity.INFO to "Buffering" + Player.STATE_READY -> PlayerEventSeverity.INFO to "Ready" + Player.STATE_ENDED -> PlayerEventSeverity.INFO to "Ended" + else -> null + } + is PlaybackAnalyticsListener.Event.FirstFrameRendered -> PlayerEventSeverity.INFO to "First frame" + is PlaybackAnalyticsListener.Event.SeekStarted -> PlayerEventSeverity.INFO to "Seek" + // The format-changed line already names the codec; the decoder output + // format only settles HDR mode, which the Stats rows show. + is PlaybackAnalyticsListener.Event.VideoOutputFormatChanged, + is PlaybackAnalyticsListener.Event.BandwidthEstimate, + is PlaybackAnalyticsListener.Event.TrackSnapshot, + -> null +} + +/** + * Appends [event] to the snapshot's event log, stamped [nowEpochMs]. Call + * after [reducePlayerStats] with the wall clock; the reducer itself stays pure. + */ +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +fun PlayerStatsSnapshot.recordPlayerEvent( + event: PlaybackAnalyticsListener.Event, + nowEpochMs: Long, +): PlayerStatsSnapshot { + val (severity, message) = describePlayerEvent(event) ?: return this + return copy(events = events.append(nowEpochMs, severity, message)) +} + +/** + * Player-side health rows shared by phone and TV: media bitrate, buffer + * ahead, rebuffers and startup time. Complements each app's existing + * decoder/format rows. + */ +fun PlayerStatsSnapshot.healthRows(): List = buildList { + videoBitrateBps?.let { add(StatsRow("Video bitrate", formatStatsBitrate(it))) } + audioChannelsLabel(audioChannels)?.let { add(StatsRow("Audio channels", it)) } + bufferHealthLabel(bufferedDurationMs)?.let { add(StatsRow("Buffer ahead", it)) } + if (rebufferCount > 0) { + add(StatsRow("Rebuffers", "$rebufferCount (${rebufferTotalMs} ms total, max ${rebufferMaxMs} ms)")) + } + startupReadyMs?.let { add(StatsRow("Startup", "$it ms")) } +} + +private fun Throwable.summary(): String { + val name = this::class.java.simpleName.ifBlank { "Error" } + val text = message?.takeIf { it.isNotBlank() } + return if (text != null) "$name: $text" else name +} diff --git a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerStatsSnapshot.kt b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerStatsSnapshot.kt index 7157204ba..96df286c4 100644 --- a/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerStatsSnapshot.kt +++ b/android-shared/src/androidMain/kotlin/org/prairieserver/prairie/common/player/PlayerStatsSnapshot.kt @@ -4,6 +4,7 @@ import androidx.media3.common.C import androidx.media3.common.Format import androidx.media3.common.MimeTypes import androidx.media3.common.Player +import org.prairieserver.prairie.playback.stats.PlayerEventLog /** * Snapshot of player statistics surfaced in playback diagnostics UI. @@ -48,6 +49,14 @@ data class PlayerStatsSnapshot( val lastSeekDurationMs: Long? = null, val seekTotalMs: Long = 0, val seekMaxMs: Long = 0, + /** Declared media bitrate of the current video format (not network throughput). */ + val videoBitrateBps: Long? = null, + val audioChannels: Int? = null, + /** + * Prairie stats-for-nerds: the last few player events, recorded by + * [recordPlayerEvent] (wall-clock stamped, so kept out of the pure reducer). + */ + val events: PlayerEventLog = PlayerEventLog(), ) /** @@ -76,6 +85,7 @@ fun reducePlayerStats( current.resolution }, frameRate = if (event.format.frameRate > 0f) event.format.frameRate else current.frameRate, + videoBitrateBps = event.format.declaredBitrate() ?: current.videoBitrateBps, hdrMode = null, // Wait for the decoder output, including after format changes. colorTransfer = describeColorTransfer(event.format) ?: current.colorTransfer, colorRange = describeColorRange(event.format) ?: current.colorRange, @@ -84,7 +94,10 @@ fun reducePlayerStats( hdrMode = describeHdrMode(event.format, event.decoderMimeType), ) is PlaybackAnalyticsListener.Event.AudioFormatChanged -> - current.copy(audioCodec = event.format.codecs ?: event.format.sampleMimeType) + current.copy( + audioCodec = event.format.codecs ?: event.format.sampleMimeType, + audioChannels = event.format.channelCount.takeIf { it > 0 } ?: current.audioChannels, + ) is PlaybackAnalyticsListener.Event.DroppedFrames -> current.copy(droppedFrames = current.droppedFrames + event.count) is PlaybackAnalyticsListener.Event.AudioUnderrun -> @@ -154,7 +167,9 @@ data class FinishedPlayerStats( fun finishPlayerStats(current: PlayerStatsSnapshot, detailedCapture: Boolean): FinishedPlayerStats = FinishedPlayerStats( - next = PlayerStatsSnapshot(), + // Keep the event log: the error that ended a session is what the + // stats panel of the retry needs to show. + next = PlayerStatsSnapshot(events = current.events), finalSnapshot = current.takeIf { detailedCapture && it.hasPerformanceEvidence() }, ) @@ -170,6 +185,11 @@ fun PlayerStatsSnapshot.firstFrameDiagnostics(firstFrameMs: Long): Map 0 } ?: bitrate.takeIf { it > 0 } ?: peakBitrate.takeIf { it > 0 }) + ?.toLong() + private fun describeColorTransfer(format: Format): String? = when (format.colorInfo?.colorTransfer) { C.COLOR_TRANSFER_ST2084 -> "st2084" C.COLOR_TRANSFER_HLG -> "hlg" diff --git a/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlayerStatsEventsTest.kt b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlayerStatsEventsTest.kt new file mode 100644 index 000000000..61c2e2061 --- /dev/null +++ b/android-shared/src/androidUnitTest/kotlin/org/prairieserver/prairie/common/player/PlayerStatsEventsTest.kt @@ -0,0 +1,125 @@ +package org.prairieserver.prairie.common.player + +import androidx.annotation.OptIn +import androidx.media3.common.Format +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import org.prairieserver.prairie.playback.stats.PlayerEventSeverity +import java.io.IOException +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertSame + +@OptIn(UnstableApi::class) +class PlayerStatsEventsTest { + + @Test + fun `errors are recorded with the URL query stripped`() { + val snapshot = PlayerStatsSnapshot().recordPlayerEvent( + PlaybackAnalyticsListener.Event.LoadError( + IOException("403 for https://srv/api/v2/stream/a/master.m3u8?stream_token=secret"), + ), + nowEpochMs = 1_000L, + ) + val entry = snapshot.events.entries.single() + assertEquals(PlayerEventSeverity.ERROR, entry.severity) + assertEquals("Load error: IOException: 403 for https://srv/api/v2/stream/a/master.m3u8?…", entry.message) + assertEquals(1_000L, entry.atEpochMs) + } + + @Test + fun `player errors name the error code`() { + val error = PlaybackException("boom", null, PlaybackException.ERROR_CODE_DECODING_FAILED) + val (severity, message) = describePlayerEvent(PlaybackAnalyticsListener.Event.PlayerError(error))!! + assertEquals(PlayerEventSeverity.ERROR, severity) + assertEquals("Player error ERROR_CODE_DECODING_FAILED: boom", message) + } + + @Test + fun `state, format and decoder events read as short lines`() { + fun line(e: PlaybackAnalyticsListener.Event) = describePlayerEvent(e)?.second + assertEquals("Buffering", line(PlaybackAnalyticsListener.Event.PlaybackStateChanged(Player.STATE_BUFFERING, 0, 0, true))) + assertEquals("Ready", line(PlaybackAnalyticsListener.Event.PlaybackStateChanged(Player.STATE_READY, 0, 0, true))) + assertEquals("Ended", line(PlaybackAnalyticsListener.Event.PlaybackStateChanged(Player.STATE_ENDED, 0, 0, true))) + assertNull(line(PlaybackAnalyticsListener.Event.PlaybackStateChanged(Player.STATE_IDLE, 0, 0, true))) + assertEquals( + "Video format hvc1.2.4.L153 3840x1600", + line( + PlaybackAnalyticsListener.Event.VideoFormatChanged( + Format.Builder().setCodecs("hvc1.2.4.L153").setWidth(3840).setHeight(1600).build(), + ), + ), + ) + assertEquals( + "Audio format audio/eac3 5.1", + line( + PlaybackAnalyticsListener.Event.AudioFormatChanged( + Format.Builder().setSampleMimeType("audio/eac3").setChannelCount(6).build(), + ), + ), + ) + assertEquals( + "Video decoder c2.qti.hevc.decoder (42 ms)", + line(PlaybackAnalyticsListener.Event.VideoDecoderInitialized("c2.qti.hevc.decoder", 42)), + ) + assertEquals("Audio decoder c2.android.aac", line(PlaybackAnalyticsListener.Event.AudioDecoderInitialized("c2.android.aac"))) + assertEquals("Dropped 5 frames", line(PlaybackAnalyticsListener.Event.DroppedFrames(5, 1000))) + assertEquals("Audio underrun", line(PlaybackAnalyticsListener.Event.AudioUnderrun)) + assertEquals("First frame", line(PlaybackAnalyticsListener.Event.FirstFrameRendered(1))) + assertEquals("Seek", line(PlaybackAnalyticsListener.Event.SeekStarted(1))) + } + + @Test + fun `chatty events are not recorded`() { + val start = PlayerStatsSnapshot() + assertSame(start, start.recordPlayerEvent(PlaybackAnalyticsListener.Event.BandwidthEstimate(1), 1)) + assertSame(start, start.recordPlayerEvent(PlaybackAnalyticsListener.Event.TrackSnapshot("x"), 1)) + } + + @Test + fun `reducer tracks declared bitrate and channels`() { + val video = reducePlayerStats( + PlayerStatsSnapshot(), + PlaybackAnalyticsListener.Event.VideoFormatChanged(Format.Builder().setAverageBitrate(18_000_000).build()), + ) + assertEquals(18_000_000L, video.videoBitrateBps) + val audio = reducePlayerStats( + video, + PlaybackAnalyticsListener.Event.AudioFormatChanged(Format.Builder().setChannelCount(2).build()), + ) + assertEquals(2, audio.audioChannels) + assertEquals(18_000_000L, audio.videoBitrateBps) + } + + @Test + fun `health rows list bitrate, channels, buffer, rebuffers and startup`() { + val rows = PlayerStatsSnapshot( + videoBitrateBps = 18_000_000, + audioChannels = 6, + bufferedDurationMs = 12_340, + rebufferCount = 2, + rebufferTotalMs = 900, + rebufferMaxMs = 600, + startupReadyMs = 850, + ).healthRows() + assertEquals( + listOf( + "Video bitrate" to "18.0 Mbps", + "Audio channels" to "5.1", + "Buffer ahead" to "12.3 s", + "Rebuffers" to "2 (900 ms total, max 600 ms)", + "Startup" to "850 ms", + ), + rows.map { it.label to it.value }, + ) + assertEquals(emptyList(), PlayerStatsSnapshot().healthRows()) + } + + @Test + fun `finishing a session keeps the event log`() { + val withEvent = PlayerStatsSnapshot().recordPlayerEvent(PlaybackAnalyticsListener.Event.AudioUnderrun, 5) + assertEquals(withEvent.events, finishPlayerStats(withEvent, detailedCapture = false).next.events) + } +} diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt index f6a19df9c..a2fc4b041 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlaybackStatsSheet.kt @@ -16,17 +16,23 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import org.prairieserver.prairie.common.player.PlayerStatsSnapshot +import org.prairieserver.prairie.common.player.healthRows +import org.prairieserver.prairie.playback.stats.PlaybackStatsContext +import org.prairieserver.prairie.playback.stats.displayLine +import org.prairieserver.prairie.playback.stats.planRows @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -41,6 +47,8 @@ fun PlaybackStatsSheet( playMethod: String? = null, positionLabel: String? = null, tabletopPaneHeight: Dp? = null, + // Prairie: plan / audio track / stream path / quality, shared with the TV HUD. + playbackContext: PlaybackStatsContext = PlaybackStatsContext(), ) { if (!isVisible) return @@ -94,13 +102,21 @@ fun PlaybackStatsSheet( ) Spacer(modifier = Modifier.height(18.dp)) + val planRows = playbackContext.planRows() val rows = buildList { sessionId?.takeIf { it.isNotBlank() }?.let { add("Session" to it) } playMethod?.takeIf { it.isNotBlank() }?.let { add("Play method" to it) } positionLabel?.takeIf { it.isNotBlank() }?.let { add("Position" to it) } addAll(stats.mobileStatsRows()) + stats.healthRows().forEach { add(it.label to it.value) } } - if (rows.isEmpty()) { + if (planRows.isNotEmpty()) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + planRows.forEach { row -> StatsBlockRow(label = row.label, value = row.value) } + } + Spacer(modifier = Modifier.height(16.dp)) + } + if (rows.isEmpty() && planRows.isEmpty()) { Text( text = "Waiting for player data", color = Color.White.copy(alpha = 0.66f), @@ -113,11 +129,51 @@ fun PlaybackStatsSheet( } } } + if (!stats.events.isEmpty) { + Spacer(modifier = Modifier.height(18.dp)) + Text( + text = "Recent player events", + color = Color.White.copy(alpha = 0.62f), + fontSize = 13.sp, + ) + Spacer(modifier = Modifier.height(8.dp)) + val offsetMs = remember(stats.events) { + java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()).toLong() + } + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + stats.events.entries.forEach { event -> + Text( + text = event.displayLine(offsetMs), + color = Color.White.copy(alpha = 0.86f), + fontSize = 12.sp, + fontFamily = FontFamily.Monospace, + ) + } + } + } } } } } +/** Label over a wrapping value, for the long plan / stream / track lines. */ +@Composable +private fun StatsBlockRow(label: String, value: String) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + color = Color.White.copy(alpha = 0.62f), + fontSize = 13.sp, + ) + Text( + text = value, + color = Color.White, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + } +} + @Composable private fun StatsRow(label: String, value: String) { Row(modifier = Modifier.fillMaxWidth()) { diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt index cbfee5f08..0b93daca7 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerOverlay.kt @@ -666,6 +666,24 @@ fun PlayerOverlay( playMethod = state.playMethod?.name, positionLabel = formatClockTime(state.position) + " / " + formatClockTime(state.duration), tabletopPaneHeight = tabletopPaneHeight, + playbackContext = org.prairieserver.prairie.playback.stats.PlaybackStatsContext( + plan = state.playbackPlan, + playMethod = state.playMethod, + audioTrack = state.audioTracks.getOrNull(state.selectedAudioIndex)?.let { track -> + org.prairieserver.prairie.playback.stats.describeAudioTrackForStats( + ordinal = state.selectedAudioIndex, + language = track.language, + codec = track.codec, + channels = track.channels, + title = track.title, + ) + }, + streamUrl = state.streamUrl, + qualityPreference = state.qualityOptions + .firstOrNull { it.id.equals(state.selectedQualityId, ignoreCase = true) } + ?.label + ?: state.selectedQualityId, + ), ) // Chapters picker — opened from the HUD chapters button (HUD product diff --git a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt index 8b0bdb829..0ad31c068 100644 --- a/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt +++ b/androidApp/src/androidMain/kotlin/org/prairieserver/prairie/android/ui/screens/player/PlayerViewModel.kt @@ -33,6 +33,7 @@ import org.prairieserver.prairie.common.player.SleepTimerState import org.prairieserver.prairie.common.player.StartParams import org.prairieserver.prairie.common.player.backend.VideoBackendCapabilities import org.prairieserver.prairie.common.player.reducePlayerStats +import org.prairieserver.prairie.common.player.recordPlayerEvent import org.prairieserver.prairie.common.player.seek.PendingSeekPresentationGuard import org.prairieserver.prairie.common.player.seek.PlaybackSeekDecision import org.prairieserver.prairie.common.player.seek.QuickSkipAccumulator @@ -966,7 +967,13 @@ class PlayerViewModel( viewModelScope.launch { playbackAnalytics.events.collect { event -> - _uiState.update { it.copy(stats = reducePlayerStats(it.stats, event)) } + _uiState.update { + // Prairie: also keep a timestamped event log for stats for nerds. + it.copy( + stats = reducePlayerStats(it.stats, event) + .recordPlayerEvent(event, System.currentTimeMillis()), + ) + } } } } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt index 8aa5d7ffd..3aee54971 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerHud.kt @@ -85,6 +85,9 @@ import androidx.tv.material3.MaterialTheme import androidx.tv.material3.Text import kotlinx.coroutines.launch import org.prairieserver.prairie.common.player.PlayerStatsSnapshot +import org.prairieserver.prairie.common.player.healthRows +import org.prairieserver.prairie.playback.stats.displayLine +import org.prairieserver.prairie.playback.stats.planRows import org.prairieserver.prairie.domain.player.IntroSkipMode import org.prairieserver.prairie.tv.R import org.prairieserver.prairie.common.player.SleepTimerState @@ -183,6 +186,8 @@ internal fun TvPlayerHud( playbackPlan: PlaybackExecutionPlan? = null, sessionId: String? = null, playMethodLabel: String? = null, + // Prairie stats for nerds: the URL actually mounted (shown path-only). + streamUrl: String? = null, desiredAudioOrdinal: Int? = null, desiredAudioConfirmed: Boolean = false, videoFillMode: VideoFillMode, @@ -498,6 +503,12 @@ internal fun TvPlayerHud( playMethod = playMethodLabel ?: playbackPlan?.stream?.playMethod?.name, positionSec = positionSec, + playbackContext = tvPlaybackStatsContext( + plan = playbackPlan, + audioTracks = audioTracks, + streamUrl = streamUrl, + videoQualities = videoQualities, + ), ) } HudTab.Video -> HudVideoPane( @@ -943,7 +954,10 @@ private fun HudStatsPane( sessionId: String? = null, playMethod: String? = null, positionSec: Double? = null, + playbackContext: org.prairieserver.prairie.playback.stats.PlaybackStatsContext = + org.prairieserver.prairie.playback.stats.PlaybackStatsContext(), ) { + val planRows = playbackContext.planRows() val rows = buildList { sessionId?.takeIf { it.isNotBlank() }?.let { add("Session" to it) } playMethod?.takeIf { it.isNotBlank() }?.let { add("Play method" to it) } @@ -951,13 +965,17 @@ private fun HudStatsPane( add("Position" to formatHudClock(it)) } addAll(stats.hudRows()) + stats.healthRows().forEach { add(it.label to it.value) } } - if (rows.isEmpty()) { + if (rows.isEmpty() && planRows.isEmpty() && stats.events.isEmpty) { HudEmptyStatePane("Stats unavailable", modifier) return } + // Prairie: the long plan / stream / track lines wrap full-width above the grid. + planRows.forEach { row -> HudStatsBlockRow(label = row.label, value = row.value) } + // Two columns, filled top-to-bottom then across, so nine rows read as a // 5+4 grid instead of a single column stretched over the full card width // with each value 500dp from its label. @@ -975,6 +993,84 @@ private fun HudStatsPane( } } } + + if (!stats.events.isEmpty) { + val offsetMs = remember(stats.events) { + java.util.TimeZone.getDefault().getOffset(System.currentTimeMillis()).toLong() + } + Text( + text = "Recent player events", + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = HudBodyTextSize, + lineHeight = HudBodyLineHeight, + fontWeight = FontWeight.Medium, + ), + ) + stats.events.entries.forEach { event -> + Text( + text = event.displayLine(offsetMs), + color = Color.White.copy(alpha = 0.85f), + style = MaterialTheme.typography.bodyMedium.copy( + fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace, + ), + ) + } + } +} + +/** Label over a wrapping value — plan summaries and stream paths run long. */ +@Composable +private fun HudStatsBlockRow(label: String, value: String) { + Column(modifier = Modifier.fillMaxWidth()) { + Text( + text = label, + color = MaterialTheme.colorScheme.onSurface, + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = HudBodyTextSize, + lineHeight = HudBodyLineHeight, + fontWeight = FontWeight.Medium, + ), + ) + Text( + text = value, + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodyLarge.copy( + fontSize = HudBodyTextSize, + lineHeight = HudBodyLineHeight, + ), + ) + } +} + +/** + * Builds the shared stats context from the HUD's inputs: the selected audio + * entry and the selected quality row are what the viewer actually has. + */ +internal fun tvPlaybackStatsContext( + plan: PlaybackExecutionPlan?, + audioTracks: List, + streamUrl: String?, + videoQualities: List, +): org.prairieserver.prairie.playback.stats.PlaybackStatsContext { + val selectedAudio = audioTracks.firstOrNull { it.isSelected } + return org.prairieserver.prairie.playback.stats.PlaybackStatsContext( + plan = plan, + playMethod = plan?.stream?.playMethod, + audioTrack = selectedAudio?.let { track -> + org.prairieserver.prairie.playback.stats.describeAudioTrackForStats( + ordinal = audioTracks.indexOf(track), + language = track.language, + codec = track.codecOrMime, + channels = track.channelCount, + title = track.displayLabel, + ) + }, + streamUrl = streamUrl, + qualityPreference = videoQualities.firstOrNull { it.isSelected }?.let { quality -> + if (quality.id == VIDEO_QUALITY_AUTO_ID) "auto" else quality.label + }, + ) } private fun formatHudClock(seconds: Double): String { @@ -2085,7 +2181,8 @@ private fun HudActionRow( } } -private fun PlayerStatsSnapshot.hasHudRows(): Boolean = hudRows().isNotEmpty() +private fun PlayerStatsSnapshot.hasHudRows(): Boolean = + hudRows().isNotEmpty() || healthRows().isNotEmpty() || !events.isEmpty private fun PlayerStatsSnapshot.hudRows(): List> = buildList { backendDisplayName?.let { add("Backend" to it) } diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt index 61028216a..d3eb5ebff 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerScreen.kt @@ -2281,6 +2281,7 @@ fun TvPlayerScreen( playbackPlan = state.playbackPlan, sessionId = state.sessionId, playMethodLabel = state.playMethod?.name, + streamUrl = state.streamUrl, desiredAudioOrdinal = state.desiredAudioOrdinal, desiredAudioConfirmed = state.desiredAudioConfirmed, videoFillMode = state.videoFillMode, diff --git a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt index 4b4a2101b..6033587da 100644 --- a/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt +++ b/androidTvApp/src/androidMain/kotlin/org/prairieserver/prairie/tv/ui/screens/player/TvPlayerViewModel.kt @@ -43,6 +43,7 @@ import org.prairieserver.prairie.common.player.MountedSubtitleTrack import org.prairieserver.prairie.common.player.resolveMountedSubtitle import org.prairieserver.prairie.common.player.backend.VideoBackendCapabilities import org.prairieserver.prairie.common.player.reducePlayerStats +import org.prairieserver.prairie.common.player.recordPlayerEvent import org.prairieserver.prairie.common.player.seek.PendingSeekPresentationGuard import org.prairieserver.prairie.common.player.seek.PlaybackSeekDecision import org.prairieserver.prairie.common.player.seek.QuickSkipAccumulator @@ -1569,7 +1570,13 @@ class TvPlayerViewModel( // PrairiePlaybackService; we just subscribe — no extra registration. viewModelScope.launch { playbackAnalytics.events.collect { event -> - _uiState.update { it.copy(stats = reducePlayerStats(it.stats, event)) } + _uiState.update { + // Prairie: also keep a timestamped event log for stats for nerds. + it.copy( + stats = reducePlayerStats(it.stats, event) + .recordPlayerEvent(event, System.currentTimeMillis()), + ) + } } } diff --git a/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/stats/StatsForNerds.kt b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/stats/StatsForNerds.kt new file mode 100644 index 000000000..c68d8f5d2 --- /dev/null +++ b/shared/src/commonMain/kotlin/org/prairieserver/prairie/playback/stats/StatsForNerds.kt @@ -0,0 +1,321 @@ +package org.prairieserver.prairie.playback.stats + +import org.prairieserver.prairie.model.playback.PlayMethod +import org.prairieserver.prairie.model.playback.PlaybackDelivery +import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan + +/** + * Prairie "stats for nerds" building blocks shared by the phone sheet and the + * TV HUD, at parity with prairie-smarttv (PR #116): the server's plan in one + * line, the audio track actually playing, the stream path handed to the + * player (never its query string or tokens), the quality preference, and the + * last few player events. + * + * Everything here is pure so the formatting is unit-tested once for both apps. + */ + +/** One label/value row in a stats panel. */ +data class StatsRow(val label: String, val value: String) + +// --------------------------------------------------------------------------- +// Player event ring buffer +// --------------------------------------------------------------------------- + +enum class PlayerEventSeverity { INFO, WARNING, ERROR } + +data class PlayerEvent( + val atEpochMs: Long, + val severity: PlayerEventSeverity, + val message: String, + /** How many identical consecutive events this entry stands for. */ + val repeat: Int = 1, +) + +/** + * Immutable ring buffer of the most recent player events, oldest first. + * + * Consecutive identical messages collapse into one entry with a repeat count + * (and the newest timestamp), so a burst of dropped-frame or buffering events + * cannot push the one error that matters out of the window. + */ +data class PlayerEventLog( + val capacity: Int = DEFAULT_CAPACITY, + val entries: List = emptyList(), +) { + fun append(event: PlayerEvent): PlayerEventLog { + val size = capacity.coerceAtLeast(1) + val last = entries.lastOrNull() + val next = if (last != null && last.message == event.message && last.severity == event.severity) { + entries.dropLast(1) + event.copy(repeat = last.repeat + event.repeat) + } else { + entries + event + } + return copy(entries = if (next.size > size) next.takeLast(size) else next) + } + + fun append( + atEpochMs: Long, + severity: PlayerEventSeverity, + message: String, + ): PlayerEventLog = append(PlayerEvent(atEpochMs, severity, redactUrlSecrets(message))) + + val isEmpty: Boolean get() = entries.isEmpty() + + companion object { + /** Same window as prairie-smarttv's overlay. */ + const val DEFAULT_CAPACITY = 8 + } +} + +/** + * `HH:mm:ss` wall-clock time for [epochMs], shifted by [utcOffsetMs] (the + * platform supplies its zone offset; pure so tests pin it). + */ +fun formatEventClock(epochMs: Long, utcOffsetMs: Long = 0L): String { + val dayMs = 24L * 60 * 60 * 1000 + val local = ((epochMs + utcOffsetMs) % dayMs + dayMs) % dayMs + val totalSeconds = local / 1000 + val h = totalSeconds / 3600 + val m = (totalSeconds % 3600) / 60 + val s = totalSeconds % 60 + return "${h.pad2()}:${m.pad2()}:${s.pad2()}" +} + +/** `"14:03:07 ERROR Load error ×3"`; INFO carries no severity tag. */ +fun PlayerEvent.displayLine(utcOffsetMs: Long = 0L): String = buildString { + append(formatEventClock(atEpochMs, utcOffsetMs)) + when (severity) { + PlayerEventSeverity.INFO -> Unit + PlayerEventSeverity.WARNING -> append(" WARN") + PlayerEventSeverity.ERROR -> append(" ERROR") + } + append(' ') + append(message) + if (repeat > 1) append(" ×").append(repeat) +} + +private fun Long.pad2(): String = toString().padStart(2, '0') + +// --------------------------------------------------------------------------- +// URL redaction +// --------------------------------------------------------------------------- + +private val urlWithQuery = Regex("""([a-zA-Z][a-zA-Z0-9+.-]*://[^\s?#"']*)[?#][^\s"']*""") +private val userInfo = Regex("""://[^/@\s]+@""") + +/** + * Strips query strings, fragments and userinfo from every URL in free text + * (player error messages quote the failing URL, and Prairie stream URLs carry + * the stream token in the query). + */ +fun redactUrlSecrets(text: String): String = + text.replace(urlWithQuery) { it.groupValues[1] + "?…" } + .replace(userInfo, "://") + +/** What is actually being played, safe to put on screen. */ +data class StreamUrlDescription( + /** "HLS", "DASH", "Progressive" or "Local file". */ + val kind: String, + /** Path only: no scheme, host, userinfo, query or fragment; token-like segments elided. */ + val path: String, +) { + fun displayValue(): String = "$kind · $path" +} + +/** + * Describes [url] for the stats panel. [streamType] is the plan's + * `stream_type` hint (e.g. "hls") and wins over the extension when present. + */ +fun describeStreamUrl(url: String?, streamType: String? = null): StreamUrlDescription? { + val raw = url?.trim().orEmpty() + if (raw.isEmpty()) return null + val schemeEnd = raw.indexOf("://") + val scheme = if (schemeEnd > 0) raw.substring(0, schemeEnd).lowercase() else "" + val afterScheme = if (schemeEnd > 0) raw.substring(schemeEnd + 3) else raw + val withoutQuery = afterScheme.substringBefore('?').substringBefore('#') + val isLocal = scheme == "file" || scheme == "content" || (schemeEnd < 0 && raw.startsWith("/")) + val path = when { + schemeEnd > 0 && !isLocal -> "/" + withoutQuery.substringAfter('/', "") + else -> withoutQuery + } + val safePath = path.split('/') + .joinToString("/") { segment -> if (looksLikeSecret(segment)) "…" else segment } + .ifEmpty { "/" } + val lowerPath = path.lowercase() + val hint = streamType?.trim()?.lowercase().orEmpty() + val kind = when { + isLocal -> "Local file" + hint.contains("hls") || lowerPath.endsWith(".m3u8") -> "HLS" + hint.contains("dash") || lowerPath.endsWith(".mpd") -> "DASH" + else -> "Progressive" + } + return StreamUrlDescription(kind = kind, path = if (isLocal) safePath.substringAfterLast('/') else safePath) +} + +/** + * A path segment that is probably a credential: a JWT (three base64url + * parts) or a long opaque run with no file extension. Content ids (UUIDs, + * short hashes) stay visible because they are what a bug report needs. + */ +private fun looksLikeSecret(segment: String): Boolean { + if (segment.length < 40) return false + val jwtParts = segment.split('.') + if (jwtParts.size == 3 && jwtParts.all { it.length >= 8 }) return true + val stem = segment.substringBeforeLast('.', segment) + return stem.length >= 40 && stem.all { it.isLetterOrDigit() || it == '-' || it == '_' || it == '=' } +} + +// --------------------------------------------------------------------------- +// Plan / track / quality summaries +// --------------------------------------------------------------------------- + +/** Human label for how the server is delivering this session. */ +fun playMethodLabel(method: PlayMethod?, delivery: PlaybackDelivery?): String? = when (method) { + PlayMethod.DIRECT -> "Direct play" + PlayMethod.REMUX -> "Remux" + PlayMethod.TRANSCODE -> "Transcode" + null -> when (delivery) { + PlaybackDelivery.ORIGINAL_HTTP -> "Direct play" + PlaybackDelivery.SERVER_REMUX_HLS, PlaybackDelivery.SERVER_REMUX_PROGRESSIVE -> "Remux" + PlaybackDelivery.SERVER_TRANSCODE_HLS -> "Transcode" + PlaybackDelivery.CLIENT_LOCAL_NORMALIZATION -> "Local normalization" + null -> null + } +} + +/** Wire name of the delivery, matching the server/smarttv vocabulary. */ +fun PlaybackDelivery.wireName(): String = when (this) { + PlaybackDelivery.ORIGINAL_HTTP -> "original_http" + PlaybackDelivery.SERVER_REMUX_HLS -> "server_remux_hls" + PlaybackDelivery.SERVER_REMUX_PROGRESSIVE -> "server_remux_progressive" + PlaybackDelivery.SERVER_TRANSCODE_HLS -> "server_transcode_hls" + PlaybackDelivery.CLIENT_LOCAL_NORMALIZATION -> "client_local_normalization" +} + +/** + * One-line plan summary in smarttv's shape, e.g. + * `Remux · server_remux_progressive · container=mp4 · video=hevc 3840x1600 hdr10 · audio=aac`. + */ +fun PlaybackExecutionPlan.statsSummary(): String { + val video = listOfNotNull( + source.videoCodec.clean(), + source.resolution.clean(), + source.hdrFormat.clean(), + ).joinToString(" ") + val audio = (claims.audio.codec.clean() ?: source.audioCodec.clean()) + ?.let { codec -> if (claims.audio.passthrough) "$codec passthrough" else codec } + return listOfNotNull( + playMethodLabel(stream.playMethod, delivery), + delivery.wireName(), + source.container.clean()?.let { "container=$it" }, + video.ifEmpty { null }?.let { "video=$it" }, + audio?.let { "audio=$it" }, + ).joinToString(" · ") +} + +/** + * Why the planner chose this route: its decision trace when it sent one, + * else degradation warnings, else the audio claim's reason. + */ +fun PlaybackExecutionPlan.statsReason(): String? { + val trace = decisionTrace.mapNotNull { it.clean() } + if (trace.isNotEmpty()) return trace.takeLast(2).joinToString(" → ") + val warnings = degradationWarnings.mapNotNull { it.message.clean() ?: it.code.clean() } + if (warnings.isNotEmpty()) return warnings.joinToString("; ") + return claims.audio.reason.clean() ?: claims.video.dolbyVisionReason.clean() +} + +/** Server-side transformations applied to this plan, e.g. "audio_transcode, subtitle_burn". */ +fun PlaybackExecutionPlan.statsTransformations(): String? = + transformations.mapNotNull { it.name.clean() }.distinct().joinToString(", ").ifEmpty { null } + +/** `"Mono"`, `"Stereo"`, `"5.1"`, `"7.1"`, else `"ch"`. */ +fun audioChannelsLabel(channels: Int?): String? = when { + channels == null || channels <= 0 -> null + channels == 1 -> "Mono" + channels == 2 -> "Stereo" + channels == 6 -> "5.1" + channels == 8 -> "7.1" + else -> "${channels}ch" +} + +/** + * `"#2 · eng · EAC3 5.1 · Commentary"` — index (1-based ordinal), language, + * codec + channels, then the title when it adds something. + */ +fun describeAudioTrackForStats( + ordinal: Int?, + language: String?, + codec: String?, + channels: Int?, + title: String? = null, +): String? { + val codecPart = listOfNotNull(codec.clean()?.uppercase(), audioChannelsLabel(channels)) + .joinToString(" ").ifEmpty { null } + val lang = language.clean() + val extra = title.clean()?.takeIf { t -> + !t.equals(lang, ignoreCase = true) && (codecPart == null || !t.equals(codecPart, ignoreCase = true)) + } + val parts = listOfNotNull( + ordinal?.takeIf { it >= 0 }?.let { "#${it + 1}" }, + lang, + codecPart, + extra, + ) + return if (parts.isEmpty()) null else parts.joinToString(" · ") +} + +/** `"Auto"`, `"Original"`, else the ladder id/label as given. */ +fun qualityPreferenceLabel(preference: String?): String? { + val value = preference.clean() ?: return null + return when (value.lowercase()) { + "auto", "-1" -> "Auto" + "original" -> "Original" + else -> value + } +} + +/** `"12.3 s"` of media buffered ahead of the playhead. */ +fun bufferHealthLabel(bufferedAheadMs: Long?): String? { + val ms = bufferedAheadMs?.takeIf { it >= 0 } ?: return null + val tenths = (ms + 50) / 100 + return "${tenths / 10}.${tenths % 10} s" +} + +/** `"19.4 Mbps"` / `"640 Kbps"` / `"900 bps"`. */ +fun formatStatsBitrate(bps: Long): String = when { + bps >= 1_000_000 -> { + val tenths = (bps + 50_000) / 100_000 + "${tenths / 10}.${tenths % 10} Mbps" + } + bps >= 1_000 -> "${(bps + 500) / 1_000} Kbps" + else -> "$bps bps" +} + +/** What the playback layer knows about the session, beyond the player's own stats. */ +data class PlaybackStatsContext( + val plan: PlaybackExecutionPlan? = null, + val playMethod: PlayMethod? = null, + val audioTrack: String? = null, + val streamUrl: String? = null, + val qualityPreference: String? = null, +) + +/** + * The plan-level rows, in display order: Plan, Planner reason, + * Transformations, Audio track, Stream, Quality. Rows with no data are + * omitted so an offline/local session shows only what applies. + */ +fun PlaybackStatsContext.planRows(): List = buildList { + plan?.let { p -> + add(StatsRow("Plan", p.statsSummary())) + p.statsReason()?.let { add(StatsRow("Planner reason", it)) } + p.statsTransformations()?.let { add(StatsRow("Transformations", it)) } + } ?: playMethodLabel(playMethod, null)?.let { add(StatsRow("Plan", it)) } + audioTrack.clean()?.let { add(StatsRow("Audio track", it)) } + describeStreamUrl(streamUrl ?: plan?.stream?.url, plan?.stream?.streamType) + ?.let { add(StatsRow("Stream", it.displayValue())) } + qualityPreferenceLabel(qualityPreference)?.let { add(StatsRow("Quality", it)) } +} + +private fun String?.clean(): String? = this?.trim()?.takeIf { it.isNotEmpty() } diff --git a/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/stats/StatsForNerdsTest.kt b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/stats/StatsForNerdsTest.kt new file mode 100644 index 000000000..5dc2b2e3d --- /dev/null +++ b/shared/src/commonTest/kotlin/org/prairieserver/prairie/playback/stats/StatsForNerdsTest.kt @@ -0,0 +1,329 @@ +package org.prairieserver.prairie.playback.stats + +import org.prairieserver.prairie.model.playback.AudioValidationClaims +import org.prairieserver.prairie.model.playback.PlayMethod +import org.prairieserver.prairie.model.playback.PlaybackDegradationWarning +import org.prairieserver.prairie.model.playback.PlaybackDelivery +import org.prairieserver.prairie.model.playback.PlaybackExecutionPlan +import org.prairieserver.prairie.model.playback.PlaybackRouteFamily +import org.prairieserver.prairie.model.playback.PlaybackSourceMetadata +import org.prairieserver.prairie.model.playback.PlaybackStreamRequest +import org.prairieserver.prairie.model.playback.PlaybackTransformationV3 +import org.prairieserver.prairie.model.playback.PlaybackValidationClaims +import org.prairieserver.prairie.model.playback.VideoValidationClaims +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StatsForNerdsTest { + + // --- Ring buffer ------------------------------------------------------ + + @Test + fun `event log keeps only the newest entries, oldest first`() { + var log = PlayerEventLog(capacity = 3) + for (i in 1..5) log = log.append(i * 1000L, PlayerEventSeverity.INFO, "event $i") + assertEquals(listOf("event 3", "event 4", "event 5"), log.entries.map { it.message }) + assertFalse(log.isEmpty) + } + + @Test + fun `default capacity matches the smarttv window`() { + var log = PlayerEventLog() + assertTrue(log.isEmpty) + for (i in 1..20) log = log.append(i.toLong(), PlayerEventSeverity.INFO, "e$i") + assertEquals(PlayerEventLog.DEFAULT_CAPACITY, log.entries.size) + assertEquals("e13", log.entries.first().message) + } + + @Test + fun `identical consecutive events collapse with a repeat count`() { + val log = PlayerEventLog() + .append(1_000L, PlayerEventSeverity.WARNING, "Dropped 3 frames") + .append(2_000L, PlayerEventSeverity.WARNING, "Dropped 3 frames") + .append(3_000L, PlayerEventSeverity.ERROR, "Player error") + .append(4_000L, PlayerEventSeverity.WARNING, "Dropped 3 frames") + assertEquals(3, log.entries.size) + assertEquals(2, log.entries[0].repeat) + assertEquals(2_000L, log.entries[0].atEpochMs) + assertEquals(1, log.entries[2].repeat) + } + + @Test + fun `same message at a different severity does not collapse`() { + val log = PlayerEventLog() + .append(1L, PlayerEventSeverity.INFO, "x") + .append(2L, PlayerEventSeverity.ERROR, "x") + assertEquals(2, log.entries.size) + } + + @Test + fun `non-positive capacity still keeps the latest event`() { + val log = PlayerEventLog(capacity = 0) + .append(1L, PlayerEventSeverity.INFO, "a") + .append(2L, PlayerEventSeverity.INFO, "b") + assertEquals(listOf("b"), log.entries.map { it.message }) + } + + @Test + fun `appended messages have URL secrets removed`() { + val log = PlayerEventLog().append( + 1L, + PlayerEventSeverity.ERROR, + "Load error: 403 https://srv.lan:8096/api/v2/stream/a/master.m3u8?token=abc.def#t", + ) + assertEquals("Load error: 403 https://srv.lan:8096/api/v2/stream/a/master.m3u8?…", log.entries.single().message) + } + + @Test + fun `event lines carry a wall clock and severity tag`() { + // 2026-01-01T13:02:03.456Z + val t = 1_767_272_523_456L + assertEquals("13:02:03", formatEventClock(t)) + assertEquals("08:02:03", formatEventClock(t, utcOffsetMs = -5L * 3_600_000)) + assertEquals("23:30:00", formatEventClock(-30L * 60_000)) + assertEquals("13:02:03 Ready", PlayerEvent(t, PlayerEventSeverity.INFO, "Ready").displayLine()) + assertEquals( + "13:02:03 WARN Buffering ×4", + PlayerEvent(t, PlayerEventSeverity.WARNING, "Buffering", repeat = 4).displayLine(), + ) + assertEquals("13:02:03 ERROR Boom", PlayerEvent(t, PlayerEventSeverity.ERROR, "Boom").displayLine()) + } + + // --- Redaction / stream path ------------------------------------------ + + @Test + fun `redaction strips queries fragments and userinfo from every URL`() { + assertEquals( + "GET https://srv/a.m3u8?… then http://srv/b.ts?…", + redactUrlSecrets("GET https://user:pw@srv/a.m3u8?t=1 then http://srv/b.ts#frag"), + ) + assertEquals("no url here", redactUrlSecrets("no url here")) + } + + @Test + fun `stream description shows kind and path only`() { + val hls = describeStreamUrl("https://srv.lan:8096/api/v2/stream/abc/master.m3u8?stream_token=secret") + assertEquals(StreamUrlDescription("HLS", "/api/v2/stream/abc/master.m3u8"), hls) + assertEquals("HLS · /api/v2/stream/abc/master.m3u8", hls?.displayValue()) + assertEquals("DASH", describeStreamUrl("https://srv/x/manifest.mpd")?.kind) + assertEquals("Progressive", describeStreamUrl("https://srv/api/v2/stream/abc?token=t")?.kind) + assertEquals("/", describeStreamUrl("https://srv")?.path) + } + + @Test + fun `stream type hint wins over the extension`() { + assertEquals("HLS", describeStreamUrl("https://srv/api/v2/stream/abc", streamType = "HLS")?.kind) + assertEquals("DASH", describeStreamUrl("https://srv/api/v2/stream/abc", streamType = "dash")?.kind) + } + + @Test + fun `local files show only the file name`() { + assertEquals( + StreamUrlDescription("Local file", "movie.mkv"), + describeStreamUrl("file:///data/user/0/org.prairieserver.prairie/files/dl/movie.mkv"), + ) + assertEquals("Local file", describeStreamUrl("/storage/emulated/0/x.mp4")?.kind) + assertEquals("Local file", describeStreamUrl("content://media/external/video/42")?.kind) + } + + @Test + fun `token-like path segments are elided but ids stay`() { + val jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U" + val opaque = "a".repeat(48) + val d = describeStreamUrl("https://srv/s/$jwt/$opaque/9f1c2d3e-aaaa-bbbb-cccc-1234567890ab/index.m3u8") + assertEquals("/s/…/…/9f1c2d3e-aaaa-bbbb-cccc-1234567890ab/index.m3u8", d?.path) + } + + @Test + fun `blank stream url has no description`() { + assertNull(describeStreamUrl(null)) + assertNull(describeStreamUrl(" ")) + assertEquals("api/x.m3u8", describeStreamUrl("api/x.m3u8")?.path) + } + + // --- Plan summary ----------------------------------------------------- + + private fun plan( + delivery: PlaybackDelivery = PlaybackDelivery.SERVER_REMUX_PROGRESSIVE, + method: PlayMethod? = PlayMethod.REMUX, + trace: List = emptyList(), + warnings: List = emptyList(), + audioClaims: AudioValidationClaims = AudioValidationClaims(), + videoClaims: VideoValidationClaims = VideoValidationClaims(), + ) = PlaybackExecutionPlan( + planId = "p1", + delivery = delivery, + routeFamily = PlaybackRouteFamily.SERVER_ADAPTIVE, + stream = PlaybackStreamRequest( + url = "https://srv/api/v2/stream/abc/master.m3u8?token=x", + streamType = "hls", + playMethod = method, + ), + source = PlaybackSourceMetadata( + container = "mp4", + videoCodec = "hevc", + audioCodec = "eac3", + resolution = "3840x1600", + hdrFormat = "hdr10", + ), + claims = PlaybackValidationClaims(video = videoClaims, audio = audioClaims), + transformations = listOf( + PlaybackTransformationV3(name = "audio_transcode"), + PlaybackTransformationV3(name = "audio_transcode"), + PlaybackTransformationV3(name = " "), + ), + degradationWarnings = warnings, + decisionTrace = trace, + ) + + @Test + fun `plan summary names method, delivery, container and codecs`() { + assertEquals( + "Remux · server_remux_progressive · container=mp4 · video=hevc 3840x1600 hdr10 · audio=eac3", + plan().statsSummary(), + ) + assertEquals( + "Transcode · server_transcode_hls · container=mp4 · video=hevc 3840x1600 hdr10 · audio=aac passthrough", + plan( + delivery = PlaybackDelivery.SERVER_TRANSCODE_HLS, + method = null, + audioClaims = AudioValidationClaims(codec = "aac", passthrough = true), + ).statsSummary(), + ) + } + + @Test + fun `planner reason prefers the trace, then warnings, then claims`() { + assertEquals( + "video_ok → audio_adaptation", + plan(trace = listOf("probe", "video_ok", "audio_adaptation", " ")).statsReason(), + ) + assertEquals( + "Audio downmixed; hdr_tonemap", + plan( + warnings = listOf( + PlaybackDegradationWarning("downmix", "Audio downmixed"), + PlaybackDegradationWarning("hdr_tonemap", ""), + ), + ).statsReason(), + ) + assertEquals( + "eac3 unsupported", + plan(audioClaims = AudioValidationClaims(reason = "eac3 unsupported")).statsReason(), + ) + assertEquals( + "profile 7", + plan(videoClaims = VideoValidationClaims(dolbyVisionReason = "profile 7")).statsReason(), + ) + assertNull(plan().statsReason()) + } + + @Test + fun `transformations are distinct and non-blank`() { + assertEquals("audio_transcode", plan().statsTransformations()) + assertNull(plan().copy(transformations = emptyList()).statsTransformations()) + } + + @Test + fun `play method labels cover every method and delivery`() { + assertEquals("Direct play", playMethodLabel(PlayMethod.DIRECT, null)) + assertEquals("Remux", playMethodLabel(PlayMethod.REMUX, null)) + assertEquals("Transcode", playMethodLabel(PlayMethod.TRANSCODE, null)) + assertEquals("Direct play", playMethodLabel(null, PlaybackDelivery.ORIGINAL_HTTP)) + assertEquals("Remux", playMethodLabel(null, PlaybackDelivery.SERVER_REMUX_HLS)) + assertEquals("Remux", playMethodLabel(null, PlaybackDelivery.SERVER_REMUX_PROGRESSIVE)) + assertEquals("Transcode", playMethodLabel(null, PlaybackDelivery.SERVER_TRANSCODE_HLS)) + assertEquals("Local normalization", playMethodLabel(null, PlaybackDelivery.CLIENT_LOCAL_NORMALIZATION)) + assertNull(playMethodLabel(null, null)) + assertEquals( + listOf( + "original_http", + "server_remux_hls", + "server_remux_progressive", + "server_transcode_hls", + "client_local_normalization", + ), + PlaybackDelivery.entries.map { it.wireName() }, + ) + } + + // --- Track / quality / buffer ------------------------------------------- + + @Test + fun `audio track label combines ordinal, language, codec, channels and title`() { + assertEquals( + "#2 · eng · EAC3 5.1 · Commentary", + describeAudioTrackForStats(1, "eng", "eac3", 6, "Commentary"), + ) + // Title that just repeats the language or codec is dropped. + assertEquals("#1 · eng · AAC Stereo", describeAudioTrackForStats(0, "eng", "aac", 2, "ENG")) + assertEquals("#1 · AAC Stereo", describeAudioTrackForStats(0, null, "aac", 2, "aac stereo")) + assertEquals("fre · Mono", describeAudioTrackForStats(null, "fre", null, 1)) + assertNull(describeAudioTrackForStats(-1, " ", null, 0)) + } + + @Test + fun `channel labels`() { + assertNull(audioChannelsLabel(null)) + assertNull(audioChannelsLabel(0)) + assertEquals("Mono", audioChannelsLabel(1)) + assertEquals("Stereo", audioChannelsLabel(2)) + assertEquals("5.1", audioChannelsLabel(6)) + assertEquals("7.1", audioChannelsLabel(8)) + assertEquals("3ch", audioChannelsLabel(3)) + } + + @Test + fun `quality preference labels`() { + assertEquals("Auto", qualityPreferenceLabel("auto")) + assertEquals("Auto", qualityPreferenceLabel("-1")) + assertEquals("Original", qualityPreferenceLabel("ORIGINAL")) + assertEquals("1080p", qualityPreferenceLabel(" 1080p ")) + assertNull(qualityPreferenceLabel("")) + assertNull(qualityPreferenceLabel(null)) + } + + @Test + fun `buffer and bitrate formatting`() { + assertEquals("12.3 s", bufferHealthLabel(12_340)) + assertEquals("0.0 s", bufferHealthLabel(0)) + assertNull(bufferHealthLabel(-1)) + assertNull(bufferHealthLabel(null)) + assertEquals("19.4 Mbps", formatStatsBitrate(19_400_000)) + assertEquals("640 Kbps", formatStatsBitrate(640_000)) + assertEquals("900 bps", formatStatsBitrate(900)) + } + + // --- Rows --------------------------------------------------------------- + + @Test + fun `plan rows list what applies in display order`() { + val rows = PlaybackStatsContext( + plan = plan(trace = listOf("audio_adaptation")), + audioTrack = "#1 · eng · AAC Stereo", + qualityPreference = "auto", + ).planRows() + assertEquals( + listOf("Plan", "Planner reason", "Transformations", "Audio track", "Stream", "Quality"), + rows.map { it.label }, + ) + // The plan's stream URL is used when the player URL is unknown, token stripped. + assertEquals("HLS · /api/v2/stream/abc/master.m3u8", rows.first { it.label == "Stream" }.value) + } + + @Test + fun `plan rows prefer the mounted stream url and fall back to the play method`() { + val rows = PlaybackStatsContext( + playMethod = PlayMethod.DIRECT, + streamUrl = "https://srv/api/v2/files/7/stream?token=t", + ).planRows() + assertEquals( + listOf(StatsRow("Plan", "Direct play"), StatsRow("Stream", "Progressive · /api/v2/files/7/stream")), + rows, + ) + assertTrue(PlaybackStatsContext().planRows().isEmpty()) + } +}