mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-10 08:27:04 +00:00
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:
@@ -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"
|
||||
+123
-1
@@ -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",
|
||||
|
||||
+61
@@ -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()
|
||||
}
|
||||
}
|
||||
+58
-18
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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))
|
||||
|
||||
+110
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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))
|
||||
}
|
||||
}
|
||||
+66
@@ -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))
|
||||
}
|
||||
}
|
||||
+53
@@ -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))
|
||||
}
|
||||
}
|
||||
+86
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user