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:
@@ -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
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Uyğun oynadıcıya keçilir...",
|
||||
"serverLimitTitle": "Oynatma uğursuz oldu",
|
||||
"serverLimitBody": "Server xətası (HTTP 500). Məhdudiyyət bu seansı rədd etdi.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Jurnallar yükləndi",
|
||||
"logsUploadFailed": "Jurnallar yüklənə bilmədi",
|
||||
"logId": "Jurnal ID-si"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Превключване към съвместим плейър...",
|
||||
"serverLimitTitle": "Възпроизвеждането е неуспешно",
|
||||
"serverLimitBody": "Грешка на сървъра (HTTP 500). Вероятно лимит за пропускателна способност/транскодиране е отхвърлил тази сесия. Помолете собственика да го коригира.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Логовете са качени",
|
||||
"logsUploadFailed": "Неуспешно качване на логовете",
|
||||
"logId": "ID на лога"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Skifter til kompatibel afspiller...",
|
||||
"serverLimitTitle": "Afspilning mislykkedes",
|
||||
"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",
|
||||
"logsUploadFailed": "Logfilerne kunne ikke uploades",
|
||||
"logId": "Log-ID"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Wechsel zu einem kompatiblen Player …",
|
||||
"serverLimitTitle": "Wiedergabe fehlgeschlagen",
|
||||
"serverLimitBody": "Serverfehler (HTTP 500). Vermutlich hat ein Bandbreiten- oder Transkodierungslimit diese Sitzung abgelehnt. Bitte den Besitzer, das Limit anzupassen.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Protokolle hochgeladen",
|
||||
"logsUploadFailed": "Protokolle konnten nicht hochgeladen werden",
|
||||
"logId": "Protokoll-ID"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Switching to compatible player...",
|
||||
"serverLimitTitle": "Playback failed",
|
||||
"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",
|
||||
"logsUploadFailed": "Failed to upload logs",
|
||||
"logId": "Log ID"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Cambiando a reproductor compatible...",
|
||||
"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.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Registros subidos",
|
||||
"logsUploadFailed": "Error al subir registros",
|
||||
"logId": "ID de registro"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Passage au lecteur compatible...",
|
||||
"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.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Journaux envoyés",
|
||||
"logsUploadFailed": "Échec de l’envoi des journaux",
|
||||
"logId": "Identifiant du journal"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Váltás kompatibilis lejátszóra...",
|
||||
"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.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Naplók feltöltve",
|
||||
"logsUploadFailed": "Nem sikerült a naplók feltöltése",
|
||||
"logId": "Naplóazonosító"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Passaggio al lettore compatibile...",
|
||||
"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.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Log caricati",
|
||||
"logsUploadFailed": "Impossibile caricare i log",
|
||||
"logId": "ID log"
|
||||
|
||||
@@ -694,6 +694,8 @@
|
||||
"switchingToCompatiblePlayer": "互換性のあるプレーヤーに切り替え中…",
|
||||
"serverLimitTitle": "再生に失敗しました",
|
||||
"serverLimitBody": "サーバーエラー(HTTP 500)。帯域幅/トランスコード制限により拒否された可能性があります。所有者に調整を依頼してください。",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "ログをアップロードしました",
|
||||
"logsUploadFailed": "ログのアップロードに失敗しました",
|
||||
"logId": "ログID"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Үйлесімді ойнатқышқа ауысуда...",
|
||||
"serverLimitTitle": "Ойнату қатесі",
|
||||
"serverLimitBody": "Сервер қатесі (HTTP 500). Шектеу бұл сеансты қабылдамады.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Журналдар жүктелді",
|
||||
"logsUploadFailed": "Журналдарды жүктеу мүмкін болмады",
|
||||
"logId": "Журнал ID-сі"
|
||||
|
||||
@@ -694,6 +694,8 @@
|
||||
"switchingToCompatiblePlayer": "호환되는 플레이어로 전환 중...",
|
||||
"serverLimitTitle": "재생 실패",
|
||||
"serverLimitBody": "서버 오류(HTTP 500). 대역폭/트랜스코딩 제한으로 세션이 거부된 것 같습니다. 소유자에게 조정을 요청하세요.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "로그 업로드 완료",
|
||||
"logsUploadFailed": "로그 업로드 실패",
|
||||
"logId": "로그 ID"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Bytter til kompatibel spiller...",
|
||||
"serverLimitTitle": "Avspilling mislyktes",
|
||||
"serverLimitBody": "Serverfeil (HTTP 500). En båndbredde-/transkodingsgrense avviste trolig økten. Be eieren justere den.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Logger lastet opp",
|
||||
"logsUploadFailed": "Kunne ikke laste opp logger",
|
||||
"logId": "Logg-ID"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Overschakelen naar compatibele speler...",
|
||||
"serverLimitTitle": "Afspelen mislukt",
|
||||
"serverLimitBody": "Serverfout (HTTP 500). Waarschijnlijk weigerde een bandbreedte-/transcodeerlimiet deze sessie. Vraag de eigenaar dit aan te passen.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Logbestanden geüpload",
|
||||
"logsUploadFailed": "Uploaden van logbestanden mislukt",
|
||||
"logId": "Logboek-ID"
|
||||
|
||||
@@ -697,6 +697,8 @@
|
||||
"switchingToCompatiblePlayer": "Przełączanie na kompatybilny odtwarzacz...",
|
||||
"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ę.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Logi przesłane",
|
||||
"logsUploadFailed": "Nie udało się przesłać logów",
|
||||
"logId": "ID logu"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Alternando para um reprodutor compatível...",
|
||||
"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.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Logs enviados",
|
||||
"logsUploadFailed": "Falha ao enviar logs",
|
||||
"logId": "ID do log"
|
||||
|
||||
@@ -697,6 +697,8 @@
|
||||
"switchingToCompatiblePlayer": "Переключение на совместимый плеер...",
|
||||
"serverLimitTitle": "Ошибка воспроизведения",
|
||||
"serverLimitBody": "Ошибка сервера (HTTP 500). Лимит пропускной способности/транскодирования, вероятно, отклонил сессию. Попросите владельца изменить настройки.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Логи загружены",
|
||||
"logsUploadFailed": "Не удалось загрузить логи",
|
||||
"logId": "ID лога"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
/// To regenerate, run: `dart run slang`
|
||||
///
|
||||
/// Locales: 22
|
||||
/// Strings: 32938 (1497 per locale)
|
||||
/// Strings: 32940 (1497 per locale)
|
||||
|
||||
// coverage:ignore-file
|
||||
// ignore_for_file: type=lint, unused_import
|
||||
|
||||
@@ -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.'
|
||||
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'
|
||||
String get logsUploaded => 'Logs uploaded';
|
||||
|
||||
@@ -6669,6 +6675,8 @@ extension on Translations {
|
||||
'messages.switchingToCompatiblePlayer' => 'Switching to compatible player...',
|
||||
'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.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.logsUploadFailed' => 'Failed to upload logs',
|
||||
'messages.logId' => 'Log ID',
|
||||
@@ -7034,10 +7042,10 @@ extension on Translations {
|
||||
'explore.sourceMaterial.novel' => 'Novel',
|
||||
'explore.sourceMaterial.visualNovel' => 'Visual novel',
|
||||
'explore.sourceMaterial.game' => 'Game',
|
||||
'explore.sourceMaterial.webComic' => 'Web comic',
|
||||
'explore.sourceMaterial.musicRelease' => 'Music',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'explore.sourceMaterial.webComic' => 'Web comic',
|
||||
'explore.sourceMaterial.musicRelease' => 'Music',
|
||||
'explore.sourceMaterial.otherMedia' => 'Other',
|
||||
'explore.creditRole.director' => 'Director',
|
||||
'explore.creditRole.writer' => 'Writer',
|
||||
@@ -7548,10 +7556,10 @@ extension on Translations {
|
||||
'metadataEdit.screenTitle' => 'Edit Metadata',
|
||||
'metadataEdit.basicInfo' => 'Basic Info',
|
||||
'metadataEdit.artwork' => 'Artwork',
|
||||
'metadataEdit.advancedSettings' => 'Advanced Settings',
|
||||
'metadataEdit.title' => 'Title',
|
||||
_ => null,
|
||||
} ?? switch (path) {
|
||||
'metadataEdit.advancedSettings' => 'Advanced Settings',
|
||||
'metadataEdit.title' => 'Title',
|
||||
'metadataEdit.sortTitle' => 'Sort Title',
|
||||
'metadataEdit.originalTitle' => 'Original Title',
|
||||
'metadataEdit.releaseDate' => 'Release Date',
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Byter till kompatibel spelare...",
|
||||
"serverLimitTitle": "Uppspelningen misslyckades",
|
||||
"serverLimitBody": "Serverfel (HTTP 500). En bandbredds-/transkodningsgräns avvisade troligen sessionen. Be ägaren justera den.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Loggarna har laddats upp",
|
||||
"logsUploadFailed": "Det gick inte att ladda upp loggarna",
|
||||
"logId": "Logg-ID"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Uyumlu oynatıcıya geçiliyor...",
|
||||
"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.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Günlükler yüklendi",
|
||||
"logsUploadFailed": "Günlükler yüklenemedi",
|
||||
"logId": "Günlük Kimliği (ID)"
|
||||
|
||||
@@ -695,6 +695,8 @@
|
||||
"switchingToCompatiblePlayer": "Mos keluvchi pleyerga oʻtilmoqda...",
|
||||
"serverLimitTitle": "Ijro etishda xatolik",
|
||||
"serverLimitBody": "Server xatoligi (HTTP 500). Cheklov ushbu seansni rad etdi.",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "Jurnallar yuklandi",
|
||||
"logsUploadFailed": "Jurnallarni yuklab boʻlmadi",
|
||||
"logId": "Jurnal ID-si"
|
||||
|
||||
@@ -694,6 +694,8 @@
|
||||
"switchingToCompatiblePlayer": "正在切換至相容的播放器…",
|
||||
"serverLimitTitle": "播放失敗",
|
||||
"serverLimitBody": "伺服器錯誤(HTTP 500)。伺服器的頻寬或轉碼限制可能拒絕此播放要求。請聯絡伺服器擁有者調整設定。",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "日誌已上傳",
|
||||
"logsUploadFailed": "上傳日誌失敗",
|
||||
"logId": "日誌 ID"
|
||||
|
||||
@@ -694,6 +694,8 @@
|
||||
"switchingToCompatiblePlayer": "正在切换到兼容的播放器…",
|
||||
"serverLimitTitle": "播放失败",
|
||||
"serverLimitBody": "服务器错误(HTTP 500)。此次会话可能因带宽或转码限制而被拒绝。请联系服务器所有者调整限制。",
|
||||
"mediaUnreadableTitle": "",
|
||||
"mediaUnreadableBody": "",
|
||||
"logsUploaded": "日志已上传",
|
||||
"logsUploadFailed": "上传日志失败",
|
||||
"logId": "日志 ID"
|
||||
|
||||
@@ -8,6 +8,13 @@ sealed class BufferRange with _$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`),
|
||||
/// letting the UI branch without parsing [message].
|
||||
@Freezed(toStringOverride: false)
|
||||
@@ -20,6 +27,23 @@ sealed class PlayerError with _$PlayerError {
|
||||
/// transcoding limit rejection set by the server owner.
|
||||
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
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
@@ -929,19 +929,22 @@ abstract class PlayerBase with PlayerStreamControllersMixin implements Player {
|
||||
}
|
||||
|
||||
/// Injects the log + error events that would fire when the server rejects the
|
||||
/// stream with HTTP 500 (shared-user bandwidth / transcoding limit). Used by
|
||||
/// the in-player debug button to preview the end-to-end detection path
|
||||
/// without needing a real misbehaving server.
|
||||
void debugSimulateServer500() {
|
||||
/// stream with [status]. Used by the in-player debug buttons to preview the
|
||||
/// end-to-end detection path without needing a real misbehaving server: 500
|
||||
/// is a shared-user bandwidth/transcoding limit, 404 a file the server can no
|
||||
/// longer read. The warn-level log mirrors ffmpeg's real wording, which is
|
||||
/// what [PlayerError.httpStatusFromLog] parses.
|
||||
void debugSimulateServerHttpError(int status) {
|
||||
if (_disposed) return;
|
||||
logController.add(
|
||||
const PlayerLog(
|
||||
level: PlayerLogLevel.warn,
|
||||
prefix: 'ffmpeg',
|
||||
text: 'https: HTTP error 500 Internal Server Error',
|
||||
),
|
||||
PlayerLog(level: PlayerLogLevel.warn, prefix: 'ffmpeg', text: 'https: HTTP error $status Simulated'),
|
||||
);
|
||||
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 {
|
||||
|
||||
@@ -15,13 +15,27 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
|
||||
if (!mounted || _isExiting.value) return;
|
||||
|
||||
// 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;
|
||||
_progressTracker?.stopTracking();
|
||||
unawaited(_showServerLimitDialog());
|
||||
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
|
||||
// (mirrors Plex web client fallback chain).
|
||||
if (widget.isLive) {
|
||||
@@ -48,9 +62,8 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
|
||||
}
|
||||
|
||||
void _onPlayerLog(PlayerLog log) {
|
||||
if (!_sawServer500 && VideoPlayerScreenState._server500Pattern.hasMatch(log.text)) {
|
||||
_sawServer500 = true;
|
||||
}
|
||||
final status = PlayerError.httpStatusFromLog(log.text);
|
||||
if (status != null && fatalPlaybackHttpStatuses.contains(status)) _fatalHttpStatuses.add(status);
|
||||
if (log.level == PlayerLogLevel.error || log.level == PlayerLogLevel.fatal) {
|
||||
appLogger.e('[Player LOG ERROR] [${log.prefix}] ${log.text}');
|
||||
_lastLogError = _redactPlayerError(log.text.trim());
|
||||
@@ -65,6 +78,12 @@ extension _VideoPlayerErrorMethods on VideoPlayerScreenState {
|
||||
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
|
||||
Future<void> _onBackendSwitched() async {
|
||||
_playerBackendLabel = 'mpv';
|
||||
|
||||
@@ -532,7 +532,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
||||
// that gets an HTTP error and the truncated body surfaces as a clean
|
||||
// mid-file EOF (#1520 — PMS answers 503 while restarting/maintenance).
|
||||
// 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
|
||||
// comma-separated option string. While ffmpeg retries, mpv reports
|
||||
// buffering, which also makes the server-online reconnect hook in
|
||||
|
||||
@@ -105,7 +105,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
||||
|
||||
_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.
|
||||
_logSubscription = currentPlayer.streams.log
|
||||
.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 {
|
||||
if (!mounted || player != currentPlayer) return;
|
||||
_lastLogError = null;
|
||||
_sawServer500 = false;
|
||||
_fatalHttpStatuses.clear();
|
||||
_live.fallbackLevel = 0;
|
||||
_live.retryFailed = false;
|
||||
final markFirstFrameReady = _markFirstFrameReady(currentPlayer, settingsService);
|
||||
|
||||
@@ -1772,9 +1772,12 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
/// Convenience wrapper around [showConfirmDialog] with destructive styling.
|
||||
Future<bool> showDeleteConfirmation(
|
||||
|
||||
@@ -730,14 +730,15 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
),
|
||||
|
||||
if (kDebugMode)
|
||||
for (final status in const [500, 404])
|
||||
FocusableListTile(
|
||||
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: () {
|
||||
final player = widget.player;
|
||||
OverlaySheetController.of(context).close();
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.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/platform_detector.dart';
|
||||
|
||||
@@ -111,6 +112,44 @@ void main() {
|
||||
await expectLater(result, completion(isNull));
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user