feat(android/player): self-heal a stale system-playlist / radio queue on total failure
android / Build + lint + test (push) Successful in 3m56s

Web parity with 27766ae0. When a load error exhausts a fully-unplayable
queue, re-pull the source instead of stopping: a bare-variant source is a
refreshable system playlist (re-pull via PlaylistsRepository.systemShuffle),
"radio:<seed>" re-seeds via RadioController. Reads the source from the
current MediaItem extra; bounded to one re-pull per exhaustion (reset when
a track next loads with real audio) so a still-stale refresh can't loop.
Album / artist / user-playlist / offline sources have nothing to refresh
and still stop. Issue #968.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 12:56:01 -04:00
parent 27766ae063
commit d4cc177db4
@@ -14,6 +14,8 @@ import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken import androidx.media3.session.SessionToken
import com.fabledsword.minstrel.di.ApplicationScope import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.models.TrackRef import com.fabledsword.minstrel.models.TrackRef
import com.fabledsword.minstrel.playlists.data.PlaylistsRepository
import com.fabledsword.minstrel.playlists.data.toPlayableTrackRefs
import com.fabledsword.minstrel.shared.resolveServerUrl import com.fabledsword.minstrel.shared.resolveServerUrl
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -63,6 +65,7 @@ class PlayerController @Inject constructor(
@ApplicationContext private val context: Context, @ApplicationContext private val context: Context,
@ApplicationScope private val scope: CoroutineScope, @ApplicationScope private val scope: CoroutineScope,
private val radio: RadioController, private val radio: RadioController,
private val playlists: PlaylistsRepository,
private val playerFactory: PlayerFactory, private val playerFactory: PlayerFactory,
private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder, private val activeUpnpHolder: com.fabledsword.minstrel.player.output.ActiveUpnpHolder,
private val remoteState: RemotePlayerState, private val remoteState: RemotePlayerState,
@@ -111,6 +114,13 @@ class PlayerController @Inject constructor(
*/ */
private var lastEvaluatedItemIndex: Int = -1 private var lastEvaluatedItemIndex: Int = -1
/**
* #968: self-heal budget — re-pulls of a stale refreshable queue since
* the last successful play. Capped so a still-broken refresh can't loop;
* reset when any track loads with real audio.
*/
private var selfHealAttempts = 0
/** /**
* Stable queue snapshot kept in sync with the player's MediaItems — * Stable queue snapshot kept in sync with the player's MediaItems —
* the player's own getMediaItem(index) returns Media3 types; we keep * the player's own getMediaItem(index) returns Media3 types; we keep
@@ -351,7 +361,12 @@ class PlayerController @Inject constructor(
if (current == null || upnpEngaged) return if (current == null || upnpEngaged) return
val duration = controller.duration val duration = controller.duration
val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET val isZeroDuration = duration <= 0L || duration == androidx.media3.common.C.TIME_UNSET
if (!isZeroDuration) return if (!isZeroDuration) {
// A track loaded with real audio — clear the self-heal budget so
// a later stale queue can recover again.
selfHealAttempts = 0
return
}
playbackErrorEventsChannel.trySend( playbackErrorEventsChannel.trySend(
PlaybackErrorEvent( PlaybackErrorEvent(
trackId = current.id, trackId = current.id,
@@ -367,6 +382,58 @@ class PlayerController @Inject constructor(
} }
} }
/**
* #968: the queue is fully unplayable (every track failed to load). If it
* came from a refreshable source — a system playlist (bare-variant source)
* or radio ("radio:<seed>") — the snapshot is probably stale (app/tab left
* open across the daily rebuild); re-pull it and resume instead of stopping.
* Bounded by [selfHealAttempts]. Album / artist / user-playlist / offline
* sources have nothing to refresh — stop.
*/
private fun selfHealOrStop(controller: MediaController) {
val source = controller.currentMediaItem
?.mediaMetadata?.extras?.getString(MINSTREL_SOURCE_KEY)
if (source == null || selfHealAttempts >= MAX_SELF_HEAL_ATTEMPTS) {
controller.stop()
return
}
when {
source.startsWith("radio:") -> {
selfHealAttempts++
scope.launch { selfHealRadio(source.removePrefix("radio:")) }
}
!source.contains(':') -> { // bare variant = refreshable system playlist
selfHealAttempts++
scope.launch { selfHealSystem(source) }
}
else -> controller.stop()
}
}
private suspend fun selfHealSystem(variant: String) {
val refs = runCatching { playlists.systemShuffle(variant).tracks.toPlayableTrackRefs() }
.getOrDefault(emptyList())
if (refs.isEmpty()) {
stopOnControllerThread()
return
}
setQueue(refs, initialIndex = 0, source = variant)
}
private suspend fun selfHealRadio(seedTrackId: String) {
val refs = runCatching { radio.seed(seedTrackId) }.getOrDefault(emptyList())
if (refs.isEmpty()) {
stopOnControllerThread()
return
}
setQueue(refs, initialIndex = 0, source = "radio:$seedTrackId")
}
private fun stopOnControllerThread() {
val controller = mediaController ?: return
runOnControllerThread(controller) { controller.stop() }
}
// ── Internal: async connect + Listener-driven UI state sync ────────── // ── Internal: async connect + Listener-driven UI state sync ──────────
private suspend fun connectAndObserve() { private suspend fun connectAndObserve() {
@@ -419,13 +486,14 @@ class PlayerController @Inject constructor(
) )
// A failed load leaves the player IDLE; advance past the bad // A failed load leaves the player IDLE; advance past the bad
// track and re-prepare so one unplayable item doesn't strand // track and re-prepare so one unplayable item doesn't strand
// playback (mirrors the zero_duration skip). Forward-only — // playback (mirrors the zero_duration skip). At the end of a
// stop at the end — bounds a fully-unplayable queue. // fully-unplayable queue, try to self-heal a stale refreshable
// source before stopping.
if (controller.hasNextMediaItem()) { if (controller.hasNextMediaItem()) {
controller.seekToNextMediaItem() controller.seekToNextMediaItem()
controller.prepare() controller.prepare()
} else { } else {
controller.stop() selfHealOrStop(controller)
} }
} }
@@ -773,6 +841,10 @@ class PlayerController @Inject constructor(
// they don't need this poll. // they don't need this poll.
private const val POSITION_POLL_INTERVAL_MS = 500L private const val POSITION_POLL_INTERVAL_MS = 500L
// #968: at most one stale-queue self-heal re-pull per exhaustion (reset on
// the next successful play) so a still-broken refresh can't loop.
private const val MAX_SELF_HEAL_ATTEMPTS = 1
/** /**
* Structured playback failure event for [PlaybackErrorReporter]. Drives * Structured playback failure event for [PlaybackErrorReporter]. Drives
* both the user-facing snackbar ("Couldn't play X — skipping") and the * both the user-facing snackbar ("Couldn't play X — skipping") and the