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 FPS_SAMPLE_COUNT = 8
private const val AUDIO_BOUNCE_TIMEOUT_MS = 1000L 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 /** Per-frame "video is at X" logcat stream (tag AssFrameCb) for diagnosing
* ASS subtitle lag against the libass pipeline's render/swap lines. */ * ASS subtitle lag against the libass pipeline's render/swap lines. */
private const val ASS_FRAME_LOGS = false 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 native DV7 failed, retry with conversion before falling to MPV
if (error.errorCode in 4001..4005 && retryWithDvConversion("decoder error ${error.errorCode}")) return if (error.errorCode in 4001..4005 && retryWithDvConversion("decoder error ${error.errorCode}")) return
// Server returned HTTP 500 — typically a shared-user bandwidth/transcoding limit // A server-side HTTP status is not a codec problem: MPV would replay the
// set by the server owner. MPV will hit the same rejection, so skip the fallback. // same request and fail identically, so skip the fallback (and its
// Keep the "server-http-500" tag in sync with PlayerError.serverHttp500 in Dart. // "switching to compatible player" toast) and report the status instead.
val isHttp500 = // Keep these tags in sync with PlayerError.serverHttp500/404 in Dart.
causeChain.contains("Response code: 500") || val httpStatus = resolveHttpStatus(error, causeChain)
(error.message?.contains("Response code: 500") == true) val statusCause = when (httpStatus) {
if (isHttp500) { // Shared-user bandwidth/transcoding limit rejection set by the server owner.
Log.w(TAG, "Server returned HTTP 500 - skipping MPV fallback (unrecoverable until server-side change)") 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( emitPlaybackErrorOnce(
mediaGeneration, mediaGeneration,
error.message ?: "HTTP 500", error.message ?: "HTTP $httpStatus",
cause = "server-http-500" cause = statusCause
) )
return return
} }
@@ -1249,6 +1259,22 @@ class ExoPlayerCore(private val activity: Activity) :
emitPlaybackErrorOnce(mediaGeneration, error.message ?: "Unknown error") 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 * media3 reports [StuckPlayerException.STUCK_PLAYING_NOT_ENDING] when the
* player sits in STATE_READY past the declared duration without any renderer * player sits in STATE_READY past the declared duration without any renderer
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Uyğun oynadıcıya keçilir...", "switchingToCompatiblePlayer": "Uyğun oynadıcıya keçilir...",
"serverLimitTitle": "Oynatma uğursuz oldu", "serverLimitTitle": "Oynatma uğursuz oldu",
"serverLimitBody": "Server xətası (HTTP 500). Məhdudiyyət bu seansı rədd etdi.", "serverLimitBody": "Server xətası (HTTP 500). Məhdudiyyət bu seansı rədd etdi.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Jurnallar yükləndi", "logsUploaded": "Jurnallar yükləndi",
"logsUploadFailed": "Jurnallar yüklənə bilmədi", "logsUploadFailed": "Jurnallar yüklənə bilmədi",
"logId": "Jurnal ID-si" "logId": "Jurnal ID-si"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Превключване към съвместим плейър...", "switchingToCompatiblePlayer": "Превключване към съвместим плейър...",
"serverLimitTitle": "Възпроизвеждането е неуспешно", "serverLimitTitle": "Възпроизвеждането е неуспешно",
"serverLimitBody": "Грешка на сървъра (HTTP 500). Вероятно лимит за пропускателна способност/транскодиране е отхвърлил тази сесия. Помолете собственика да го коригира.", "serverLimitBody": "Грешка на сървъра (HTTP 500). Вероятно лимит за пропускателна способност/транскодиране е отхвърлил тази сесия. Помолете собственика да го коригира.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Логовете са качени", "logsUploaded": "Логовете са качени",
"logsUploadFailed": "Неуспешно качване на логовете", "logsUploadFailed": "Неуспешно качване на логовете",
"logId": "ID на лога" "logId": "ID на лога"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Skifter til kompatibel afspiller...", "switchingToCompatiblePlayer": "Skifter til kompatibel afspiller...",
"serverLimitTitle": "Afspilning mislykkedes", "serverLimitTitle": "Afspilning mislykkedes",
"serverLimitBody": "Serverfejl (HTTP 500). En båndbredde- eller transkodningsgrænse afviste sandsynligvis sessionen. Bed ejeren om at justere den.", "serverLimitBody": "Serverfejl (HTTP 500). En båndbredde- eller transkodningsgrænse afviste sandsynligvis sessionen. Bed ejeren om at justere den.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Logfilerne blev uploadet", "logsUploaded": "Logfilerne blev uploadet",
"logsUploadFailed": "Logfilerne kunne ikke uploades", "logsUploadFailed": "Logfilerne kunne ikke uploades",
"logId": "Log-ID" "logId": "Log-ID"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Wechsel zu einem kompatiblen Player …", "switchingToCompatiblePlayer": "Wechsel zu einem kompatiblen Player …",
"serverLimitTitle": "Wiedergabe fehlgeschlagen", "serverLimitTitle": "Wiedergabe fehlgeschlagen",
"serverLimitBody": "Serverfehler (HTTP 500). Vermutlich hat ein Bandbreiten- oder Transkodierungslimit diese Sitzung abgelehnt. Bitte den Besitzer, das Limit anzupassen.", "serverLimitBody": "Serverfehler (HTTP 500). Vermutlich hat ein Bandbreiten- oder Transkodierungslimit diese Sitzung abgelehnt. Bitte den Besitzer, das Limit anzupassen.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Protokolle hochgeladen", "logsUploaded": "Protokolle hochgeladen",
"logsUploadFailed": "Protokolle konnten nicht hochgeladen werden", "logsUploadFailed": "Protokolle konnten nicht hochgeladen werden",
"logId": "Protokoll-ID" "logId": "Protokoll-ID"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Switching to compatible player...", "switchingToCompatiblePlayer": "Switching to compatible player...",
"serverLimitTitle": "Playback failed", "serverLimitTitle": "Playback failed",
"serverLimitBody": "Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.", "serverLimitBody": "Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.",
"mediaUnreadableTitle": "File unavailable",
"mediaUnreadableBody": "The server found this item but could not read its file (HTTP 404). The file was probably moved, deleted, or its storage is offline. Ask the server owner to check the file and rescan the library.",
"logsUploaded": "Logs uploaded", "logsUploaded": "Logs uploaded",
"logsUploadFailed": "Failed to upload logs", "logsUploadFailed": "Failed to upload logs",
"logId": "Log ID" "logId": "Log ID"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Cambiando a reproductor compatible...", "switchingToCompatiblePlayer": "Cambiando a reproductor compatible...",
"serverLimitTitle": "Error de reproducción", "serverLimitTitle": "Error de reproducción",
"serverLimitBody": "Error del servidor (HTTP 500). Un límite de ancho de banda/transcodificación probablemente rechazó esta sesión. Pide al propietario que lo ajuste.", "serverLimitBody": "Error del servidor (HTTP 500). Un límite de ancho de banda/transcodificación probablemente rechazó esta sesión. Pide al propietario que lo ajuste.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Registros subidos", "logsUploaded": "Registros subidos",
"logsUploadFailed": "Error al subir registros", "logsUploadFailed": "Error al subir registros",
"logId": "ID de registro" "logId": "ID de registro"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Passage au lecteur compatible...", "switchingToCompatiblePlayer": "Passage au lecteur compatible...",
"serverLimitTitle": "Échec de la lecture", "serverLimitTitle": "Échec de la lecture",
"serverLimitBody": "Erreur serveur (HTTP 500). Une limite de bande passante/transcodage a probablement rejeté cette session. Demandez au propriétaire de l'ajuster.", "serverLimitBody": "Erreur serveur (HTTP 500). Une limite de bande passante/transcodage a probablement rejeté cette session. Demandez au propriétaire de l'ajuster.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Journaux envoyés", "logsUploaded": "Journaux envoyés",
"logsUploadFailed": "Échec de lenvoi des journaux", "logsUploadFailed": "Échec de lenvoi des journaux",
"logId": "Identifiant du journal" "logId": "Identifiant du journal"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Váltás kompatibilis lejátszóra...", "switchingToCompatiblePlayer": "Váltás kompatibilis lejátszóra...",
"serverLimitTitle": "A lejátszás nem sikerült", "serverLimitTitle": "A lejátszás nem sikerült",
"serverLimitBody": "Szerverhiba (HTTP 500). A munkamenetet valószínűleg egy sávszélességi vagy átkódolási korlát utasította el. Kérd meg a tulajdonost a korlát módosítására.", "serverLimitBody": "Szerverhiba (HTTP 500). A munkamenetet valószínűleg egy sávszélességi vagy átkódolási korlát utasította el. Kérd meg a tulajdonost a korlát módosítására.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Naplók feltöltve", "logsUploaded": "Naplók feltöltve",
"logsUploadFailed": "Nem sikerült a naplók feltöltése", "logsUploadFailed": "Nem sikerült a naplók feltöltése",
"logId": "Naplóazonosító" "logId": "Naplóazonosító"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Passaggio al lettore compatibile...", "switchingToCompatiblePlayer": "Passaggio al lettore compatibile...",
"serverLimitTitle": "Riproduzione non riuscita", "serverLimitTitle": "Riproduzione non riuscita",
"serverLimitBody": "Errore del server (HTTP 500). È probabile che un limite di banda o transcodifica abbia impedito questa sessione. Chiedi al proprietario di modificare il limite.", "serverLimitBody": "Errore del server (HTTP 500). È probabile che un limite di banda o transcodifica abbia impedito questa sessione. Chiedi al proprietario di modificare il limite.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Log caricati", "logsUploaded": "Log caricati",
"logsUploadFailed": "Impossibile caricare i log", "logsUploadFailed": "Impossibile caricare i log",
"logId": "ID log" "logId": "ID log"
+2
View File
@@ -694,6 +694,8 @@
"switchingToCompatiblePlayer": "互換性のあるプレーヤーに切り替え中…", "switchingToCompatiblePlayer": "互換性のあるプレーヤーに切り替え中…",
"serverLimitTitle": "再生に失敗しました", "serverLimitTitle": "再生に失敗しました",
"serverLimitBody": "サーバーエラー(HTTP 500)。帯域幅/トランスコード制限により拒否された可能性があります。所有者に調整を依頼してください。", "serverLimitBody": "サーバーエラー(HTTP 500)。帯域幅/トランスコード制限により拒否された可能性があります。所有者に調整を依頼してください。",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "ログをアップロードしました", "logsUploaded": "ログをアップロードしました",
"logsUploadFailed": "ログのアップロードに失敗しました", "logsUploadFailed": "ログのアップロードに失敗しました",
"logId": "ログID" "logId": "ログID"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Үйлесімді ойнатқышқа ауысуда...", "switchingToCompatiblePlayer": "Үйлесімді ойнатқышқа ауысуда...",
"serverLimitTitle": "Ойнату қатесі", "serverLimitTitle": "Ойнату қатесі",
"serverLimitBody": "Сервер қатесі (HTTP 500). Шектеу бұл сеансты қабылдамады.", "serverLimitBody": "Сервер қатесі (HTTP 500). Шектеу бұл сеансты қабылдамады.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Журналдар жүктелді", "logsUploaded": "Журналдар жүктелді",
"logsUploadFailed": "Журналдарды жүктеу мүмкін болмады", "logsUploadFailed": "Журналдарды жүктеу мүмкін болмады",
"logId": "Журнал ID-сі" "logId": "Журнал ID-сі"
+2
View File
@@ -694,6 +694,8 @@
"switchingToCompatiblePlayer": "호환되는 플레이어로 전환 중...", "switchingToCompatiblePlayer": "호환되는 플레이어로 전환 중...",
"serverLimitTitle": "재생 실패", "serverLimitTitle": "재생 실패",
"serverLimitBody": "서버 오류(HTTP 500). 대역폭/트랜스코딩 제한으로 세션이 거부된 것 같습니다. 소유자에게 조정을 요청하세요.", "serverLimitBody": "서버 오류(HTTP 500). 대역폭/트랜스코딩 제한으로 세션이 거부된 것 같습니다. 소유자에게 조정을 요청하세요.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "로그 업로드 완료", "logsUploaded": "로그 업로드 완료",
"logsUploadFailed": "로그 업로드 실패", "logsUploadFailed": "로그 업로드 실패",
"logId": "로그 ID" "logId": "로그 ID"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Bytter til kompatibel spiller...", "switchingToCompatiblePlayer": "Bytter til kompatibel spiller...",
"serverLimitTitle": "Avspilling mislyktes", "serverLimitTitle": "Avspilling mislyktes",
"serverLimitBody": "Serverfeil (HTTP 500). En båndbredde-/transkodingsgrense avviste trolig økten. Be eieren justere den.", "serverLimitBody": "Serverfeil (HTTP 500). En båndbredde-/transkodingsgrense avviste trolig økten. Be eieren justere den.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Logger lastet opp", "logsUploaded": "Logger lastet opp",
"logsUploadFailed": "Kunne ikke laste opp logger", "logsUploadFailed": "Kunne ikke laste opp logger",
"logId": "Logg-ID" "logId": "Logg-ID"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Overschakelen naar compatibele speler...", "switchingToCompatiblePlayer": "Overschakelen naar compatibele speler...",
"serverLimitTitle": "Afspelen mislukt", "serverLimitTitle": "Afspelen mislukt",
"serverLimitBody": "Serverfout (HTTP 500). Waarschijnlijk weigerde een bandbreedte-/transcodeerlimiet deze sessie. Vraag de eigenaar dit aan te passen.", "serverLimitBody": "Serverfout (HTTP 500). Waarschijnlijk weigerde een bandbreedte-/transcodeerlimiet deze sessie. Vraag de eigenaar dit aan te passen.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Logbestanden geüpload", "logsUploaded": "Logbestanden geüpload",
"logsUploadFailed": "Uploaden van logbestanden mislukt", "logsUploadFailed": "Uploaden van logbestanden mislukt",
"logId": "Logboek-ID" "logId": "Logboek-ID"
+2
View File
@@ -697,6 +697,8 @@
"switchingToCompatiblePlayer": "Przełączanie na kompatybilny odtwarzacz...", "switchingToCompatiblePlayer": "Przełączanie na kompatybilny odtwarzacz...",
"serverLimitTitle": "Odtwarzanie nie powiodło się", "serverLimitTitle": "Odtwarzanie nie powiodło się",
"serverLimitBody": "Błąd serwera (HTTP 500). Limit przepustowości/transkodowania prawdopodobnie odrzucił tę sesję. Poproś właściciela o zmianę.", "serverLimitBody": "Błąd serwera (HTTP 500). Limit przepustowości/transkodowania prawdopodobnie odrzucił tę sesję. Poproś właściciela o zmianę.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Logi przesłane", "logsUploaded": "Logi przesłane",
"logsUploadFailed": "Nie udało się przesłać logów", "logsUploadFailed": "Nie udało się przesłać logów",
"logId": "ID logu" "logId": "ID logu"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Alternando para um reprodutor compatível...", "switchingToCompatiblePlayer": "Alternando para um reprodutor compatível...",
"serverLimitTitle": "Falha na reprodução", "serverLimitTitle": "Falha na reprodução",
"serverLimitBody": "Erro do servidor (HTTP 500). Um limite de largura de banda ou transcodificação provavelmente rejeitou esta sessão. Peça ao proprietário do servidor para ajustá-lo.", "serverLimitBody": "Erro do servidor (HTTP 500). Um limite de largura de banda ou transcodificação provavelmente rejeitou esta sessão. Peça ao proprietário do servidor para ajustá-lo.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Logs enviados", "logsUploaded": "Logs enviados",
"logsUploadFailed": "Falha ao enviar logs", "logsUploadFailed": "Falha ao enviar logs",
"logId": "ID do log" "logId": "ID do log"
+2
View File
@@ -697,6 +697,8 @@
"switchingToCompatiblePlayer": "Переключение на совместимый плеер...", "switchingToCompatiblePlayer": "Переключение на совместимый плеер...",
"serverLimitTitle": "Ошибка воспроизведения", "serverLimitTitle": "Ошибка воспроизведения",
"serverLimitBody": "Ошибка сервера (HTTP 500). Лимит пропускной способности/транскодирования, вероятно, отклонил сессию. Попросите владельца изменить настройки.", "serverLimitBody": "Ошибка сервера (HTTP 500). Лимит пропускной способности/транскодирования, вероятно, отклонил сессию. Попросите владельца изменить настройки.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Логи загружены", "logsUploaded": "Логи загружены",
"logsUploadFailed": "Не удалось загрузить логи", "logsUploadFailed": "Не удалось загрузить логи",
"logId": "ID лога" "logId": "ID лога"
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang` /// To regenerate, run: `dart run slang`
/// ///
/// Locales: 22 /// Locales: 22
/// Strings: 32938 (1497 per locale) /// Strings: 32940 (1497 per locale)
// coverage:ignore-file // coverage:ignore-file
// ignore_for_file: type=lint, unused_import // ignore_for_file: type=lint, unused_import
+12 -4
View File
@@ -2117,6 +2117,12 @@ class Translations$messages$en {
/// en: 'Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.' /// en: 'Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.'
String get serverLimitBody => 'Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.'; String get serverLimitBody => 'Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.';
/// en: 'File unavailable'
String get mediaUnreadableTitle => 'File unavailable';
/// en: 'The server found this item but could not read its file (HTTP 404). The file was probably moved, deleted, or its storage is offline. Ask the server owner to check the file and rescan the library.'
String get mediaUnreadableBody => 'The server found this item but could not read its file (HTTP 404). The file was probably moved, deleted, or its storage is offline. Ask the server owner to check the file and rescan the library.';
/// en: 'Logs uploaded' /// en: 'Logs uploaded'
String get logsUploaded => 'Logs uploaded'; String get logsUploaded => 'Logs uploaded';
@@ -6669,6 +6675,8 @@ extension on Translations {
'messages.switchingToCompatiblePlayer' => 'Switching to compatible player...', 'messages.switchingToCompatiblePlayer' => 'Switching to compatible player...',
'messages.serverLimitTitle' => 'Playback failed', 'messages.serverLimitTitle' => 'Playback failed',
'messages.serverLimitBody' => 'Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.', 'messages.serverLimitBody' => 'Server error (HTTP 500). A bandwidth/transcoding limit likely rejected this session. Ask the owner to adjust it.',
'messages.mediaUnreadableTitle' => 'File unavailable',
'messages.mediaUnreadableBody' => 'The server found this item but could not read its file (HTTP 404). The file was probably moved, deleted, or its storage is offline. Ask the server owner to check the file and rescan the library.',
'messages.logsUploaded' => 'Logs uploaded', 'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs', 'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID', 'messages.logId' => 'Log ID',
@@ -7034,10 +7042,10 @@ extension on Translations {
'explore.sourceMaterial.novel' => 'Novel', 'explore.sourceMaterial.novel' => 'Novel',
'explore.sourceMaterial.visualNovel' => 'Visual novel', 'explore.sourceMaterial.visualNovel' => 'Visual novel',
'explore.sourceMaterial.game' => 'Game', 'explore.sourceMaterial.game' => 'Game',
'explore.sourceMaterial.webComic' => 'Web comic',
'explore.sourceMaterial.musicRelease' => 'Music',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'explore.sourceMaterial.webComic' => 'Web comic',
'explore.sourceMaterial.musicRelease' => 'Music',
'explore.sourceMaterial.otherMedia' => 'Other', 'explore.sourceMaterial.otherMedia' => 'Other',
'explore.creditRole.director' => 'Director', 'explore.creditRole.director' => 'Director',
'explore.creditRole.writer' => 'Writer', 'explore.creditRole.writer' => 'Writer',
@@ -7548,10 +7556,10 @@ extension on Translations {
'metadataEdit.screenTitle' => 'Edit Metadata', 'metadataEdit.screenTitle' => 'Edit Metadata',
'metadataEdit.basicInfo' => 'Basic Info', 'metadataEdit.basicInfo' => 'Basic Info',
'metadataEdit.artwork' => 'Artwork', 'metadataEdit.artwork' => 'Artwork',
'metadataEdit.advancedSettings' => 'Advanced Settings',
'metadataEdit.title' => 'Title',
_ => null, _ => null,
} ?? switch (path) { } ?? switch (path) {
'metadataEdit.advancedSettings' => 'Advanced Settings',
'metadataEdit.title' => 'Title',
'metadataEdit.sortTitle' => 'Sort Title', 'metadataEdit.sortTitle' => 'Sort Title',
'metadataEdit.originalTitle' => 'Original Title', 'metadataEdit.originalTitle' => 'Original Title',
'metadataEdit.releaseDate' => 'Release Date', 'metadataEdit.releaseDate' => 'Release Date',
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Byter till kompatibel spelare...", "switchingToCompatiblePlayer": "Byter till kompatibel spelare...",
"serverLimitTitle": "Uppspelningen misslyckades", "serverLimitTitle": "Uppspelningen misslyckades",
"serverLimitBody": "Serverfel (HTTP 500). En bandbredds-/transkodningsgräns avvisade troligen sessionen. Be ägaren justera den.", "serverLimitBody": "Serverfel (HTTP 500). En bandbredds-/transkodningsgräns avvisade troligen sessionen. Be ägaren justera den.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Loggarna har laddats upp", "logsUploaded": "Loggarna har laddats upp",
"logsUploadFailed": "Det gick inte att ladda upp loggarna", "logsUploadFailed": "Det gick inte att ladda upp loggarna",
"logId": "Logg-ID" "logId": "Logg-ID"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Uyumlu oynatıcıya geçiliyor...", "switchingToCompatiblePlayer": "Uyumlu oynatıcıya geçiliyor...",
"serverLimitTitle": "Oynatma başarısız oldu", "serverLimitTitle": "Oynatma başarısız oldu",
"serverLimitBody": "Sunucu hatası (HTTP 500). Muhtemelen bir bant genişliği/kod dönüştürme sınırı bu oturumu reddetti. Sunucu sahibinden bunu ayarlamasını isteyin.", "serverLimitBody": "Sunucu hatası (HTTP 500). Muhtemelen bir bant genişliği/kod dönüştürme sınırı bu oturumu reddetti. Sunucu sahibinden bunu ayarlamasını isteyin.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Günlükler yüklendi", "logsUploaded": "Günlükler yüklendi",
"logsUploadFailed": "Günlükler yüklenemedi", "logsUploadFailed": "Günlükler yüklenemedi",
"logId": "Günlük Kimliği (ID)" "logId": "Günlük Kimliği (ID)"
+2
View File
@@ -695,6 +695,8 @@
"switchingToCompatiblePlayer": "Mos keluvchi pleyerga oʻtilmoqda...", "switchingToCompatiblePlayer": "Mos keluvchi pleyerga oʻtilmoqda...",
"serverLimitTitle": "Ijro etishda xatolik", "serverLimitTitle": "Ijro etishda xatolik",
"serverLimitBody": "Server xatoligi (HTTP 500). Cheklov ushbu seansni rad etdi.", "serverLimitBody": "Server xatoligi (HTTP 500). Cheklov ushbu seansni rad etdi.",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "Jurnallar yuklandi", "logsUploaded": "Jurnallar yuklandi",
"logsUploadFailed": "Jurnallarni yuklab boʻlmadi", "logsUploadFailed": "Jurnallarni yuklab boʻlmadi",
"logId": "Jurnal ID-si" "logId": "Jurnal ID-si"
+2
View File
@@ -694,6 +694,8 @@
"switchingToCompatiblePlayer": "正在切換至相容的播放器…", "switchingToCompatiblePlayer": "正在切換至相容的播放器…",
"serverLimitTitle": "播放失敗", "serverLimitTitle": "播放失敗",
"serverLimitBody": "伺服器錯誤(HTTP 500)。伺服器的頻寬或轉碼限制可能拒絕此播放要求。請聯絡伺服器擁有者調整設定。", "serverLimitBody": "伺服器錯誤(HTTP 500)。伺服器的頻寬或轉碼限制可能拒絕此播放要求。請聯絡伺服器擁有者調整設定。",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "日誌已上傳", "logsUploaded": "日誌已上傳",
"logsUploadFailed": "上傳日誌失敗", "logsUploadFailed": "上傳日誌失敗",
"logId": "日誌 ID" "logId": "日誌 ID"
+2
View File
@@ -694,6 +694,8 @@
"switchingToCompatiblePlayer": "正在切换到兼容的播放器…", "switchingToCompatiblePlayer": "正在切换到兼容的播放器…",
"serverLimitTitle": "播放失败", "serverLimitTitle": "播放失败",
"serverLimitBody": "服务器错误(HTTP 500)。此次会话可能因带宽或转码限制而被拒绝。请联系服务器所有者调整限制。", "serverLimitBody": "服务器错误(HTTP 500)。此次会话可能因带宽或转码限制而被拒绝。请联系服务器所有者调整限制。",
"mediaUnreadableTitle": "",
"mediaUnreadableBody": "",
"logsUploaded": "日志已上传", "logsUploaded": "日志已上传",
"logsUploadFailed": "上传日志失败", "logsUploadFailed": "上传日志失败",
"logId": "日志 ID" "logId": "日志 ID"
+24
View File
@@ -8,6 +8,13 @@ sealed class BufferRange with _$BufferRange {
const factory BufferRange({required Duration start, required Duration end}) = _BufferRange; const factory BufferRange({required Duration start, required Duration end}) = _BufferRange;
} }
final RegExp _httpStatusPattern = RegExp(r'\b(?:HTTP error |Response code: )(\d{3})\b');
/// Server statuses that end playback outright: nothing client-side recovers a
/// transcoding-limit rejection or a file the server cannot read. Everything
/// else (notably the 503 the reconnect path retries) is transient.
const Set<int> fatalPlaybackHttpStatuses = {404, 500};
/// [cause] is an optional machine-readable tag (e.g. `server-http-500`), /// [cause] is an optional machine-readable tag (e.g. `server-http-500`),
/// letting the UI branch without parsing [message]. /// letting the UI branch without parsing [message].
@Freezed(toStringOverride: false) @Freezed(toStringOverride: false)
@@ -20,6 +27,23 @@ sealed class PlayerError with _$PlayerError {
/// transcoding limit rejection set by the server owner. /// transcoding limit rejection set by the server owner.
static const String serverHttp500 = 'server-http-500'; static const String serverHttp500 = 'server-http-500';
/// Cause tag for a server-side HTTP 404 on the media stream. The server
/// resolved the item but cannot read the file behind it (moved, deleted, or
/// on unavailable storage), so no retry or backend switch can recover it.
static const String serverHttp404 = 'server-http-404';
/// HTTP status [logText] reports, or null when it names none.
///
/// A [PlayerError] carries no status field: mpv only ever tells us the
/// end-file reason. ffmpeg does log the status, one warn-level line ahead of
/// the error-level failure (`http: HTTP error 404 Not Found`), and media3's
/// exception chain stringifies it as `Response code: 404`, so scanning the
/// player's own log stream is the only way to recover it.
static int? httpStatusFromLog(String logText) {
final match = _httpStatusPattern.firstMatch(logText);
return match == null ? null : int.tryParse(match.group(1)!);
}
@override @override
String toString() => message; String toString() => message;
} }
+13 -10
View File
@@ -929,19 +929,22 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
} }
/// Injects the log + error events that would fire when the server rejects the /// Injects the log + error events that would fire when the server rejects the
/// stream with HTTP 500 (shared-user bandwidth / transcoding limit). Used by /// stream with [status]. Used by the in-player debug buttons to preview the
/// the in-player debug button to preview the end-to-end detection path /// end-to-end detection path without needing a real misbehaving server: 500
/// without needing a real misbehaving server. /// is a shared-user bandwidth/transcoding limit, 404 a file the server can no
void debugSimulateServer500() { /// longer read. The warn-level log mirrors ffmpeg's real wording, which is
/// what [PlayerError.httpStatusFromLog] parses.
void debugSimulateServerHttpError(int status) {
if (_disposed) return; if (_disposed) return;
logController.add( logController.add(
const PlayerLog( PlayerLog(level: PlayerLogLevel.warn, prefix: 'ffmpeg', text: 'https: HTTP error $status Simulated'),
level: PlayerLogLevel.warn,
prefix: 'ffmpeg',
text: 'https: HTTP error 500 Internal Server Error',
),
); );
errorController.add(const PlayerError('HTTP 500', cause: PlayerError.serverHttp500)); final cause = switch (status) {
500 => PlayerError.serverHttp500,
404 => PlayerError.serverHttp404,
_ => null,
};
errorController.add(PlayerError('HTTP $status', cause: cause));
} }
Future<bool> _waitForNativeOwnershipForDispose() async { Future<bool> _waitForNativeOwnershipForDispose() async {
+23 -4
View File
@@ -15,13 +15,27 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
if (!mounted || _isExiting.value) return; if (!mounted || _isExiting.value) return;
// Fatal, unrecoverable until server-side fix — show modal instead of a snackbar. // Fatal, unrecoverable until server-side fix — show modal instead of a snackbar.
if (err.cause == PlayerError.serverHttp500 || _sawServer500) { //
// A sidecar subtitle fetch can also log a status, but it never raises the
// end-file error this handler is wired to, so the status observed here
// belongs to the primary media open.
if (err.cause == PlayerError.serverHttp500 || _fatalHttpStatuses.contains(500)) {
_hasFatalPlaybackError = true; _hasFatalPlaybackError = true;
_progressTracker?.stopTracking(); _progressTracker?.stopTracking();
unawaited(_showServerLimitDialog()); unawaited(_showServerLimitDialog());
return; return;
} }
// The server resolved the item but could not read the file behind it. No
// retry, quality change, or backend switch recovers that, and the raw mpv
// line ("Failed to open <redacted url>") tells the user nothing actionable.
if (err.cause == PlayerError.serverHttp404 || _fatalHttpStatuses.contains(404)) {
_hasFatalPlaybackError = true;
_progressTracker?.stopTracking();
unawaited(_showMediaUnreadableDialog());
return;
}
// Live TV: retry with progressively degraded stream settings // Live TV: retry with progressively degraded stream settings
// (mirrors Plex web client fallback chain). // (mirrors Plex web client fallback chain).
if (widget.isLive) { if (widget.isLive) {
@@ -48,9 +62,8 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
} }
void _onPlayerLog(PlayerLog log) { void _onPlayerLog(PlayerLog log) {
if (!_sawServer500 && VideoPlayerScreenState._server500Pattern.hasMatch(log.text)) { final status = PlayerError.httpStatusFromLog(log.text);
_sawServer500 = true; if (status != null && fatalPlaybackHttpStatuses.contains(status)) _fatalHttpStatuses.add(status);
}
if (log.level == PlayerLogLevel.error || log.level == PlayerLogLevel.fatal) { if (log.level == PlayerLogLevel.error || log.level == PlayerLogLevel.fatal) {
appLogger.e('[Player LOG ERROR] [${log.prefix}] ${log.text}'); appLogger.e('[Player LOG ERROR] [${log.prefix}] ${log.text}');
_lastLogError = _redactPlayerError(log.text.trim()); _lastLogError = _redactPlayerError(log.text.trim());
@@ -65,6 +78,12 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
if (mounted) unawaited(_handleBackButton()); if (mounted) unawaited(_handleBackButton());
} }
Future<void> _showMediaUnreadableDialog() async {
if (!mounted) return;
await showMediaUnreadableDialog(context);
if (mounted) unawaited(_handleBackButton());
}
/// Handle notification when native player switched from ExoPlayer to MPV /// Handle notification when native player switched from ExoPlayer to MPV
Future<void> _onBackendSwitched() async { Future<void> _onBackendSwitched() async {
_playerBackendLabel = 'mpv'; _playerBackendLabel = 'mpv';
@@ -532,7 +532,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
// that gets an HTTP error and the truncated body surfaces as a clean // that gets an HTTP error and the truncated body surfaces as a clean
// mid-file EOF (#1520 — PMS answers 503 while restarting/maintenance). // mid-file EOF (#1520 — PMS answers 503 while restarting/maintenance).
// Deliberately 503 only: a persistent 500 must keep failing fast so the // Deliberately 503 only: a persistent 500 must keep failing fast so the
// server-limit dialog (_server500Pattern) appears promptly, and a // server-limit dialog (_httpStatusPattern) appears promptly, and a
// multi-code list would need mpv's %len% quoting to survive the // multi-code list would need mpv's %len% quoting to survive the
// comma-separated option string. While ffmpeg retries, mpv reports // comma-separated option string. While ffmpeg retries, mpv reports
// buffering, which also makes the server-online reconnect hook in // buffering, which also makes the server-online reconnect hook in
@@ -105,7 +105,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
_errorSubscription = currentPlayer.streams.error.listen(_onPlayerError); _errorSubscription = currentPlayer.streams.error.listen(_onPlayerError);
// warn is included so we can catch ffmpeg's "HTTP error 500" line in // warn is included so we can catch ffmpeg's "HTTP error 4xx/5xx" line in
// _onPlayerLog — the error-level log that follows omits the status code. // _onPlayerLog — the error-level log that follows omits the status code.
_logSubscription = currentPlayer.streams.log _logSubscription = currentPlayer.streams.log
.where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level)) .where((log) => const {PlayerLogLevel.fatal, PlayerLogLevel.error, PlayerLogLevel.warn}.contains(log.level))
@@ -147,7 +147,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
_playbackRestartSubscription = currentPlayer.streams.playbackRestart.listen((_) async { _playbackRestartSubscription = currentPlayer.streams.playbackRestart.listen((_) async {
if (!mounted || player != currentPlayer) return; if (!mounted || player != currentPlayer) return;
_lastLogError = null; _lastLogError = null;
_sawServer500 = false; _fatalHttpStatuses.clear();
_live.fallbackLevel = 0; _live.fallbackLevel = 0;
_live.retryFailed = false; _live.retryFailed = false;
final markFirstFrameReady = _markFirstFrameReady(currentPlayer, settingsService); final markFirstFrameReady = _markFirstFrameReady(currentPlayer, settingsService);
+5 -2
View File
@@ -1772,9 +1772,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
} }
String? _lastLogError; String? _lastLogError;
bool _sawServer500 = false;
static final RegExp _server500Pattern = RegExp(r'\b(?:HTTP error |Response code: )500\b'); /// Statuses in [fatalPlaybackHttpStatuses] the player's own log stream
/// reported for this open. Each latches independently: the reconnect path
/// deliberately retries a 503 (see `_applyNetworkStreamTuning`), and a
/// transient status must never mask the fatal one that follows.
final Set<int> _fatalHttpStatuses = <int>{};
// OS Media Controls Integration // OS Media Controls Integration
+22
View File
@@ -104,6 +104,28 @@ Future<void> showServerLimitDialog(BuildContext context) async {
); );
} }
/// Shows the server-side 404 modal: the item exists but the server cannot read
/// the file behind it, so nothing client-side can recover the playback.
Future<void> showMediaUnreadableDialog(BuildContext context) async {
await showScopedDialog<void>(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
title: Text(t.messages.mediaUnreadableTitle),
content: Text(t.messages.mediaUnreadableBody),
actions: [
DialogActionButton(
autofocus: true,
onPressed: () => Navigator.of(ctx).pop(),
label: t.common.close,
isPrimary: true,
style: FilledButton.styleFrom(padding: _buttonPadding, shape: _buttonShape),
),
],
),
);
}
/// Shows a delete confirmation dialog. /// Shows a delete confirmation dialog.
/// Convenience wrapper around [showConfirmDialog] with destructive styling. /// Convenience wrapper around [showConfirmDialog] with destructive styling.
Future<bool> showDeleteConfirmation( Future<bool> showDeleteConfirmation(
@@ -730,14 +730,15 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
), ),
if (kDebugMode) if (kDebugMode)
for (final status in const [500, 404])
FocusableListTile( FocusableListTile(
leading: AppIcon(Symbols.bug_report_rounded, fill: 1, color: tokens(context).textMuted), leading: AppIcon(Symbols.bug_report_rounded, fill: 1, color: tokens(context).textMuted),
title: const Text('Simulate HTTP 500 from server'), title: Text('Simulate HTTP $status from server'),
onTap: () { onTap: () {
final player = widget.player; final player = widget.player;
OverlaySheetController.of(context).close(); OverlaySheetController.of(context).close();
if (player is PlayerBase) { if (player is PlayerBase) {
player.debugSimulateServer500(); player.debugSimulateServerHttpError(status);
} }
}, },
), ),
@@ -0,0 +1,56 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/mpv/models.dart';
void main() {
group('PlayerError.httpStatusFromLog', () {
test('reads the status out of the ffmpeg warn line that precedes a failed open', () {
// Verbatim from the #1750 report: the only line in the whole failure that
// names the status. The error-level "Failed to open ..." that follows
// omits it.
expect(PlayerError.httpStatusFromLog('http: HTTP error 404 Not Found'), 404);
expect(PlayerError.httpStatusFromLog('http: HTTP error 500 Internal Server Error'), 500);
expect(PlayerError.httpStatusFromLog('http: HTTP error 503 Service Unavailable'), 503);
});
test('reads the status media3 stringifies into its exception chain', () {
expect(
PlayerError.httpStatusFromLog(
'androidx.media3.datasource.HttpDataSource\$InvalidResponseCodeException: Response code: 404',
),
404,
);
expect(PlayerError.httpStatusFromLog('Response code: 500'), 500);
});
test('returns null for player logs that name no status', () {
expect(PlayerError.httpStatusFromLog('Failed to open https://jf.example.com/Videos/item-1/stream.'), isNull);
expect(PlayerError.httpStatusFromLog('loading failed'), isNull);
expect(PlayerError.httpStatusFromLog(''), isNull);
});
test('does not mistake an unrelated number for a status', () {
// A bare code with no HTTP context must not reach the fatal dialogs.
expect(PlayerError.httpStatusFromLog('Set property: stream-buffer-size="404"'), isNull);
expect(PlayerError.httpStatusFromLog('audio/aac 500 kbps'), isNull);
// Adjacent digits are not a 3-digit status.
expect(PlayerError.httpStatusFromLog('http: HTTP error 4040 Nope'), isNull);
});
test('reports the first status when a line names several', () {
expect(PlayerError.httpStatusFromLog('HTTP error 404 after HTTP error 500'), 404);
});
});
group('fatalPlaybackHttpStatuses', () {
test('covers exactly the statuses no client-side retry can recover', () {
expect(fatalPlaybackHttpStatuses, {404, 500});
});
test('excludes the 503 the reconnect path deliberately retries', () {
// stream-lavf-o sets reconnect_on_http_error=503, so a 503 is expected
// mid-playback and must not latch as fatal.
expect(fatalPlaybackHttpStatuses.contains(503), isFalse);
expect(PlayerError.httpStatusFromLog('http: HTTP error 503 Service Unavailable'), 503);
});
});
}
+39
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/i18n/strings.g.dart';
import 'package:plezy/utils/dialogs.dart'; import 'package:plezy/utils/dialogs.dart';
import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/utils/platform_detector.dart';
@@ -111,6 +112,44 @@ void main() {
await expectLater(result, completion(isNull)); await expectLater(result, completion(isNull));
expect(find.byType(AlertDialog), findsNothing); expect(find.byType(AlertDialog), findsNothing);
}); });
testWidgets('media-unreadable dialog names the server-side cause and cannot be dismissed by the barrier', (
tester,
) async {
final hostContext = await _pumpHost(tester);
final result = showMediaUnreadableDialog(hostContext);
await tester.pumpAndSettle();
expect(find.text(t.messages.mediaUnreadableTitle), findsOneWidget);
// The body has to say what a 404 on the stream actually means, because the
// only recovery is on the server (#1750).
expect(find.textContaining('HTTP 404'), findsOneWidget);
expect(find.textContaining('could not read'), findsOneWidget);
// Barrier taps must not strand the caller's future.
await tester.tapAt(const Offset(10, 10));
await tester.pumpAndSettle();
expect(find.byType(AlertDialog), findsOneWidget);
await tester.tap(find.text(t.common.close));
await tester.pumpAndSettle();
await expectLater(result, completes);
expect(find.byType(AlertDialog), findsNothing);
});
testWidgets('server-limit dialog stays distinct from the media-unreadable one', (tester) async {
final hostContext = await _pumpHost(tester);
final result = showServerLimitDialog(hostContext);
await tester.pumpAndSettle();
expect(find.text(t.messages.serverLimitTitle), findsOneWidget);
expect(find.textContaining('HTTP 500'), findsOneWidget);
expect(find.text(t.messages.mediaUnreadableTitle), findsNothing);
await tester.tap(find.text(t.common.close));
await tester.pumpAndSettle();
await expectLater(result, completes);
});
} }
Future<BuildContext> _pumpHost(WidgetTester tester) async { Future<BuildContext> _pumpHost(WidgetTester tester) async {