From 018c00ffe5cbeff9df7529c5e08605a5f6a09908 Mon Sep 17 00:00:00 2001 From: davotoula Date: Wed, 15 Jul 2026 15:56:12 +0100 Subject: [PATCH] fix(playback): keep live HLS playing; cache only proven on-demand HLS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A live HLS stream shared as a plain kind:1 note (a FAST/IPTV channel `.m3u8`) played its first ~60 s window and then broke or looped. Root cause: the isLiveStream flag is derived from the Nostr event kind — only kind:30311 live activities set it — so a live `.m3u8` in a note arrived flagged non-live and was routed to the caching data source. Caching a live playlist makes ExoPlayer reload a stale, non-advancing manifest and throw PlaylistStuckException, after which playback loops replaying the frozen window. Caching: learn liveness from ExoPlayer instead of the URL or event kind. HlsLivenessRecorder records the playlist's live/on-demand verdict into HlsLivenessCache once the media playlist resolves; CustomMediaSourceFactory routes the next play by it (shouldBypassCache, pure/tested). A live stream is never cached (the unclassified first play bypasses too), while immutable multi-rendition NIP-71 VOD is cached from its second view. The verdict is asymmetric on purpose — a wrong "live" only forgoes caching, a wrong "on-demand" breaks playback — so live is recorded eagerly while on-demand is recorded only from a resolved static window at STATE_READY. That keeps a live stream's early/placeholder timeline, and a geo-blocked stream that serves a VOD-shaped placeholder and then 403/404s before it plays, from being mislearned as cacheable. Error recovery: recover only ERROR_CODE_BEHIND_LIVE_WINDOW (seek to live edge + re-prepare) for genuine live-edge drift. An earlier broad "recover any live I/O error" thrashed on a stream whose segments fail to parse — it re-prepared, briefly reached READY, hit the same bad segment, and reset its cap on READY, so it looped forever. I/O and decode errors are now terminal (RenderPlaybackError's overlay), and the recovery budget refills only after real forward progress past the error. --- .../amethyst/service/playback/PlaybackDiag.kt | 33 +++++ .../composable/WatchPlaybackErrors.kt | 124 +++++++++++++++++- .../playback/diskCache/HlsLivenessCache.kt | 61 +++++++++ .../playerPool/CustomMediaSourceFactory.kt | 76 ++++++++--- .../playback/playerPool/ExoPlayerBuilder.kt | 1 + .../playerPool/HlsLivenessRecorder.kt | 110 ++++++++++++++++ .../WatchPlaybackErrorsRecoveryTest.kt | 60 +++++++++ .../diskCache/HlsLivenessCacheTest.kt | 66 ++++++++++ .../CustomMediaSourceFactoryRoutingTest.kt | 53 ++++++++ .../playerPool/HlsLivenessRecorderTest.kt | 86 ++++++++++++ 10 files changed, 651 insertions(+), 19 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackDiag.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/HlsLivenessCache.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/HlsLivenessRecorder.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrorsRecoveryTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/diskCache/HlsLivenessCacheTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactoryRoutingTest.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/HlsLivenessRecorderTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackDiag.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackDiag.kt new file mode 100644 index 0000000000..c6cd9c9085 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/PlaybackDiag.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback + +/** + * Shared logcat tag for the playback diagnostic trace (source routing, player lifecycle, error + * recovery, HLS liveness learning). Emitted with `Log.d`, so it appears only in a debug build + * (`Log.minLevel = DEBUG`) and is silent in benchmark/release (`ERROR`). To capture a playback + * investigation, install a debug build and run: + * + * ``` + * adb logcat -s PlaybackDiag + * ``` + */ +const val PLAYBACK_DIAG_TAG = "PlaybackDiag" diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt index 8141b4a80f..89bbc87a94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrors.kt @@ -29,7 +29,10 @@ import androidx.compose.runtime.MutableState import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player +import androidx.media3.common.Tracks import androidx.media3.common.util.UnstableApi +import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG +import com.vitorpamplona.amethyst.service.playback.composable.controls.getVideoTrackGroup import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.delay @@ -38,6 +41,30 @@ import kotlinx.coroutines.delay // be told apart from a genuinely undecodable stream in a field logcat. See WatchPlaybackErrors. internal const val ERROR_LOG_TAG = "PlaybackError" +private fun playbackStateName(state: Int): String = + when (state) { + Player.STATE_IDLE -> "IDLE" + Player.STATE_BUFFERING -> "BUFFERING" + Player.STATE_READY -> "READY" + Player.STATE_ENDED -> "ENDED" + else -> "UNKNOWN($state)" + } + +// Summarizes the currently-selected video track (codec + resolution + mime). A change in this line +// across an #EXT-X-DISCONTINUITY is the signature of an ad-splice codec reconfig — a second, distinct +// failure mode from BEHIND_LIVE_WINDOW. +@OptIn(UnstableApi::class) +private fun Tracks.selectedVideoSummary(): String { + val group = getVideoTrackGroup(this) ?: return "none" + for (i in 0 until group.length) { + if (group.isTrackSelected(i)) { + val f = group.getTrackFormat(i) + return "codec=${f.codecs} ${f.width}x${f.height} mime=${f.sampleMimeType} fps=${f.frameRate}" + } + } + return "none" +} + /** * Flattens a [PlaybackException] into a single line: error code, message, and the full nested * cause chain (e.g. `DecoderInitializationException <- MediaCodec.CodecException`). The cause @@ -70,6 +97,32 @@ private const val STALL_MIN_BUFFER_AHEAD_MS = 2_000L // or two, so 8 s of fed-but-frozen buffering is unambiguous. private const val STALL_TIMEOUT_MS = 8_000L +// A live HLS stream (a FAST channel / kind:30311 broadcast) publishes only a short sliding window +// of segments — e.g. ~60 s — with no ENDLIST. If the playhead drifts before the start of that +// window (the player was prepared then held off the live edge, or a rebuffer outlasted the DVR +// depth), ExoPlayer raises ERROR_CODE_BEHIND_LIVE_WINDOW. That is not a terminal failure: the +// documented recovery is to seek back to the default (live) position and re-prepare. +// +// This is the ONLY error we auto-recover. An earlier version also re-prepared on any live I/O error +// (the 2xxx band), but that thrashed on a stream whose segments fail to parse +// (UnexpectedLoaderException / IllegalArgumentException): each re-prepare briefly reached READY then +// hit the same bad segment, and because the cap reset on READY it never gave up. I/O and decode +// errors are now left terminal — ExoPlayer's own LoadErrorHandlingPolicy already retries transient +// segment loads, and RenderPlaybackError shows the honest "open in browser" overlay for the rest. +internal const val MAX_LIVE_STREAM_RECOVERY_ATTEMPTS = 5 + +// The playhead must advance at least this far past the position an error was raised at before the +// recovery budget is refilled. Reaching READY alone is not enough: a stream that flaps READY for a +// fraction of a second at the same spot must exhaust the cap and surface, not loop forever. +private const val RECOVERY_RESET_PROGRESS_MS = 3_000L + +/** + * Whether a terminal [PlaybackException] should be recovered by seek-to-live + re-prepare rather + * than surfaced. Only `ERROR_CODE_BEHIND_LIVE_WINDOW` qualifies — it is a rejoin signal, not a + * failure. Everything else (I/O, decode, parse) is left terminal. Pure, so it is unit-testable. + */ +internal fun isRecoverableLiveError(errorCode: Int): Boolean = errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW + /** * Mirrors the MediaController's terminal-error state into [MediaControllerState.playbackError] * so [RenderPlaybackError] can show the codec-not-supported overlay with a browser fallback. @@ -96,13 +149,46 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) { errorState.value = controller.playerError controller.playerError?.let { Log.w(ERROR_LOG_TAG) { "Primed with existing error on ${controller.currentMediaItem?.mediaId}: ${it.describe()}" } + Log.d(PLAYBACK_DIAG_TAG) { "PRIMED with existing error on ${controller.currentMediaItem?.mediaId}: ${it.describe()}" } } + // Re-preparations attempted for the current stream. Reset on a media-item change and once + // playback makes real progress past the point an error was raised (see recoveryAnchorMs), + // never merely on reaching READY — a sub-second READY flap must not refill the budget. + var liveRecoveryAttempts = 0 + var recoveryAnchorMs = Long.MIN_VALUE + val listener = object : Player.Listener { override fun onPlayerErrorChanged(error: PlaybackException?) { if (error != null) { - Log.w(ERROR_LOG_TAG) { "Error raised on ${controller.currentMediaItem?.mediaId}: ${error.describe()}" } + val mediaId = controller.currentMediaItem?.mediaId + val recoverable = isRecoverableLiveError(error.errorCode) + Log.d(PLAYBACK_DIAG_TAG) { + "ERROR code=${error.errorCodeName}(${error.errorCode}) recoverable=$recoverable " + + "attempts=$liveRecoveryAttempts pos=${controller.currentPosition} buffered=${controller.bufferedPosition} " + + "on $mediaId :: ${error.describe()}" + } + Log.w(ERROR_LOG_TAG) { "Error raised on $mediaId: ${error.describe()}" } + + if (recoverable && liveRecoveryAttempts < MAX_LIVE_STREAM_RECOVERY_ATTEMPTS) { + liveRecoveryAttempts++ + recoveryAnchorMs = controller.currentPosition + Log.d(PLAYBACK_DIAG_TAG) { + "RECOVER attempt $liveRecoveryAttempts/$MAX_LIVE_STREAM_RECOVERY_ATTEMPTS " + + "after ${error.errorCodeName} on $mediaId — seekToDefaultPosition + prepare" + } + // Rejoin the live edge and re-prepare. Leave errorState null so the + // overlay never appears for a recoverable hiccup. + controller.seekToDefaultPosition() + controller.prepare() + return + } + + Log.d(PLAYBACK_DIAG_TAG) { + val why = if (recoverable) "GAVE UP after $liveRecoveryAttempts attempts" else "TERMINAL (not recovering) ${error.errorCodeName}" + "$why on $mediaId — overlay will show" + } } else if (errorState.value != null) { Log.d(ERROR_LOG_TAG) { "Error cleared on ${controller.currentMediaItem?.mediaId}" } } @@ -113,6 +199,9 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) { mediaItem: MediaItem?, reason: Int, ) { + Log.d(PLAYBACK_DIAG_TAG) { "ITEM reason=$reason -> ${mediaItem?.mediaId} (mime=${mediaItem?.localConfiguration?.mimeType})" } + liveRecoveryAttempts = 0 + recoveryAnchorMs = Long.MIN_VALUE // A new item on a pooled player starts fresh; drop any error from the old one. if (errorState.value != null) { Log.d(ERROR_LOG_TAG) { "Error dropped on media transition (reason=$reason) -> ${mediaItem?.mediaId}" } @@ -121,17 +210,46 @@ fun WatchPlaybackErrors(controllerState: MediaControllerState) { } override fun onPlaybackStateChanged(state: Int) { + Log.d(PLAYBACK_DIAG_TAG) { + "STATE=${playbackStateName(state)} playWhenReady=${controller.playWhenReady} " + + "pos=${controller.currentPosition} buffered=${controller.bufferedPosition} " + + "on ${controller.currentMediaItem?.mediaId}" + } // A successful transition to READY (the renderer produced output) is the only // real recovery — clear the overlay then. We deliberately do NOT clear on // STATE_BUFFERING: the synthetic decode-stall error below is raised *while* // buffering, and clearing on every buffering event would wipe it instantly. if (state == Player.STATE_READY) { + // Refill the recovery budget only after genuine forward progress past the last + // error, so a stream that re-errors at the same spot every ~1 s still hits the + // cap and surfaces instead of thrashing forever. + if (controller.currentPosition >= recoveryAnchorMs + RECOVERY_RESET_PROGRESS_MS) { + liveRecoveryAttempts = 0 + recoveryAnchorMs = Long.MIN_VALUE + } if (errorState.value != null) { Log.d(ERROR_LOG_TAG) { "Recovered (STATE_READY) on ${controller.currentMediaItem?.mediaId} — clearing overlay" } errorState.value = null } } } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int, + ) { + Log.d(PLAYBACK_DIAG_TAG) { + "DISCONTINUITY reason=$reason ${oldPosition.positionMs}ms -> ${newPosition.positionMs}ms " + + "on ${controller.currentMediaItem?.mediaId}" + } + } + + override fun onTracksChanged(tracks: Tracks) { + Log.d(PLAYBACK_DIAG_TAG) { + "TRACKS video[${tracks.selectedVideoSummary()}] on ${controller.currentMediaItem?.mediaId}" + } + } } controller.addListener(listener) @@ -184,6 +302,10 @@ private suspend fun watchForDecodeStall( "Synthetic decode-stall after ${STALL_TIMEOUT_MS}ms fed-but-frozen " + "(pos=$position buffered=${controller.bufferedPosition}) on ${controller.currentMediaItem?.mediaId}" } + Log.d(PLAYBACK_DIAG_TAG) { + "SYNTHETIC-STALL fed-but-frozen ${STALL_TIMEOUT_MS}ms " + + "(pos=$position buffered=${controller.bufferedPosition}) on ${controller.currentMediaItem?.mediaId} — likely codec reconfig/unsupported" + } errorState.value = PlaybackException( "Video decoding stalled with a full buffer — likely an unsupported codec", diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/HlsLivenessCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/HlsLivenessCache.kt new file mode 100644 index 0000000000..f7a5de2759 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/diskCache/HlsLivenessCache.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.diskCache + +import androidx.collection.LruCache + +/** + * Remembers, per media URL, whether ExoPlayer determined a stream to be live once it parsed the + * playlist. This is how the cache tells a live HLS FAST channel from an immutable on-demand HLS + * rendition — a distinction the `.m3u8` URL alone cannot make. + * + * A `.m3u8` cannot be classified before it is fetched, so the very first play of a URL is unknown + * and routed conservatively to the non-caching source (see CustomMediaSourceFactory). Once the + * timeline reports liveness (recorded by HlsLivenessRecorder), a *subsequent* play of the same URL + * routes correctly: a known on-demand stream is cached, a known live stream never is. + * + * A URL is permanently one or the other, and the recorder overwrites with the latest verdict on + * every timeline change, so a transient early value self-corrects. Backed by a bounded, thread-safe + * [LruCache] (same convention as [com.vitorpamplona.amethyst.model.MediaAspectRatioCache]) so a long + * feed session over many distinct URLs can't grow it without bound — an evicted entry just relearns + * on its next play, which is one uncached play, the same negligible cost as a process restart. + */ +object HlsLivenessCache { + // url -> isLive. Absent = not yet learned. + private val verdicts = LruCache(1000) + + fun record( + url: String, + isLive: Boolean, + ) { + verdicts.put(url, isLive) + } + + /** True only when we have positively learned this URL is on-demand (safe to cache). */ + fun isKnownOnDemand(url: String): Boolean = verdicts.get(url) == false + + /** The learned verdict, or null if this URL has not been classified yet. */ + fun verdict(url: String): Boolean? = verdicts.get(url) + + fun clear() { + verdicts.evictAll() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt index f6561c9a60..c38b614e45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactory.kt @@ -27,20 +27,54 @@ import androidx.media3.exoplayer.drm.DrmSessionManagerProvider import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy +import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache +import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache import com.vitorpamplona.amethyst.service.playback.diskCache.VideoCache import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.quartz.utils.Log /** - * True live streams (kind 30311) must not be cached. On-demand HLS - * (e.g. multi-rendition NIP-71 videos) is cached like any other video, - * because its segments are immutable. + * Whether an HLS item should bypass the disk cache. Pure so the routing is unit-testable. * - * The `isLiveStream` flag is carried on [MediaItem.mediaMetadata] extras - * and set by [MediaItemCache] from [com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData]. - * If the flag is absent (e.g. a [MediaItem] built outside the cache path), - * we fall back to the URL-based heuristic. + * - **Flagged live** (kind:30311 live activity) → always bypass. + * - **Progressive** (mp4, …) → never bypass; cache normally. + * - **HLS learned on-demand** → cache. A VOD playlist has `#EXT-X-ENDLIST`, so it is static and its + * segments are immutable — safe and worthwhile to cache. + * - **HLS live or not-yet-classified** → bypass. Caching a live playlist makes ExoPlayer reload a + * stale, non-advancing playlist and throw `PlaylistStuckException`, after which playback loops on + * the frozen window. Until we have positively learned a URL is on-demand (see [HlsLivenessCache]), + * the safe default is to bypass. + */ +internal fun shouldBypassCache( + isFlaggedLive: Boolean, + isHls: Boolean, + isKnownOnDemand: Boolean, +): Boolean = + when { + isFlaggedLive -> true + !isHls -> false + isKnownOnDemand -> false + else -> true + } + +/** + * Decides whether a [MediaItem] plays through the caching data source or bypasses it. + * + * The hard constraint is that a **live** HLS stream must never be cached — caching its mutating + * playlist makes ExoPlayer reload a stale, non-advancing manifest and throw `PlaylistStuckException` + * (surfaced as `ERROR_CODE_IO_UNSPECIFIED`), after which playback loops replaying the frozen window. + * But we also *want* to cache immutable on-demand HLS (multi-rendition NIP-71 VOD). + * + * A `.m3u8` URL cannot distinguish the two, and the `isLiveStream` metadata flag only marks + * kind:30311 live activities, so a live `.m3u8` shared in a plain kind:1 note arrives flagged + * non-live. We therefore learn liveness from ExoPlayer itself: [HlsLivenessRecorder] records the + * playlist's live/on-demand verdict into [HlsLivenessCache] once loaded, and the *next* play of that + * URL routes by it. The first (unclassified) play bypasses — the safe default — so a live stream is + * never cached even once; only a proven on-demand URL is cached, from its second view onward. + * + * The `isLiveStream` flag is carried on [MediaItem.mediaMetadata] extras and set by + * [MediaItemCache] from [com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData]. */ @UnstableApi class CustomMediaSourceFactory( @@ -67,27 +101,33 @@ class CustomMediaSourceFactory( override fun getSupportedTypes(): IntArray = nonCachingFactory.supportedTypes override fun createMediaSource(mediaItem: MediaItem): MediaSource { - val live = isLiveStream(mediaItem) - val itemMime = mediaItem.localConfiguration?.mimeType + val id = mediaItem.mediaId + val flaggedLive = isFlaggedLive(mediaItem) + val hls = isLiveStreaming(id) + val knownOnDemand = HlsLivenessCache.isKnownOnDemand(id) + val bypassCache = shouldBypassCache(flaggedLive, hls, knownOnDemand) + val source = - if (live) { + if (bypassCache) { nonCachingFactory.createMediaSource(mediaItem) } else { cachingFactory.createMediaSource(mediaItem) } - Log.d("CustomMediaSourceFactory") { - "createMediaSource(${if (live) "BYPASS" else "CACHE"}): id=${mediaItem.mediaId} mime=$itemMime -> ${source::class.java.simpleName}" + // Logs the three routing inputs directly rather than a re-derived label, so it can't drift + // from shouldBypassCache. + Log.d(PLAYBACK_DIAG_TAG) { + "SOURCE ${if (bypassCache) "BYPASS" else "CACHE"} flaggedLive=$flaggedLive hls=$hls knownOnDemand=$knownOnDemand " + + "mime=${mediaItem.localConfiguration?.mimeType} -> ${source::class.java.simpleName} id=$id" } return source } - private fun isLiveStream(mediaItem: MediaItem): Boolean { + // Only the explicit event-kind flag (kind:30311 live activities). Returns false when the flag + // is absent or false; the HLS URL check in createMediaSource covers everything else. + private fun isFlaggedLive(mediaItem: MediaItem): Boolean { val extras = mediaItem.mediaMetadata.extras - return if (extras != null && extras.containsKey(MediaItemCache.EXTRA_IS_LIVE_STREAM)) { + return extras != null && + extras.containsKey(MediaItemCache.EXTRA_IS_LIVE_STREAM) && extras.getBoolean(MediaItemCache.EXTRA_IS_LIVE_STREAM, false) - } else { - // Fallback for MediaItems that weren't built via MediaItemCache. - isLiveStreaming(mediaItem.mediaId) - } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt index 9575b9775a..c92252150a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerBuilder.kt @@ -83,6 +83,7 @@ class ExoPlayerBuilder( ) PcmTapRegistry.bind(currentMediaItem?.mediaId, sink) addListener(AspectRatioCacher(MediaAspectRatioCache)) + addListener(HlsLivenessRecorder(this)) addListener(AutoReplayLimiter(pause = ::pause)) addListener(KeepVideosPlaying(this)) addListener(CurrentPlayPositionCacher(this, VideoViewedPositionCache)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/HlsLivenessRecorder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/HlsLivenessRecorder.kt new file mode 100644 index 0000000000..4da281e78a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/HlsLivenessRecorder.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.playerPool + +import androidx.media3.common.C +import androidx.media3.common.Player +import androidx.media3.common.Timeline +import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG +import com.vitorpamplona.amethyst.service.playback.diskCache.HlsLivenessCache +import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming +import com.vitorpamplona.quartz.utils.Log + +/** + * The verdict to store for an HLS URL given the player's current window, or null to store nothing. + * + * The recording is deliberately asymmetric. A wrong "live" only forgoes caching, while a wrong + * "on-demand" caches a live stream and breaks it (PlaylistStuck), so live is cheap to record and + * on-demand must be earned: + * + * - record **live** as soon as any window says so ([allowOnDemand] irrelevant), but + * - record **on-demand** only when [allowOnDemand] (the player reached STATE_READY, i.e. it is + * actually playing, not a geo-blocked stream that served a VOD-shaped placeholder playlist and + * then errored), the window is a resolved static finite seekable VOD, and the URL is not already + * known live. + * + * Recording on-demand only at READY is what keeps a broken/geo-fenced live stream — which reports a + * static window from a timeline event but 403/404s before it can ever reach READY — from being + * mislearned as cacheable. Pure, so the classification is unit-testable without a [Player]. + */ +internal fun livenessVerdictToRecord( + isLive: Boolean, + isDynamic: Boolean, + isSeekable: Boolean, + hasKnownDuration: Boolean, + known: Boolean?, + allowOnDemand: Boolean, +): Boolean? = + when { + isLive -> true + !allowOnDemand -> null + !isDynamic && isSeekable && hasKnownDuration && known != true -> false + else -> null + } + +/** + * Records into [HlsLivenessCache] whether the current HLS item is live, so [CustomMediaSourceFactory] + * can cache proven on-demand HLS on the *next* play while never caching a live stream. The only + * reliable live/on-demand discriminator (`#EXT-X-ENDLIST`) is inside the playlist, so it is knowable + * only once ExoPlayer has loaded it — hence learning it here rather than from the URL. + * + * Only `.m3u8` items are considered; progressive media is unambiguous and never routed by liveness. + */ +class HlsLivenessRecorder( + private val player: Player, +) : Player.Listener { + override fun onTimelineChanged( + timeline: Timeline, + reason: Int, + ) = maybeRecord(allowOnDemand = false) + + override fun onPlaybackStateChanged(state: Int) { + // Only a stream that actually plays (reached READY) may be recorded on-demand; a geo-blocked + // stream that errors before READY must never be learned as cacheable. Live is still recorded + // from the timeline above the moment the window says so. + if (state == Player.STATE_READY) maybeRecord(allowOnDemand = true) + } + + private fun maybeRecord(allowOnDemand: Boolean) { + if (player.currentTimeline.isEmpty) return + val url = player.currentMediaItem?.mediaId ?: return + if (!isLiveStreaming(url)) return + + val known = HlsLivenessCache.verdict(url) + val toRecord = + livenessVerdictToRecord( + isLive = player.isCurrentMediaItemLive, + isDynamic = player.isCurrentMediaItemDynamic, + isSeekable = player.isCurrentMediaItemSeekable, + hasKnownDuration = player.contentDuration != C.TIME_UNSET, + known = known, + allowOnDemand = allowOnDemand, + ) ?: return + + // Only write when the verdict actually changes: onTimelineChanged fires on every manifest + // refresh of a live stream, and the verdict is stable once learned, so re-putting the same + // value would take a ConcurrentHashMap bin lock on every callback for nothing. + if (known != toRecord) { + Log.d(PLAYBACK_DIAG_TAG) { "LIVENESS ${if (toRecord) "LIVE" else "ON-DEMAND"} learned for $url" } + HlsLivenessCache.record(url, toRecord) + } + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrorsRecoveryTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrorsRecoveryTest.kt new file mode 100644 index 0000000000..c99161d0da --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/composable/WatchPlaybackErrorsRecoveryTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable + +import androidx.media3.common.PlaybackException +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The recovery predicate deliberately covers only BEHIND_LIVE_WINDOW. A prior version recovered any + * live I/O error, which thrashed on a stream whose segments fail to parse (it re-prepared, briefly + * reached READY, hit the same bad segment, and — because the cap reset on READY — never gave up). + * Everything except BEHIND_LIVE_WINDOW is now left terminal. + */ +class WatchPlaybackErrorsRecoveryTest { + @Test + fun onlyBehindLiveWindowIsRecoverable() { + assertTrue(isRecoverableLiveError(PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW)) + } + + @Test + fun ioErrorsAreNotRecoverable() { + // The nogoodradio thrash: UnexpectedLoaderException surfaced as ERROR_CODE_IO_UNSPECIFIED. + assertFalse(isRecoverableLiveError(PlaybackException.ERROR_CODE_IO_UNSPECIFIED)) + assertFalse(isRecoverableLiveError(PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED)) + assertFalse(isRecoverableLiveError(PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS)) + assertFalse(isRecoverableLiveError(PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND)) + } + + @Test + fun decodeAndFormatErrorsAreNotRecoverable() { + assertFalse(isRecoverableLiveError(PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED)) + assertFalse(isRecoverableLiveError(PlaybackException.ERROR_CODE_DECODER_INIT_FAILED)) + assertFalse(isRecoverableLiveError(PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED)) + } + + @Test + fun unspecifiedRuntimeErrorIsNotRecoverable() { + assertFalse(isRecoverableLiveError(PlaybackException.ERROR_CODE_UNSPECIFIED)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/diskCache/HlsLivenessCacheTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/diskCache/HlsLivenessCacheTest.kt new file mode 100644 index 0000000000..36584dc100 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/diskCache/HlsLivenessCacheTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.diskCache + +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class HlsLivenessCacheTest { + private val url = "https://nostr.download/abc.m3u8" + + @After + fun tearDown() { + HlsLivenessCache.clear() + } + + @Test + fun unknownUntilRecorded() { + assertNull(HlsLivenessCache.verdict(url)) + // Unknown must NOT be treated as on-demand — that is what keeps a live stream from being + // cached on its first play. + assertFalse(HlsLivenessCache.isKnownOnDemand(url)) + } + + @Test + fun recordsOnDemandVerdict() { + HlsLivenessCache.record(url, isLive = false) + assertTrue(HlsLivenessCache.isKnownOnDemand(url)) + assertEquals(false, HlsLivenessCache.verdict(url)) + } + + @Test + fun liveIsNeverTreatedAsOnDemand() { + HlsLivenessCache.record(url, isLive = true) + assertFalse(HlsLivenessCache.isKnownOnDemand(url)) + assertEquals(true, HlsLivenessCache.verdict(url)) + } + + @Test + fun latestVerdictWins() { + HlsLivenessCache.record(url, isLive = true) + HlsLivenessCache.record(url, isLive = false) + assertTrue(HlsLivenessCache.isKnownOnDemand(url)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactoryRoutingTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactoryRoutingTest.kt new file mode 100644 index 0000000000..79d99f778f --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/CustomMediaSourceFactoryRoutingTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.playerPool + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CustomMediaSourceFactoryRoutingTest { + @Test + fun flaggedLiveAlwaysBypasses() { + // kind:30311 live activity, regardless of anything else. + assertTrue(shouldBypassCache(isFlaggedLive = true, isHls = true, isKnownOnDemand = false)) + assertTrue(shouldBypassCache(isFlaggedLive = true, isHls = false, isKnownOnDemand = true)) + } + + @Test + fun progressiveMediaIsCached() { + // mp4 and friends: never HLS, so never live-ambiguous. + assertFalse(shouldBypassCache(isFlaggedLive = false, isHls = false, isKnownOnDemand = false)) + } + + @Test + fun unclassifiedHlsBypasses() { + // First play of an HLS URL: we have not learned live vs on-demand yet, so bypass (safe — + // a live stream is never cached even once). + assertTrue(shouldBypassCache(isFlaggedLive = false, isHls = true, isKnownOnDemand = false)) + } + + @Test + fun learnedOnDemandHlsIsCached() { + // Second play of a proven VOD HLS: cache it (static playlist, immutable segments). + assertFalse(shouldBypassCache(isFlaggedLive = false, isHls = true, isKnownOnDemand = true)) + } +} diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/HlsLivenessRecorderTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/HlsLivenessRecorderTest.kt new file mode 100644 index 0000000000..9831abcf25 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/playback/playerPool/HlsLivenessRecorderTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.playerPool + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HlsLivenessRecorderTest { + @Test + fun liveWindowRecordsLiveEvenBeforeReady() { + // Live is recorded from any signal, allowOnDemand irrelevant. + assertEquals( + true, + livenessVerdictToRecord(isLive = true, isDynamic = true, isSeekable = true, hasKnownDuration = false, known = null, allowOnDemand = false), + ) + } + + @Test + fun staticWindowBeforeReadyRecordsNothing() { + // The fox/freespeech case: a geo-blocked stream serves a VOD-shaped placeholder that looks + // static from a timeline event, but it errors before READY. allowOnDemand=false => record + // nothing, so it is never learned as cacheable. + assertNull( + livenessVerdictToRecord(isLive = false, isDynamic = false, isSeekable = true, hasKnownDuration = true, known = null, allowOnDemand = false), + ) + } + + @Test + fun prematureTimelineOfLiveStreamRecordsNothing() { + // A live stream's early timeline reports isLive=false, no duration, not seekable. + assertNull( + livenessVerdictToRecord(isLive = false, isDynamic = false, isSeekable = false, hasKnownDuration = false, known = null, allowOnDemand = true), + ) + } + + @Test + fun resolvedLiveWindowRecordsNothingAsOnDemand() { + // Once resolved, a live window is dynamic — excluded from the on-demand path. + assertNull( + livenessVerdictToRecord(isLive = false, isDynamic = true, isSeekable = true, hasKnownDuration = true, known = null, allowOnDemand = true), + ) + } + + @Test + fun staticFiniteSeekableWindowAtReadyRecordsOnDemand() { + assertEquals( + false, + livenessVerdictToRecord(isLive = false, isDynamic = false, isSeekable = true, hasKnownDuration = true, known = null, allowOnDemand = true), + ) + } + + @Test + fun knownLiveIsNeverDowngraded() { + // Even if a later window momentarily looks static at READY, a URL already learned live stays live. + assertNull( + livenessVerdictToRecord(isLive = false, isDynamic = false, isSeekable = true, hasKnownDuration = true, known = true, allowOnDemand = true), + ) + } + + @Test + fun liveVerdictUpgradesAKnownOnDemand() { + assertEquals( + true, + livenessVerdictToRecord(isLive = true, isDynamic = true, isSeekable = true, hasKnownDuration = false, known = false, allowOnDemand = true), + ) + } +}