Missing-file lifecycle end to end, UPnP stall recovery, Android browse parity, Flutter client removed #126
@@ -15,6 +15,7 @@ import com.fabledsword.minstrel.connectivity.ServerHealth
|
|||||||
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
import com.fabledsword.minstrel.player.output.ActiveUpnp
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
||||||
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
import com.fabledsword.minstrel.player.output.upnp.SoapFaultException
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportInfo
|
||||||
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
@@ -71,12 +72,18 @@ class MinstrelForwardingPlayer(
|
|||||||
private val castNetworkLock: CastNetworkLock,
|
private val castNetworkLock: CastNetworkLock,
|
||||||
private val networkStatus: NetworkStatusController,
|
private val networkStatus: NetworkStatusController,
|
||||||
private val onDrop: (routeName: String) -> Unit,
|
private val onDrop: (routeName: String) -> Unit,
|
||||||
|
private val onStalled: (trackId: String) -> Unit = {},
|
||||||
) : ForwardingPlayer(delegate) {
|
) : ForwardingPlayer(delegate) {
|
||||||
|
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
private val handler = Handler(delegate.applicationLooper)
|
private val handler = Handler(delegate.applicationLooper)
|
||||||
private var pollJob: Job? = null
|
private var pollJob: Job? = null
|
||||||
|
|
||||||
|
// Watches for the renderer stopping without being asked to. A UPnP
|
||||||
|
// renderer streams on its own, so a stream that dies looks like silence
|
||||||
|
// and nothing else in the app would notice -- see [RemoteStallWatchdog].
|
||||||
|
private val stallWatchdog = RemoteStallWatchdog()
|
||||||
|
|
||||||
// Tracks consecutive non-PLAYING poll observations so a single transient
|
// Tracks consecutive non-PLAYING poll observations so a single transient
|
||||||
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
|
// PAUSED_PLAYBACK / STOPPED tick during a Sonos track transition does not
|
||||||
// flip the play/pause button. Manual pause still feels instant because it
|
// flip the play/pause button. Manual pause still feels instant because it
|
||||||
@@ -492,6 +499,9 @@ class MinstrelForwardingPlayer(
|
|||||||
} else {
|
} else {
|
||||||
castNetworkLock.release()
|
castNetworkLock.release()
|
||||||
remoteState.reset()
|
remoteState.reset()
|
||||||
|
// The next cast starts with a clean attempt budget; a stall on the
|
||||||
|
// route we just left says nothing about the next one.
|
||||||
|
stallWatchdog.reset()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -584,9 +594,63 @@ class MinstrelForwardingPlayer(
|
|||||||
}
|
}
|
||||||
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
|
TransportState.TRANSITIONING, TransportState.UNKNOWN -> Unit
|
||||||
}
|
}
|
||||||
|
checkForStall(active, info.trackUri, transport)
|
||||||
notifyRemoteStateChanged()
|
notifyRemoteStateChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the watchdog what to make of this poll, and act on its answer.
|
||||||
|
*
|
||||||
|
* Recovery re-issues Play and then seeks back to the last position the
|
||||||
|
* renderer was observed playing, so a stream that died 90 seconds into a
|
||||||
|
* track resumes near there rather than restarting it. The seek is
|
||||||
|
* best-effort and deliberately after the play: a renderer that refuses
|
||||||
|
* the seek is still better off playing from zero than silent.
|
||||||
|
*/
|
||||||
|
private suspend fun checkForStall(
|
||||||
|
active: ActiveUpnp,
|
||||||
|
trackUri: String,
|
||||||
|
transport: TransportInfo,
|
||||||
|
) {
|
||||||
|
val decision = stallWatchdog.onPoll(
|
||||||
|
trackUri = trackUri,
|
||||||
|
state = transport.state,
|
||||||
|
statusOk = transport.statusOk,
|
||||||
|
playIntent = remoteState.lastPlayIntent,
|
||||||
|
positionMs = remoteState.positionMs,
|
||||||
|
nowMs = SystemClock.elapsedRealtime(),
|
||||||
|
)
|
||||||
|
when (decision) {
|
||||||
|
is RemoteStallWatchdog.Decision.Resume -> {
|
||||||
|
Timber.w(
|
||||||
|
"UPnP stall on %s: renderer stopped unasked (status_ok=%b), " +
|
||||||
|
"resume attempt %d at %dms",
|
||||||
|
active.routeName, transport.statusOk, decision.attempt, decision.resumeAtMs,
|
||||||
|
)
|
||||||
|
runCatching {
|
||||||
|
retryTransport { active.avTransport.play() }
|
||||||
|
if (decision.resumeAtMs > 0L) {
|
||||||
|
retryTransport { active.avTransport.seek(decision.resumeAtMs) }
|
||||||
|
}
|
||||||
|
}.onFailure {
|
||||||
|
// Leave the streak alone: a failed recovery is more
|
||||||
|
// evidence of a stall, and the next poll re-decides.
|
||||||
|
Timber.w(it, "UPnP stall: resume attempt failed on %s", active.routeName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RemoteStallWatchdog.Decision.GiveUp -> {
|
||||||
|
Timber.w(
|
||||||
|
"UPnP stall on %s: giving up after repeated resume attempts",
|
||||||
|
active.routeName,
|
||||||
|
)
|
||||||
|
// Tell the user and the admin inbox. Silence here would be the
|
||||||
|
// original bug: playback simply ends and nobody finds out.
|
||||||
|
trackIdFromStreamUri(trackUri)?.let { handler.post { onStalled(it) } }
|
||||||
|
}
|
||||||
|
RemoteStallWatchdog.Decision.None -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Align the paused local delegate cursor to the track the renderer is
|
* Align the paused local delegate cursor to the track the renderer is
|
||||||
* actually playing, so the un-overridden current-item getters
|
* actually playing, so the un-overridden current-item getters
|
||||||
|
|||||||
@@ -128,6 +128,28 @@ class PlayerController @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
private var queueRefs: List<TrackRef> = emptyList()
|
private var queueRefs: List<TrackRef> = emptyList()
|
||||||
|
|
||||||
|
init {
|
||||||
|
// A remote stall that survived the watchdog's retries is a playback
|
||||||
|
// failure like any other: the user gets the snackbar and the operator
|
||||||
|
// gets an admin-inbox row, via the same reporter that handles dead
|
||||||
|
// files. Without this the session just ends in silence -- the exact
|
||||||
|
// failure the watchdog exists to surface.
|
||||||
|
scope.launch {
|
||||||
|
playerFactory.stallEvents.collect { trackId ->
|
||||||
|
val title = queueRefs.firstOrNull { it.id == trackId }?.title
|
||||||
|
?.takeIf { it.isNotEmpty() } ?: "Track"
|
||||||
|
playbackErrorEventsChannel.trySend(
|
||||||
|
PlaybackErrorEvent(
|
||||||
|
trackId = trackId,
|
||||||
|
kind = "stalled",
|
||||||
|
title = title,
|
||||||
|
detail = "remote renderer stopped and would not resume",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Completes when [mediaController] is non-null and the listener has
|
* Completes when [mediaController] is non-null and the listener has
|
||||||
* been attached. Used by [awaitReady] so cold-boot callers like
|
* been attached. Used by [awaitReady] so cold-boot callers like
|
||||||
|
|||||||
@@ -75,6 +75,15 @@ class PlayerFactory @Inject constructor(
|
|||||||
)
|
)
|
||||||
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
|
val dropEvents: SharedFlow<String> = dropEventsInternal.asSharedFlow()
|
||||||
|
|
||||||
|
// Track ids whose remote playback stalled and could not be resumed. Same
|
||||||
|
// buffering rationale as dropEvents: a burst is one problem, not N.
|
||||||
|
private val stallEventsInternal = MutableSharedFlow<String>(
|
||||||
|
replay = 0,
|
||||||
|
extraBufferCapacity = 1,
|
||||||
|
onBufferOverflow = BufferOverflow.DROP_OLDEST,
|
||||||
|
)
|
||||||
|
val stallEvents: SharedFlow<String> = stallEventsInternal.asSharedFlow()
|
||||||
|
|
||||||
fun build(): Player {
|
fun build(): Player {
|
||||||
val exo = buildExoPlayer()
|
val exo = buildExoPlayer()
|
||||||
return MinstrelForwardingPlayer(
|
return MinstrelForwardingPlayer(
|
||||||
@@ -84,6 +93,7 @@ class PlayerFactory @Inject constructor(
|
|||||||
castNetworkLock = CastNetworkLock(context),
|
castNetworkLock = CastNetworkLock(context),
|
||||||
networkStatus = serverHealth,
|
networkStatus = serverHealth,
|
||||||
onDrop = { name -> emitDrop(name) },
|
onDrop = { name -> emitDrop(name) },
|
||||||
|
onStalled = { trackId -> stallEventsInternal.tryEmit(trackId) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notices when a UPnP renderer has stopped playing without being asked, and
|
||||||
|
* decides whether to try getting it going again.
|
||||||
|
*
|
||||||
|
* The gap this closes (diagnostics 2026-08-16): a Sonos playing from the
|
||||||
|
* server stopped by itself mid-track while the phone was in Doze. The poll
|
||||||
|
* loop was frozen, so nothing saw it; when the screen came back on the app
|
||||||
|
* faithfully reported "queue track 10, position 113s, not playing" and then
|
||||||
|
* sat there. Playback was over and no part of the app considered that a
|
||||||
|
* problem. A renderer streams autonomously, which is exactly why a failed
|
||||||
|
* stream is invisible without something watching for it.
|
||||||
|
*
|
||||||
|
* Deliberately conservative about what counts as a stall:
|
||||||
|
*
|
||||||
|
* - Only STOPPED (or a transport reporting an error) triggers recovery.
|
||||||
|
* PAUSED is left alone: the likely cause is a person pausing from the
|
||||||
|
* Sonos app or a wall controller, and fighting them for the transport is
|
||||||
|
* obnoxious. A stream that dies stops, it does not pause.
|
||||||
|
* - Only when the operator's last intent was to play. A stop we asked for
|
||||||
|
* is not a stall.
|
||||||
|
* - Only after [STALL_CONFIRM_POLLS] consecutive polls agree, so a single
|
||||||
|
* reading during a track change (Sonos passes through STOPPED and
|
||||||
|
* TRANSITIONING between queue items) never trips it.
|
||||||
|
* - At most [MAX_RESUME_ATTEMPTS] per track, spaced by
|
||||||
|
* [RETRY_SPACING_MS]. A genuinely unplayable file must not become an
|
||||||
|
* infinite retry loop against the renderer.
|
||||||
|
*
|
||||||
|
* Pure decision state, no coroutines and no SOAP: the caller owns the poll
|
||||||
|
* loop and performs the transport calls, this only says what should happen.
|
||||||
|
* That keeps the awkward part — counting, keying and giving up — testable
|
||||||
|
* without a renderer.
|
||||||
|
*/
|
||||||
|
class RemoteStallWatchdog {
|
||||||
|
|
||||||
|
sealed interface Decision {
|
||||||
|
/** Nothing to do. */
|
||||||
|
data object None : Decision
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ask the renderer to play again. [resumeAtMs] is the last position
|
||||||
|
* observed while it was actually playing, so the caller can seek back
|
||||||
|
* to roughly where the listener was rather than restarting the track.
|
||||||
|
*/
|
||||||
|
data class Resume(val attempt: Int, val resumeAtMs: Long) : Decision
|
||||||
|
|
||||||
|
/** Attempts are exhausted. Report it and stop trying for this track. */
|
||||||
|
data object GiveUp : Decision
|
||||||
|
}
|
||||||
|
|
||||||
|
private var trackKey: String = ""
|
||||||
|
private var lastPlayingPositionMs: Long = 0L
|
||||||
|
private var stoppedStreak: Int = 0
|
||||||
|
private var attempts: Int = 0
|
||||||
|
private var lastAttemptAtMs: Long = 0L
|
||||||
|
private var gaveUp: Boolean = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Feed one poll result in, get the action out.
|
||||||
|
*
|
||||||
|
* @param trackUri the renderer's current track URI — identity for the
|
||||||
|
* per-track attempt budget, so moving to the next track forgives a
|
||||||
|
* previous one's failures.
|
||||||
|
* @param statusOk the transport's own status flag: false means the
|
||||||
|
* renderer is reporting an error rather than merely being stopped.
|
||||||
|
* @param playIntent the operator's last play/pause intent.
|
||||||
|
* @param nowMs a monotonic clock (SystemClock.elapsedRealtime), passed in
|
||||||
|
* so tests can drive time.
|
||||||
|
*/
|
||||||
|
@Suppress("ReturnCount") // early returns per state are clearer than nesting
|
||||||
|
fun onPoll(
|
||||||
|
trackUri: String,
|
||||||
|
state: TransportState,
|
||||||
|
statusOk: Boolean,
|
||||||
|
playIntent: Boolean,
|
||||||
|
positionMs: Long,
|
||||||
|
nowMs: Long,
|
||||||
|
): Decision {
|
||||||
|
if (trackUri != trackKey) {
|
||||||
|
// New track: a fresh attempt budget, and no inherited stall state.
|
||||||
|
trackKey = trackUri
|
||||||
|
resetStall()
|
||||||
|
attempts = 0
|
||||||
|
gaveUp = false
|
||||||
|
lastPlayingPositionMs = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playIntent) {
|
||||||
|
// Stopped because we asked. Not a stall, and the next genuine one
|
||||||
|
// should start from a clean budget.
|
||||||
|
resetStall()
|
||||||
|
attempts = 0
|
||||||
|
gaveUp = false
|
||||||
|
return Decision.None
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state == TransportState.PLAYING && statusOk) {
|
||||||
|
lastPlayingPositionMs = positionMs
|
||||||
|
resetStall()
|
||||||
|
// A track that recovered and is playing again has earned back its
|
||||||
|
// budget; a later, unrelated stall on the same track should get
|
||||||
|
// the full set of attempts rather than the remainder.
|
||||||
|
attempts = 0
|
||||||
|
return Decision.None
|
||||||
|
}
|
||||||
|
|
||||||
|
val stalled = state == TransportState.STOPPED || !statusOk
|
||||||
|
if (!stalled) {
|
||||||
|
// PAUSED (someone else's doing) or TRANSITIONING/UNKNOWN (in
|
||||||
|
// flight). Neither is a stall; drop the streak so a mid-track
|
||||||
|
// transition doesn't accumulate toward one.
|
||||||
|
resetStall()
|
||||||
|
return Decision.None
|
||||||
|
}
|
||||||
|
|
||||||
|
stoppedStreak += 1
|
||||||
|
if (stoppedStreak < STALL_CONFIRM_POLLS) return Decision.None
|
||||||
|
if (gaveUp) return Decision.None
|
||||||
|
|
||||||
|
if (attempts >= MAX_RESUME_ATTEMPTS) {
|
||||||
|
gaveUp = true
|
||||||
|
return Decision.GiveUp
|
||||||
|
}
|
||||||
|
if (attempts > 0 && nowMs - lastAttemptAtMs < RETRY_SPACING_MS) return Decision.None
|
||||||
|
|
||||||
|
attempts += 1
|
||||||
|
lastAttemptAtMs = nowMs
|
||||||
|
return Decision.Resume(attempt = attempts, resumeAtMs = lastPlayingPositionMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget everything — call when the route changes or playback is torn down. */
|
||||||
|
fun reset() {
|
||||||
|
trackKey = ""
|
||||||
|
lastPlayingPositionMs = 0L
|
||||||
|
resetStall()
|
||||||
|
attempts = 0
|
||||||
|
lastAttemptAtMs = 0L
|
||||||
|
gaveUp = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun resetStall() {
|
||||||
|
stoppedStreak = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
// At the 1s poll cadence this is ~3s of agreement. Sonos passes
|
||||||
|
// through STOPPED between queue items, so one or two readings mean
|
||||||
|
// nothing on their own.
|
||||||
|
const val STALL_CONFIRM_POLLS = 3
|
||||||
|
|
||||||
|
// Three tries at ~5s spacing covers a server blip or a dropped
|
||||||
|
// connection without hammering a renderer whose file is simply bad.
|
||||||
|
const val MAX_RESUME_ATTEMPTS = 3
|
||||||
|
const val RETRY_SPACING_MS = 5_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
-2
@@ -225,7 +225,14 @@ class AVTransportClient(
|
|||||||
"TRANSITIONING" -> TransportState.TRANSITIONING
|
"TRANSITIONING" -> TransportState.TRANSITIONING
|
||||||
else -> TransportState.UNKNOWN
|
else -> TransportState.UNKNOWN
|
||||||
}
|
}
|
||||||
return TransportInfo(state)
|
// CurrentTransportStatus is the renderer's own verdict on whether it is
|
||||||
|
// healthy, and it is the one unambiguous way to tell "the stream died"
|
||||||
|
// from "somebody pressed stop" — both of which land in STOPPED. The
|
||||||
|
// spec defines OK and ERROR_OCCURRED; anything unrecognised (or absent,
|
||||||
|
// which some renderers do) is read as OK so a quiet device is never
|
||||||
|
// treated as a broken one.
|
||||||
|
val statusOk = result["CurrentTransportStatus"]?.let { it != "ERROR_OCCURRED" } ?: true
|
||||||
|
return TransportInfo(state, statusOk)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildDidlLite(uri: String, mime: String, title: String): String {
|
private fun buildDidlLite(uri: String, mime: String, title: String): String {
|
||||||
@@ -293,4 +300,9 @@ data class PositionInfo(
|
|||||||
|
|
||||||
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
|
enum class TransportState { PLAYING, PAUSED, STOPPED, TRANSITIONING, UNKNOWN }
|
||||||
|
|
||||||
data class TransportInfo(val state: TransportState)
|
/**
|
||||||
|
* [statusOk] is CurrentTransportStatus, defaulted true so the many call sites
|
||||||
|
* that only care about [state] read unchanged and an older/quieter renderer is
|
||||||
|
* never mistaken for a failing one.
|
||||||
|
*/
|
||||||
|
data class TransportInfo(val state: TransportState, val statusOk: Boolean = true)
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package com.fabledsword.minstrel.player
|
||||||
|
|
||||||
|
import com.fabledsword.minstrel.player.output.upnp.TransportState
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class RemoteStallWatchdogTest {
|
||||||
|
|
||||||
|
private val uri = "http://server/api/tracks/t-1/stream.flac"
|
||||||
|
private val other = "http://server/api/tracks/t-2/stream.flac"
|
||||||
|
|
||||||
|
/** Feed one poll, defaulting everything to "playing normally". */
|
||||||
|
private fun RemoteStallWatchdog.poll(
|
||||||
|
trackUri: String = uri,
|
||||||
|
state: TransportState = TransportState.PLAYING,
|
||||||
|
statusOk: Boolean = true,
|
||||||
|
playIntent: Boolean = true,
|
||||||
|
positionMs: Long = 0L,
|
||||||
|
nowMs: Long = 0L,
|
||||||
|
) = onPoll(trackUri, state, statusOk, playIntent, positionMs, nowMs)
|
||||||
|
|
||||||
|
/** Drive [n] stopped polls and return the last decision. */
|
||||||
|
private fun RemoteStallWatchdog.stopFor(
|
||||||
|
n: Int,
|
||||||
|
nowMs: Long = 0L,
|
||||||
|
): RemoteStallWatchdog.Decision {
|
||||||
|
var last: RemoteStallWatchdog.Decision = RemoteStallWatchdog.Decision.None
|
||||||
|
repeat(n) { last = poll(state = TransportState.STOPPED, nowMs = nowMs) }
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `playing normally asks for nothing`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
repeat(10) { assertIs<RemoteStallWatchdog.Decision.None>(w.poll(positionMs = it * 1000L)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a stop we asked for is not a stall`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
repeat(10) {
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.None>(
|
||||||
|
w.poll(state = TransportState.STOPPED, playIntent = false),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sonos passes through STOPPED between queue items. One or two readings
|
||||||
|
* must never trigger a resume or every track change would fight itself.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a brief stop during a track transition is ignored`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.None>(w.stopFor(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a sustained unrequested stop asks for a resume`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
val decision = w.stopFor(3)
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(decision)
|
||||||
|
assertEquals(1, decision.attempt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The point of the resume: come back where the listener was. */
|
||||||
|
@Test
|
||||||
|
fun `resume carries the last position seen while playing`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
w.poll(state = TransportState.PLAYING, positionMs = 113_000L)
|
||||||
|
val decision = w.stopFor(3)
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(decision)
|
||||||
|
assertEquals(113_000L, decision.resumeAtMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Someone pausing from the Sonos app or a wall controller owns the
|
||||||
|
* transport. Grabbing it back would be a fight the user always loses.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a pause from elsewhere is left alone`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
repeat(10) {
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.None>(w.poll(state = TransportState.PAUSED))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `transitioning is not treated as a stall`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
repeat(10) {
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.None>(
|
||||||
|
w.poll(state = TransportState.TRANSITIONING),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A renderer reporting ERROR_OCCURRED is the unambiguous signal, and it
|
||||||
|
* should not have to also say STOPPED before we act.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `a transport reporting an error counts as a stall`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
var last: RemoteStallWatchdog.Decision = RemoteStallWatchdog.Decision.None
|
||||||
|
repeat(3) { last = w.poll(state = TransportState.PLAYING, statusOk = false) }
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(last)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `retries are spaced out rather than fired every poll`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(w.stopFor(3, nowMs = 0L))
|
||||||
|
// Immediately after, still stopped: too soon to try again.
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.None>(w.stopFor(1, nowMs = 1_000L))
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.None>(w.stopFor(1, nowMs = 4_999L))
|
||||||
|
// Past the spacing, the next attempt goes out.
|
||||||
|
val second = w.stopFor(1, nowMs = 5_000L)
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(second)
|
||||||
|
assertEquals(2, second.attempt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The failure this guards against is an unplayable file turning into an
|
||||||
|
* endless retry loop against the renderer.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `attempts are capped and then it gives up exactly once`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(w.stopFor(3, nowMs = 0L))
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(w.stopFor(1, nowMs = 5_000L))
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(w.stopFor(1, nowMs = 10_000L))
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.GiveUp>(w.stopFor(1, nowMs = 15_000L))
|
||||||
|
// Reported once; after that it stays quiet instead of spamming.
|
||||||
|
repeat(20) {
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.None>(w.stopFor(1, nowMs = 20_000L + it * 5_000L))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `moving to another track restores the attempt budget`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(w.stopFor(3, nowMs = 0L))
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(w.stopFor(1, nowMs = 5_000L))
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(w.stopFor(1, nowMs = 10_000L))
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.GiveUp>(w.stopFor(1, nowMs = 15_000L))
|
||||||
|
|
||||||
|
// A different track is a different problem.
|
||||||
|
w.poll(trackUri = other, state = TransportState.PLAYING, nowMs = 16_000L)
|
||||||
|
w.poll(trackUri = other, state = TransportState.STOPPED, nowMs = 20_000L)
|
||||||
|
w.poll(trackUri = other, state = TransportState.STOPPED, nowMs = 20_000L)
|
||||||
|
// Held in a val: a var mutated inside a lambda can't be smart-cast.
|
||||||
|
val onNewTrack = w.poll(trackUri = other, state = TransportState.STOPPED, nowMs = 20_000L)
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(onNewTrack)
|
||||||
|
assertEquals(1, onNewTrack.attempt)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A track that stalls, recovers and stalls again later gets the full
|
||||||
|
* budget the second time — otherwise one bad patch early in a long track
|
||||||
|
* would leave it defenceless for the rest.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun `recovering to playing restores the attempt budget`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(w.stopFor(3, nowMs = 0L))
|
||||||
|
w.poll(state = TransportState.PLAYING, positionMs = 60_000L, nowMs = 6_000L)
|
||||||
|
|
||||||
|
val again = w.stopFor(3, nowMs = 30_000L)
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(again)
|
||||||
|
assertEquals(1, again.attempt)
|
||||||
|
assertEquals(60_000L, again.resumeAtMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reset forgets everything`() {
|
||||||
|
val w = RemoteStallWatchdog()
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(w.stopFor(3, nowMs = 0L))
|
||||||
|
w.reset()
|
||||||
|
val afterReset = w.stopFor(3, nowMs = 1_000L)
|
||||||
|
assertIs<RemoteStallWatchdog.Decision.Resume>(afterReset)
|
||||||
|
assertEquals(1, afterReset.attempt)
|
||||||
|
assertTrue(afterReset.resumeAtMs == 0L)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user