Merge pull request #3607 from davotoula/fix/live-hls-behind-window-recovery

Keep live HLS streams playing; cache only proven on-demand HLS
This commit is contained in:
Vitor Pamplona
2026-07-17 08:47:51 -04:00
committed by GitHub
10 changed files with 651 additions and 19 deletions
@@ -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"
@@ -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",
@@ -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<String, Boolean>(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()
}
}
@@ -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)
}
}
}
@@ -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))
@@ -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)
}
}
}
@@ -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))
}
}
@@ -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))
}
}
@@ -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))
}
}
@@ -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),
)
}
}