fix(player): tell the user when the server cannot read the media file

A 404 on the media stream means the server resolved the item but could not
open the file behind it — moved, deleted, or on storage that went away.
Jellyfin maps the resulting FileNotFoundException to 404, and PlaybackInfo
never stats the file, so negotiation succeeds and only the stream request
fails. Playback then died with a snackbar reading "Failed to open
[REDACTED_URL]" before popping the route, which tells the user nothing and
leaves nothing useful in a bug report.

Generalize the HTTP-500 log probe into PlayerError.httpStatusFromLog and
latch every status in fatalPlaybackHttpStatuses. Each latches on its own so
the 503 that stream-lavf-o deliberately retries cannot mask the fatal status
behind it. A 404 now raises a dedicated modal naming the cause and the fix.

On Android a 404 previously failed the "Response code: 500" string test and
fell through to the ExoPlayer→MPV fallback, showing "switching to compatible
player" before failing again on the same request. Read the real status off
HttpDataSource.InvalidResponseCodeException instead and skip the fallback:
an HTTP status is not a codec problem.
This commit is contained in:
edde746
2026-07-31 21:45:33 +02:00
parent 6f9edd3e93
commit 86c8011b72
35 changed files with 290 additions and 45 deletions
@@ -120,6 +120,10 @@ class ExoPlayerCore(private val activity: Activity) :
private const val FPS_SAMPLE_COUNT = 8
private const val AUDIO_BOUNCE_TIMEOUT_MS = 1000L
/** Fallback for stacks that only stringify the status instead of raising
* [HttpDataSource.InvalidResponseCodeException]. */
private val RESPONSE_CODE_PATTERN = Regex("""\bResponse code: (\d{3})\b""")
/** Per-frame "video is at X" logcat stream (tag AssFrameCb) for diagnosing
* ASS subtitle lag against the libass pipeline's render/swap lines. */
private const val ASS_FRAME_LOGS = false
@@ -1204,18 +1208,24 @@ class ExoPlayerCore(private val activity: Activity) :
// If native DV7 failed, retry with conversion before falling to MPV
if (error.errorCode in 4001..4005 && retryWithDvConversion("decoder error ${error.errorCode}")) return
// Server returned HTTP 500 — typically a shared-user bandwidth/transcoding limit
// set by the server owner. MPV will hit the same rejection, so skip the fallback.
// Keep the "server-http-500" tag in sync with PlayerError.serverHttp500 in Dart.
val isHttp500 =
causeChain.contains("Response code: 500") ||
(error.message?.contains("Response code: 500") == true)
if (isHttp500) {
Log.w(TAG, "Server returned HTTP 500 - skipping MPV fallback (unrecoverable until server-side change)")
// A server-side HTTP status is not a codec problem: MPV would replay the
// same request and fail identically, so skip the fallback (and its
// "switching to compatible player" toast) and report the status instead.
// Keep these tags in sync with PlayerError.serverHttp500/404 in Dart.
val httpStatus = resolveHttpStatus(error, causeChain)
val statusCause = when (httpStatus) {
// Shared-user bandwidth/transcoding limit rejection set by the server owner.
500 -> "server-http-500"
// The server resolved the item but cannot read the file behind it.
404 -> "server-http-404"
else -> null
}
if (statusCause != null) {
Log.w(TAG, "Server returned HTTP $httpStatus - skipping MPV fallback (unrecoverable until server-side change)")
emitPlaybackErrorOnce(
mediaGeneration,
error.message ?: "HTTP 500",
cause = "server-http-500"
error.message ?: "HTTP $httpStatus",
cause = statusCause
)
return
}
@@ -1249,6 +1259,22 @@ class ExoPlayerCore(private val activity: Activity) :
emitPlaybackErrorOnce(mediaGeneration, error.message ?: "Unknown error")
}
/**
* HTTP status the failed request came back with, or null when this is not an
* HTTP failure. [HttpDataSource.InvalidResponseCodeException] carries the
* real code (both DefaultHttpDataSource and CronetDataSource raise it);
* [causeChain] is the already-built string fallback.
*/
private fun resolveHttpStatus(error: PlaybackException, causeChain: String): Int? {
var cause: Throwable? = error
while (cause != null) {
if (cause is HttpDataSource.InvalidResponseCodeException) return cause.responseCode
cause = cause.cause
}
return RESPONSE_CODE_PATTERN.find(causeChain)?.groupValues?.get(1)?.toIntOrNull()
?: error.message?.let { RESPONSE_CODE_PATTERN.find(it)?.groupValues?.get(1)?.toIntOrNull() }
}
/**
* media3 reports [StuckPlayerException.STUCK_PLAYING_NOT_ENDING] when the
* player sits in STATE_READY past the declared duration without any renderer