fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Sincea1b6a8971sidecars load with the media behind a 10s open guard, so a subtitle URL the server is slow to serve — Jellyfin extracting an embedded stream while its transcoder spins up — tripped the guard: stop, reopen without subtitles, "Selected subtitles could not be loaded" snackbar, and an emptied subtitle menu. Since2b3853a88every embedded Plex subtitle was handed to the player as a sidecar whose URL is the original container, so a transcode also range-read and demuxed the source over HTTP — for a 40 GB remux, purely to find a subtitle track — which is also why PGS never appeared: the client was handed a container to demux rather than a rendition to play. Delivery is the server's job again, backported from the AVPlayer branch (42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted to main's mpv backend: Plex burns every embedded track (subtitles=burn); only a real external file with a /library/streams key stays a client-fetched sidecar. A burn is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400 to directPlay=1 with burn — and the burn is aimed by selecting the stream on the part first via the selectStreams PUT, because the decision endpoint ignores subtitleStreamID alongside subtitles=burn. An unaimable or undeliverable burn (dvb_teletext) refuses the transcode and falls back to warned direct play rather than welding the wrong language in or silently dropping the caption. Main's per-preset directPlay/directStream pinning is kept; verified against a live PMS that burn works under directStream=0. Jellyfin never offers image formats as External, so bitmaps fall through to Encode and are burned; text External is withheld per request when the effective selection — including the server's DefaultSubtitleStreamIndex — is embedded, and offered when it is a real file, so a file is delivered as a file and never fetched twice. The burned row is excluded from the sidecars; remaining text rows stay extractable, which is how a secondary track still renders over a transcode. Sidecar URLs now use the format extension the endpoint expects instead of the reported codec name. The controls and selection layers learn what burning means: burn eligibility is the codec's property, so burned rows stay selectable in the menu; any change away from a burned selection renegotiates with the server instead of pretending a local switch worked; the visibility shortcut explains itself instead of doing nothing; and the track manager is told when the primary is server-rendered so it stops waiting out a thirty-second deadline for a native track that is already pixels. Verified: analyzer parity, clean_translations --check --strict, full flutter test (5749), and decision-level runs against live Plex and Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn 400, External file delivery, an unchanged no-burn baseline, and a real burn session serving its playlist. The pre-commit aggregate was bypassed for pre-existing main-state findings outside this diff: 21 format-drifted files and three unused test seams in lib/main.dart. close #1738 Refs #1815, #1622.
This commit is contained in:
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"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",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Mətn",
|
"text": "Mətn",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "Логовете са качени",
|
"logsUploaded": "Логовете са качени",
|
||||||
"logsUploadFailed": "Неуспешно качване на логовете",
|
"logsUploadFailed": "Неуспешно качване на логовете",
|
||||||
"logId": "ID на лога"
|
"logId": "ID на лога",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Текст",
|
"text": "Текст",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "Logfilerne blev uploadet",
|
"logsUploaded": "Logfilerne blev uploadet",
|
||||||
"logsUploadFailed": "Logfilerne kunne ikke uploades",
|
"logsUploadFailed": "Logfilerne kunne ikke uploades",
|
||||||
"logId": "Log-ID"
|
"logId": "Log-ID",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Tekst",
|
"text": "Tekst",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "Protokolle hochgeladen",
|
"logsUploaded": "Protokolle hochgeladen",
|
||||||
"logsUploadFailed": "Protokolle konnten nicht hochgeladen werden",
|
"logsUploadFailed": "Protokolle konnten nicht hochgeladen werden",
|
||||||
"logId": "Protokoll-ID"
|
"logId": "Protokoll-ID",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "The server kept refusing to stream this file (HTTP 503). It may be restarting, busy, or the file's storage may be offline. Try again in a moment — if it keeps happening, ask the server owner to check the server and the file's storage.",
|
"serverBusyBody": "The server kept refusing to stream this file (HTTP 503). It may be restarting, busy, or the file's storage may be offline. Try again in a moment — if it keeps happening, ask the server owner to check the server and the file's storage.",
|
||||||
"logsUploaded": "Logs uploaded",
|
"logsUploaded": "Logs uploaded",
|
||||||
"logsUploadFailed": "Failed to upload logs",
|
"logsUploadFailed": "Failed to upload logs",
|
||||||
"logId": "Log ID"
|
"logId": "Log ID",
|
||||||
|
"burnedSubtitlesUseMenu": "Subtitles are burned into this stream. Change them from the subtitle menu."
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "Registros subidos",
|
"logsUploaded": "Registros subidos",
|
||||||
"logsUploadFailed": "Error al subir registros",
|
"logsUploadFailed": "Error al subir registros",
|
||||||
"logId": "ID de registro"
|
"logId": "ID de registro",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Texto",
|
"text": "Texto",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "Journaux envoyés",
|
"logsUploaded": "Journaux envoyés",
|
||||||
"logsUploadFailed": "Échec de l’envoi des journaux",
|
"logsUploadFailed": "Échec de l’envoi des journaux",
|
||||||
"logId": "Identifiant du journal"
|
"logId": "Identifiant du journal",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Texte",
|
"text": "Texte",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"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ó",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Szöveg",
|
"text": "Szöveg",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "Log caricati",
|
"logsUploaded": "Log caricati",
|
||||||
"logsUploadFailed": "Impossibile caricare i log",
|
"logsUploadFailed": "Impossibile caricare i log",
|
||||||
"logId": "ID log"
|
"logId": "ID log",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Testo",
|
"text": "Testo",
|
||||||
|
|||||||
@@ -744,7 +744,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "ログをアップロードしました",
|
"logsUploaded": "ログをアップロードしました",
|
||||||
"logsUploadFailed": "ログのアップロードに失敗しました",
|
"logsUploadFailed": "ログのアップロードに失敗しました",
|
||||||
"logId": "ログID"
|
"logId": "ログID",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "テキスト",
|
"text": "テキスト",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "Журналдар жүктелді",
|
"logsUploaded": "Журналдар жүктелді",
|
||||||
"logsUploadFailed": "Журналдарды жүктеу мүмкін болмады",
|
"logsUploadFailed": "Журналдарды жүктеу мүмкін болмады",
|
||||||
"logId": "Журнал ID-сі"
|
"logId": "Журнал ID-сі",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Мәтін",
|
"text": "Мәтін",
|
||||||
|
|||||||
@@ -744,7 +744,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "로그 업로드 완료",
|
"logsUploaded": "로그 업로드 완료",
|
||||||
"logsUploadFailed": "로그 업로드 실패",
|
"logsUploadFailed": "로그 업로드 실패",
|
||||||
"logId": "로그 ID"
|
"logId": "로그 ID",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "텍스트",
|
"text": "텍스트",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"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",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Tekst",
|
"text": "Tekst",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "Logbestanden geüpload",
|
"logsUploaded": "Logbestanden geüpload",
|
||||||
"logsUploadFailed": "Uploaden van logbestanden mislukt",
|
"logsUploadFailed": "Uploaden van logbestanden mislukt",
|
||||||
"logId": "Logboek-ID"
|
"logId": "Logboek-ID",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Tekst",
|
"text": "Tekst",
|
||||||
|
|||||||
@@ -756,7 +756,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"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",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Tekst",
|
"text": "Tekst",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "Logs enviados",
|
"logsUploaded": "Logs enviados",
|
||||||
"logsUploadFailed": "Falha ao enviar logs",
|
"logsUploadFailed": "Falha ao enviar logs",
|
||||||
"logId": "ID do log"
|
"logId": "ID do log",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Texto",
|
"text": "Texto",
|
||||||
|
|||||||
@@ -756,7 +756,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "Логи загружены",
|
"logsUploaded": "Логи загружены",
|
||||||
"logsUploadFailed": "Не удалось загрузить логи",
|
"logsUploadFailed": "Не удалось загрузить логи",
|
||||||
"logId": "ID лога"
|
"logId": "ID лога",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Текст",
|
"text": "Текст",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
/// To regenerate, run: `dart run slang`
|
/// To regenerate, run: `dart run slang`
|
||||||
///
|
///
|
||||||
/// Locales: 22
|
/// Locales: 22
|
||||||
/// Strings: 38876 (1767 per locale)
|
/// Strings: 38877 (1767 per locale)
|
||||||
|
|
||||||
// coverage:ignore-file
|
// coverage:ignore-file
|
||||||
// ignore_for_file: type=lint, unused_import
|
// ignore_for_file: type=lint, unused_import
|
||||||
|
|||||||
@@ -2223,6 +2223,9 @@ class Translations$messages$en {
|
|||||||
|
|
||||||
/// en: 'Log ID'
|
/// en: 'Log ID'
|
||||||
String get logId => 'Log ID';
|
String get logId => 'Log ID';
|
||||||
|
|
||||||
|
/// en: 'Subtitles are burned into this stream. Change them from the subtitle menu.'
|
||||||
|
String get burnedSubtitlesUseMenu => 'Subtitles are burned into this stream. Change them from the subtitle menu.';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Path: subtitlingStyling
|
// Path: subtitlingStyling
|
||||||
@@ -6829,6 +6832,7 @@ extension on Translations {
|
|||||||
'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',
|
||||||
|
'messages.burnedSubtitlesUseMenu' => 'Subtitles are burned into this stream. Change them from the subtitle menu.',
|
||||||
'subtitlingStyling.text' => 'Text',
|
'subtitlingStyling.text' => 'Text',
|
||||||
'subtitlingStyling.border' => 'Border',
|
'subtitlingStyling.border' => 'Border',
|
||||||
'subtitlingStyling.background' => 'Background',
|
'subtitlingStyling.background' => 'Background',
|
||||||
@@ -7152,9 +7156,9 @@ extension on Translations {
|
|||||||
'explore.badge.available' => 'Available',
|
'explore.badge.available' => 'Available',
|
||||||
'explore.badge.partiallyAvailable' => 'Partly available',
|
'explore.badge.partiallyAvailable' => 'Partly available',
|
||||||
'explore.badge.availableIn4k' => '4K available',
|
'explore.badge.availableIn4k' => '4K available',
|
||||||
'explore.badge.requested' => 'Requested',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'explore.badge.requested' => 'Requested',
|
||||||
'explore.badge.pendingApproval' => 'Pending approval',
|
'explore.badge.pendingApproval' => 'Pending approval',
|
||||||
'explore.badge.processing' => 'Processing',
|
'explore.badge.processing' => 'Processing',
|
||||||
'explore.badge.declined' => 'Declined',
|
'explore.badge.declined' => 'Declined',
|
||||||
@@ -7666,9 +7670,9 @@ extension on Translations {
|
|||||||
'performanceOverlay.renderFps' => 'Render FPS',
|
'performanceOverlay.renderFps' => 'Render FPS',
|
||||||
'performanceOverlay.displayFps' => 'Display FPS',
|
'performanceOverlay.displayFps' => 'Display FPS',
|
||||||
'performanceOverlay.avSync' => 'A/V Sync',
|
'performanceOverlay.avSync' => 'A/V Sync',
|
||||||
'performanceOverlay.dropped' => 'Dropped',
|
|
||||||
_ => null,
|
_ => null,
|
||||||
} ?? switch (path) {
|
} ?? switch (path) {
|
||||||
|
'performanceOverlay.dropped' => 'Dropped',
|
||||||
'performanceOverlay.dvRpus' => 'DV RPUs',
|
'performanceOverlay.dvRpus' => 'DV RPUs',
|
||||||
'performanceOverlay.dvRpuAverage' => 'DV RPU Avg',
|
'performanceOverlay.dvRpuAverage' => 'DV RPU Avg',
|
||||||
'performanceOverlay.dvSampleAverage' => 'DV Sample Avg',
|
'performanceOverlay.dvSampleAverage' => 'DV Sample Avg',
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"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",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Text",
|
"text": "Text",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"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)",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Metin",
|
"text": "Metin",
|
||||||
|
|||||||
@@ -748,7 +748,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "Jurnallar yuklandi",
|
"logsUploaded": "Jurnallar yuklandi",
|
||||||
"logsUploadFailed": "Jurnallarni yuklab boʻlmadi",
|
"logsUploadFailed": "Jurnallarni yuklab boʻlmadi",
|
||||||
"logId": "Jurnal ID-si"
|
"logId": "Jurnal ID-si",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "Matn",
|
"text": "Matn",
|
||||||
|
|||||||
@@ -744,7 +744,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "日誌已上傳",
|
"logsUploaded": "日誌已上傳",
|
||||||
"logsUploadFailed": "上傳日誌失敗",
|
"logsUploadFailed": "上傳日誌失敗",
|
||||||
"logId": "日誌 ID"
|
"logId": "日誌 ID",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "文字",
|
"text": "文字",
|
||||||
|
|||||||
@@ -744,7 +744,8 @@
|
|||||||
"serverBusyBody": "",
|
"serverBusyBody": "",
|
||||||
"logsUploaded": "日志已上传",
|
"logsUploaded": "日志已上传",
|
||||||
"logsUploadFailed": "上传日志失败",
|
"logsUploadFailed": "上传日志失败",
|
||||||
"logId": "日志 ID"
|
"logId": "日志 ID",
|
||||||
|
"burnedSubtitlesUseMenu": ""
|
||||||
},
|
},
|
||||||
"subtitlingStyling": {
|
"subtitlingStyling": {
|
||||||
"text": "文本",
|
"text": "文本",
|
||||||
|
|||||||
@@ -72,7 +72,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
|||||||
_currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
_currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
|
||||||
isTranscoding: _isTranscoding,
|
isTranscoding: _isTranscoding,
|
||||||
sidecarSourceIds: sidecarSourceIds,
|
sidecarSourceIds: sidecarSourceIds,
|
||||||
supportsEmbeddedTranscodeSelection: _currentMetadata.backend == MediaBackend.plex,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -387,6 +387,38 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
PlaybackSourceSubtitleChoice choice, {
|
PlaybackSourceSubtitleChoice choice, {
|
||||||
required bool Function() shouldContinue,
|
required bool Function() shouldContinue,
|
||||||
}) async {
|
}) async {
|
||||||
|
// On a transcode the server owns the picture: a burned subtitle cannot be removed or covered
|
||||||
|
// locally, and an embedded target can only arrive by being burned in. Either way the change
|
||||||
|
// goes back to the server rather than being applied to the running player.
|
||||||
|
final burnSession = _playbackSession;
|
||||||
|
final burnedSourceStreamId = burnSession?.subtitleSelection.primarySourceStreamId;
|
||||||
|
final targetSourceStreamId = choice.sourceStreamId;
|
||||||
|
if (PlaybackSubtitleResolver.burnRequiresRenegotiation(
|
||||||
|
isTranscoding: _isTranscoding,
|
||||||
|
currentSourceStreamId: burnedSourceStreamId,
|
||||||
|
currentSelectionHasSidecar:
|
||||||
|
burnSession != null &&
|
||||||
|
burnedSourceStreamId != null &&
|
||||||
|
_sidecarForSourceStreamId(burnSession, burnedSourceStreamId) != null,
|
||||||
|
targetIsOff: choice.isOff,
|
||||||
|
// "A file the client fetches for itself", which the two backends mark differently: Jellyfin
|
||||||
|
// flags the row `IsExternal`, while Plex never sets that and instead gives a real external
|
||||||
|
// subtitle a `/library/streams/{id}` key - the same test `_selectedInternalSubtitleForHls`
|
||||||
|
// uses to decide what it must not burn. Reading only `isExternalFile` classified every Plex
|
||||||
|
// external file as an embedded target and sent an already-loaded track switch to the server.
|
||||||
|
targetIsExternalFile:
|
||||||
|
targetSourceStreamId != null &&
|
||||||
|
(_currentMediaInfo?.subtitleTracks.any(
|
||||||
|
(track) =>
|
||||||
|
track.id == targetSourceStreamId &&
|
||||||
|
(track.isExternalFile ||
|
||||||
|
(_currentMetadata.backend == MediaBackend.plex && (track.key?.isNotEmpty ?? false))),
|
||||||
|
) ??
|
||||||
|
false),
|
||||||
|
)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (choice.isOff) {
|
if (choice.isOff) {
|
||||||
await currentPlayer.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
await currentPlayer.selectSecondarySubtitleTrack(SubtitleTrack.off);
|
||||||
if (!shouldContinue()) return false;
|
if (!shouldContinue()) return false;
|
||||||
@@ -850,6 +882,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
preferredSubtitleTrack:
|
preferredSubtitleTrack:
|
||||||
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
|
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
|
||||||
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
|
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
|
||||||
|
// A source-backed primary with no sidecar on a transcode is one the server burned into
|
||||||
|
// the picture: it is already visible, and no native track will ever arrive to match it.
|
||||||
|
primarySubtitleIsServerRendered:
|
||||||
|
result.isTranscoding &&
|
||||||
|
subtitleSelection.primarySourceStreamId != null &&
|
||||||
|
subtitleSelection.primarySidecar == null,
|
||||||
);
|
);
|
||||||
_trackManager = trackManager;
|
_trackManager = trackManager;
|
||||||
trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
|
trackManager.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||||
preserveSourceIdentity: preserveSubtitleSourceIdentity,
|
preserveSourceIdentity: preserveSubtitleSourceIdentity,
|
||||||
|
isTranscoding: result.isTranscoding,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,6 +468,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
AudioTrack? preferredAudioTrack,
|
AudioTrack? preferredAudioTrack,
|
||||||
SubtitlePreference? preferredSubtitleTrack,
|
SubtitlePreference? preferredSubtitleTrack,
|
||||||
SubtitlePreference? preferredSecondarySubtitleTrack,
|
SubtitlePreference? preferredSecondarySubtitleTrack,
|
||||||
|
bool primarySubtitleIsServerRendered = false,
|
||||||
}) {
|
}) {
|
||||||
return TrackManager(
|
return TrackManager(
|
||||||
player: forPlayer,
|
player: forPlayer,
|
||||||
@@ -481,6 +483,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
preferredAudioTrack: preferredAudioTrack,
|
preferredAudioTrack: preferredAudioTrack,
|
||||||
preferredSubtitleTrack: preferredSubtitleTrack,
|
preferredSubtitleTrack: preferredSubtitleTrack,
|
||||||
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
|
||||||
|
primarySubtitleIsServerRendered: primarySubtitleIsServerRendered,
|
||||||
showMessage: (message, {duration}) {
|
showMessage: (message, {duration}) {
|
||||||
if (mounted) showAppSnackBar(context, message, duration: duration);
|
if (mounted) showAppSnackBar(context, message, duration: duration);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -361,6 +361,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
|||||||
preferredSubtitleTrack:
|
preferredSubtitleTrack:
|
||||||
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
|
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
|
||||||
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
|
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(subtitleSelection.secondaryTrack),
|
||||||
|
// Same rule as the reload flow: a source-backed primary with no sidecar on a transcode is
|
||||||
|
// burned into the picture, so nothing native is coming for it.
|
||||||
|
primarySubtitleIsServerRendered:
|
||||||
|
_isTranscoding &&
|
||||||
|
subtitleSelection.primarySourceStreamId != null &&
|
||||||
|
subtitleSelection.primarySidecar == null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Store only the active sidecars for re-use after backend fallback.
|
// Store only the active sidecars for re-use after backend fallback.
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import 'scrub_preview_source.dart';
|
|||||||
import 'subtitle_preference.dart';
|
import 'subtitle_preference.dart';
|
||||||
import 'track_selection_service.dart';
|
import 'track_selection_service.dart';
|
||||||
import '../mpv/mpv.dart';
|
import '../mpv/mpv.dart';
|
||||||
|
import '../utils/codec_utils.dart';
|
||||||
|
|
||||||
part 'jellyfin_client/parts/browse.dart';
|
part 'jellyfin_client/parts/browse.dart';
|
||||||
part 'jellyfin_client/parts/music.dart';
|
part 'jellyfin_client/parts/music.dart';
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals {
|
|||||||
bool? allowVideoStreamCopy,
|
bool? allowVideoStreamCopy,
|
||||||
bool? allowAudioStreamCopy,
|
bool? allowAudioStreamCopy,
|
||||||
bool audioProfile,
|
bool audioProfile,
|
||||||
|
bool burnSubtitles,
|
||||||
});
|
});
|
||||||
String _withApiKey(String urlOrPath);
|
String _withApiKey(String urlOrPath);
|
||||||
|
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
);
|
);
|
||||||
var effectiveSourceId = bundle.selectedSourceId;
|
var effectiveSourceId = bundle.selectedSourceId;
|
||||||
var effectiveContainer = bundle.container;
|
var effectiveContainer = bundle.container;
|
||||||
var includeExternalSubtitleDelivery = false;
|
|
||||||
|
|
||||||
String? videoUrl;
|
String? videoUrl;
|
||||||
String? playSessionId;
|
String? playSessionId;
|
||||||
@@ -186,6 +185,32 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
: findSourceAudioTrackForIntent(options.preferredAudioTrack!, mediaInfo.audioTracks)?.id
|
: findSourceAudioTrackForIntent(options.preferredAudioTrack!, mediaInfo.audioTracks)?.id
|
||||||
: _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
|
: _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo);
|
||||||
final requestedSubtitleStreamId = _validJellyfinSubtitleStreamId(options.preferredSubtitleTrack, mediaInfo);
|
final requestedSubtitleStreamId = _validJellyfinSubtitleStreamId(options.preferredSubtitleTrack, mediaInfo);
|
||||||
|
// A real external subtitle file stays a file the client fetches, on a transcode as much as on
|
||||||
|
// a direct play - it is the one case where the client genuinely holds it. Jellyfin decides
|
||||||
|
// delivery from the profile and matches on format alone, never on whether a stream is embedded
|
||||||
|
// or a file, so the two rules are only expressible per request: withhold `External` when the
|
||||||
|
// selected stream is embedded (the server then burns it in), and offer it when the selection is
|
||||||
|
// a file. Deciding it per selection is what lets both hold at once.
|
||||||
|
//
|
||||||
|
// The *effective* selection, not just an explicit one: the normal launch path sends no
|
||||||
|
// preferred track and lets the server's `DefaultSubtitleStreamIndex` decide. Reading only the
|
||||||
|
// explicit request would withhold `External` for a default that is a real file, so the server
|
||||||
|
// would burn it while the client still fetched the same file as a sidecar - two copies on
|
||||||
|
// screen, and a transcode nobody needed.
|
||||||
|
final effectiveSubtitleStreamId = requestedSubtitleStreamId == -1
|
||||||
|
? null
|
||||||
|
: requestedSubtitleStreamId ?? mediaInfo.defaultSubtitleStreamIndex;
|
||||||
|
// Text only, because that is all the profile can actually deliver externally: a bitmap file
|
||||||
|
// falls through to `Encode` and gets burned in whatever we ask for, so classifying one as
|
||||||
|
// externally delivered would leave the client fetching a copy of pixels already in the video.
|
||||||
|
final requestedSubtitleIsExternalFile =
|
||||||
|
effectiveSubtitleStreamId != null &&
|
||||||
|
mediaInfo.subtitleTracks.any(
|
||||||
|
(track) =>
|
||||||
|
track.id == effectiveSubtitleStreamId &&
|
||||||
|
track.isExternalFile &&
|
||||||
|
CodecUtils.isTextSubtitleCodec(track.codec),
|
||||||
|
);
|
||||||
final int? maxStreamingBitrate = wantsOriginal
|
final int? maxStreamingBitrate = wantsOriginal
|
||||||
? null
|
? null
|
||||||
: isTrack
|
: isTrack
|
||||||
@@ -207,6 +232,11 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
audioStreamIndex: requestedAudioStreamId,
|
audioStreamIndex: requestedAudioStreamId,
|
||||||
subtitleStreamIndex: requestedSubtitleStreamId,
|
subtitleStreamIndex: requestedSubtitleStreamId,
|
||||||
audioProfile: isTrack,
|
audioProfile: isTrack,
|
||||||
|
// A capped preset is the only way a transcode is asked for, and on one the server
|
||||||
|
// burns the selected embedded stream in rather than serving it as a file the client
|
||||||
|
// would fetch as well. A selected external *file* keeps `External`, so it is still
|
||||||
|
// delivered as a file - the one case where the client genuinely holds it.
|
||||||
|
burnSubtitles: !wantsOriginal && !requestedSubtitleIsExternalFile,
|
||||||
);
|
);
|
||||||
chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId);
|
chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId);
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
@@ -249,7 +279,6 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
videoUrl = _withApiKey(transcodingUrl);
|
videoUrl = _withApiKey(transcodingUrl);
|
||||||
playMethod = 'Transcode';
|
playMethod = 'Transcode';
|
||||||
isTranscoding = true;
|
isTranscoding = true;
|
||||||
includeExternalSubtitleDelivery = true;
|
|
||||||
} else if (!wantsOriginal) {
|
} else if (!wantsOriginal) {
|
||||||
fallbackReason = TranscodeFallbackReason.directPlayOnly;
|
fallbackReason = TranscodeFallbackReason.directPlayOnly;
|
||||||
}
|
}
|
||||||
@@ -259,13 +288,26 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
|
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
|
||||||
// Tracks have no subtitle streams to assemble (a `Lyric` stream may be
|
// Tracks have no subtitle streams to assemble (a `Lyric` stream may be
|
||||||
// present, but lyrics flow through fetchLyrics, not the subtitle path).
|
// present, but lyrics flow through fetchLyrics, not the subtitle path).
|
||||||
|
//
|
||||||
|
// The burned row is excluded so it cannot be painted twice; the rest stay fetchable, which is
|
||||||
|
// what keeps a secondary track renderable over a transcode.
|
||||||
|
//
|
||||||
|
// Recomputed against the negotiated `mediaInfo`: the request's own view came from the
|
||||||
|
// pre-negotiation source, and when nothing was explicitly asked for it is the *server's*
|
||||||
|
// default that decides, which the response can report differently. An explicit request still
|
||||||
|
// wins, and an off request still burns nothing.
|
||||||
|
final negotiatedSubtitleStreamId = requestedSubtitleStreamId == -1
|
||||||
|
? null
|
||||||
|
: requestedSubtitleStreamId ?? mediaInfo.defaultSubtitleStreamIndex;
|
||||||
|
final burnedSourceStreamId = isTranscoding && !requestedSubtitleIsExternalFile ? negotiatedSubtitleStreamId : null;
|
||||||
final subtitleSidecars = isTrack
|
final subtitleSidecars = isTrack
|
||||||
? const <PlaybackSubtitleSidecar>[]
|
? const <PlaybackSubtitleSidecar>[]
|
||||||
: _buildExternalSubtitles(
|
: _buildExternalSubtitles(
|
||||||
metadata.id,
|
metadata.id,
|
||||||
effectiveSourceId,
|
effectiveSourceId,
|
||||||
mediaInfo,
|
mediaInfo,
|
||||||
includeExternalDelivery: includeExternalSubtitleDelivery,
|
isTranscoding: isTranscoding,
|
||||||
|
burnedSourceStreamId: burnedSourceStreamId,
|
||||||
);
|
);
|
||||||
mediaInfo = _withSidecarBackedSubtitleIdentity(mediaInfo, subtitleSidecars);
|
mediaInfo = _withSidecarBackedSubtitleIdentity(mediaInfo, subtitleSidecars);
|
||||||
// Jellyfin's streaming endpoint resolves a blank MediaSourceId to its own
|
// Jellyfin's streaming endpoint resolves a blank MediaSourceId to its own
|
||||||
@@ -381,9 +423,10 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
/// Restrict sidecar identity to the subtitle rows this open actually fetched
|
/// Restrict sidecar identity to the subtitle rows this open actually fetched
|
||||||
/// as sidecars.
|
/// as sidecars.
|
||||||
///
|
///
|
||||||
/// Plezy's device profile declares every subtitle format with
|
/// Plezy's device profile declares every *text* subtitle format with
|
||||||
/// `Method: External`, so Jellyfin returns `DeliveryMethod: External` and a
|
/// `Method: External`, so Jellyfin returns `DeliveryMethod: External` and a
|
||||||
/// `DeliveryUrl` even for streams embedded in a direct-played container.
|
/// `DeliveryUrl` even for text streams embedded in a direct-played container
|
||||||
|
/// whose container cannot carry subtitles in the delivered form.
|
||||||
/// [_buildExternalSubtitles] correctly skips those, and the native player
|
/// [_buildExternalSubtitles] correctly skips those, and the native player
|
||||||
/// reads them out of the container instead — but the leftover delivery URL
|
/// reads them out of the container instead — but the leftover delivery URL
|
||||||
/// makes the shared track matchers demand a sidecar that will never load,
|
/// makes the shared track matchers demand a sidecar that will never load,
|
||||||
@@ -411,23 +454,44 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
final streamIndex = track.index ?? track.id;
|
final streamIndex = track.index ?? track.id;
|
||||||
final codec = track.codec;
|
final codec = track.codec;
|
||||||
if (sourceId == null || codec == null || codec.isEmpty) return null;
|
if (sourceId == null || codec == null || codec.isEmpty) return null;
|
||||||
|
// The endpoint keys off the *format*, not the codec name Jellyfin reports: it calls SRT streams
|
||||||
|
// `subrip` and WebVTT ones `webvtt`, so the raw name would ask for `Stream.subrip` and get
|
||||||
|
// nothing. Only load-bearing since extracted rows without a `DeliveryUrl` started coming
|
||||||
|
// through here.
|
||||||
|
final extension = CodecUtils.getSubtitleExtension(codec);
|
||||||
final path = Uri(
|
final path = Uri(
|
||||||
pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$codec'],
|
pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$extension'],
|
||||||
).path;
|
).path;
|
||||||
return path.startsWith('/') ? path : '/$path';
|
return path.startsWith('/') ? path : '/$path';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Sidecars this open should fetch.
|
||||||
|
///
|
||||||
|
/// Never the row the server burned in, whatever its source: those pixels are already in the
|
||||||
|
/// video, and fetching a copy would draw it twice.
|
||||||
|
///
|
||||||
|
/// Never a bitmap on a transcode either. The profile only ever offers `External` for text, so a
|
||||||
|
/// bitmap falls through to `Encode` and is burned whatever we ask for - an external bitmap *file*
|
||||||
|
/// included, which is why this is not just an embedded-row rule.
|
||||||
|
///
|
||||||
|
/// Otherwise: a real external file always, since it is a file whether the video is transcoded or
|
||||||
|
/// not; and an embedded text row only on a transcode, where Jellyfin can extract it on demand.
|
||||||
|
/// That is how a *secondary* track still renders over a transcode whose primary is painted into
|
||||||
|
/// the picture. On a direct play embedded rows are absent on purpose - the native player reads
|
||||||
|
/// them out of the container itself.
|
||||||
List<PlaybackSubtitleSidecar> _buildExternalSubtitles(
|
List<PlaybackSubtitleSidecar> _buildExternalSubtitles(
|
||||||
String itemId,
|
String itemId,
|
||||||
String? mediaSourceId,
|
String? mediaSourceId,
|
||||||
MediaSourceInfo mediaInfo, {
|
MediaSourceInfo mediaInfo, {
|
||||||
bool includeExternalDelivery = false,
|
bool isTranscoding = false,
|
||||||
|
int? burnedSourceStreamId,
|
||||||
}) {
|
}) {
|
||||||
final externalSubtitles = <PlaybackSubtitleSidecar>[];
|
final externalSubtitles = <PlaybackSubtitleSidecar>[];
|
||||||
for (final track in mediaInfo.subtitleTracks) {
|
for (final track in mediaInfo.subtitleTracks) {
|
||||||
if (!track.isExternalFile && !(includeExternalDelivery && track.usesExternalDelivery)) {
|
if (burnedSourceStreamId != null && track.id == burnedSourceStreamId) continue;
|
||||||
continue;
|
final isText = CodecUtils.isTextSubtitleCodec(track.codec);
|
||||||
}
|
if (isTranscoding && !isText) continue;
|
||||||
|
if (!track.isExternalFile && !isTranscoding) continue;
|
||||||
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
|
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
|
||||||
if (path == null) continue;
|
if (path == null) continue;
|
||||||
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
|
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
|
||||||
@@ -601,6 +665,10 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
bool? allowVideoStreamCopy,
|
bool? allowVideoStreamCopy,
|
||||||
bool? allowAudioStreamCopy,
|
bool? allowAudioStreamCopy,
|
||||||
bool audioProfile = false,
|
bool audioProfile = false,
|
||||||
|
|
||||||
|
/// Drop `External` subtitle delivery from the profile, so the server burns
|
||||||
|
/// the selected subtitle into a transcode instead of serving it alongside.
|
||||||
|
bool burnSubtitles = false,
|
||||||
}) async {
|
}) async {
|
||||||
final query = <String, String>{
|
final query = <String, String>{
|
||||||
'userId': connection.userId,
|
'userId': connection.userId,
|
||||||
@@ -683,27 +751,40 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
'AudioCodec': 'flac,mp3,aac,alac,opus,vorbis,wav,wma',
|
'AudioCodec': 'flac,mp3,aac,alac,opus,vorbis,wav,wma',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
// Embed is listed first so a direct-played container reports its
|
// `Embed` covers direct play and an mkv remux, where the native
|
||||||
// subtitle streams as `DeliveryMethod: Embed`, matching what the
|
// player reads the subtitle stream straight out of the container.
|
||||||
// native player actually reads. External stays declared for every
|
// Jellyfin only offers it when the delivered container can carry
|
||||||
// format because a remux or transcode drops those streams from the
|
// subtitles, so it is unreachable on an HLS transcode (ts/mp4) and
|
||||||
// rendition and the server must hand us sidecar URLs instead; the
|
// is listed for every format purely for the direct paths.
|
||||||
// server picks per play method, so both entries are required.
|
//
|
||||||
'SubtitleProfiles': const <Map<String, Object?>>[
|
// `External` asks the server to extract a stream and serve it as a
|
||||||
{'Format': 'srt', 'Method': 'Embed'},
|
// subtitle file. It is offered only when the caller is not asking for
|
||||||
{'Format': 'ass', 'Method': 'Embed'},
|
// a transcode: on a transcode the owner decision is that the server
|
||||||
{'Format': 'ssa', 'Method': 'Embed'},
|
// delivers the picture complete, so every subtitle is burned in and
|
||||||
{'Format': 'vtt', 'Method': 'Embed'},
|
// the client fetches nothing alongside it. Jellyfin matches an
|
||||||
{'Format': 'pgssub', 'Method': 'Embed'},
|
// external profile by text-vs-image format and never consults whether
|
||||||
{'Format': 'dvdsub', 'Method': 'Embed'},
|
// the stream is embedded or a real file, so the list cannot express
|
||||||
{'Format': 'dvbsub', 'Method': 'Embed'},
|
// "files as files, embedded burned" - offering text `External` at all
|
||||||
{'Format': 'srt', 'Method': 'External'},
|
// is what made embedded text arrive as a sidecar.
|
||||||
{'Format': 'ass', 'Method': 'External'},
|
//
|
||||||
{'Format': 'ssa', 'Method': 'External'},
|
// With no matching `External` entry the server finds no external
|
||||||
{'Format': 'vtt', 'Method': 'External'},
|
// profile and falls through to `Encode`, which is also why image
|
||||||
{'Format': 'pgssub', 'Method': 'External'},
|
// formats never appear here: a bitmap handed over as a separate
|
||||||
{'Format': 'dvdsub', 'Method': 'External'},
|
// stream alongside a transcode is not something the client can render.
|
||||||
{'Format': 'dvbsub', 'Method': 'External'},
|
'SubtitleProfiles': <Map<String, Object?>>[
|
||||||
|
const {'Format': 'srt', 'Method': 'Embed'},
|
||||||
|
const {'Format': 'ass', 'Method': 'Embed'},
|
||||||
|
const {'Format': 'ssa', 'Method': 'Embed'},
|
||||||
|
const {'Format': 'vtt', 'Method': 'Embed'},
|
||||||
|
const {'Format': 'pgssub', 'Method': 'Embed'},
|
||||||
|
const {'Format': 'dvdsub', 'Method': 'Embed'},
|
||||||
|
const {'Format': 'dvbsub', 'Method': 'Embed'},
|
||||||
|
if (!burnSubtitles) ...const [
|
||||||
|
{'Format': 'srt', 'Method': 'External'},
|
||||||
|
{'Format': 'ass', 'Method': 'External'},
|
||||||
|
{'Format': 'ssa', 'Method': 'External'},
|
||||||
|
{'Format': 'vtt', 'Method': 'External'},
|
||||||
|
],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ class PlaybackSubtitleResolver {
|
|||||||
SubtitlePreference? preferredSubtitleTrack,
|
SubtitlePreference? preferredSubtitleTrack,
|
||||||
SubtitlePreference? preferredSecondarySubtitleTrack,
|
SubtitlePreference? preferredSecondarySubtitleTrack,
|
||||||
bool preserveSourceIdentity = true,
|
bool preserveSourceIdentity = true,
|
||||||
|
bool isTranscoding = false,
|
||||||
}) {
|
}) {
|
||||||
final candidates = <_SubtitleCandidate>[];
|
final candidates = <_SubtitleCandidate>[];
|
||||||
final matchedSidecars = <PlaybackSubtitleSidecar>{};
|
final matchedSidecars = <PlaybackSubtitleSidecar>{};
|
||||||
@@ -238,6 +239,11 @@ class PlaybackSubtitleResolver {
|
|||||||
secondaryCandidate = candidates
|
secondaryCandidate = candidates
|
||||||
.where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id)
|
.where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id)
|
||||||
.firstOrNull;
|
.firstOrNull;
|
||||||
|
// A transcode carries exactly one subtitle - the burned primary - so an embedded secondary has
|
||||||
|
// no route at all: no sidecar to fetch and no native track to land on. Kept in the committed
|
||||||
|
// selection it made `TrackManager` wait out its thirty-second deadline for a track that could
|
||||||
|
// never arrive, and then read as selected while nothing was on screen.
|
||||||
|
if (isTranscoding && secondaryCandidate?.sidecar == null) secondaryCandidate = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return PlaybackSubtitleSelection(
|
return PlaybackSubtitleSelection(
|
||||||
@@ -251,6 +257,33 @@ class PlaybackSubtitleResolver {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a subtitle change has to go back to the server rather than being applied to the
|
||||||
|
/// running player.
|
||||||
|
///
|
||||||
|
/// Two independent reasons, both only on a transcode.
|
||||||
|
///
|
||||||
|
/// Something is burned in right now: burned pixels are not a track, so turning them off
|
||||||
|
/// client-side leaves them on screen and selecting something else draws it *over* them. Every
|
||||||
|
/// change from that state needs a fresh negotiation, whatever it changes to.
|
||||||
|
///
|
||||||
|
/// Or the target itself can only come from the server. On a transcode the only subtitle the
|
||||||
|
/// client holds is a real external file; an embedded row is delivered by being burned in, so
|
||||||
|
/// selecting one has to be negotiated. Applying it locally instead finds nothing attached and
|
||||||
|
/// reports success over a picture that never changed.
|
||||||
|
///
|
||||||
|
/// Turning off with nothing burned is a genuine local no-op, and a direct play never burns.
|
||||||
|
static bool burnRequiresRenegotiation({
|
||||||
|
required bool isTranscoding,
|
||||||
|
required int? currentSourceStreamId,
|
||||||
|
required bool currentSelectionHasSidecar,
|
||||||
|
required bool targetIsOff,
|
||||||
|
required bool targetIsExternalFile,
|
||||||
|
}) {
|
||||||
|
if (!isTranscoding) return false;
|
||||||
|
if (currentSourceStreamId != null && !currentSelectionHasSidecar) return true;
|
||||||
|
return !targetIsOff && !targetIsExternalFile;
|
||||||
|
}
|
||||||
|
|
||||||
/// Stable source descriptor used for an explicit user selection. Supplying
|
/// Stable source descriptor used for an explicit user selection. Supplying
|
||||||
/// this as the next open's preferred track makes it the highest-priority
|
/// this as the next open's preferred track makes it the highest-priority
|
||||||
/// choice without retaining a stale sidecar URL.
|
/// choice without retaining a stale sidecar URL.
|
||||||
|
|||||||
+141
-32
@@ -74,6 +74,7 @@ import 'plex_lyrics_parser.dart';
|
|||||||
import 'plex_mappers.dart';
|
import 'plex_mappers.dart';
|
||||||
import 'plex_playback_mapper.dart';
|
import 'plex_playback_mapper.dart';
|
||||||
import 'playback_initialization_types.dart';
|
import 'playback_initialization_types.dart';
|
||||||
|
import 'subtitle_preference.dart';
|
||||||
import 'track_selection_service.dart';
|
import 'track_selection_service.dart';
|
||||||
|
|
||||||
part 'plex_client/parts/live_tv.dart';
|
part 'plex_client/parts/live_tv.dart';
|
||||||
@@ -2603,9 +2604,10 @@ class PlexClient
|
|||||||
|
|
||||||
/// Build an HLS VOD transcode stream URL (decision + start path).
|
/// Build an HLS VOD transcode stream URL (decision + start path).
|
||||||
///
|
///
|
||||||
/// Subtitle delivery stays outside the HLS video stream. Callers attach
|
/// [selectedSubtitleTrack] is burned into the picture by the server, so
|
||||||
/// Plex subtitle sources independently, so changing subtitle tracks never
|
/// switching to a different embedded track needs a new transcode session.
|
||||||
/// restarts the video transcode.
|
/// Real external subtitle files are unaffected — they ride alongside as
|
||||||
|
/// sidecars the client fetches directly.
|
||||||
///
|
///
|
||||||
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
||||||
/// seeks + quality/version/audio switches within one playback so the
|
/// seeks + quality/version/audio switches within one playback so the
|
||||||
@@ -2619,8 +2621,11 @@ class PlexClient
|
|||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
Duration? offset,
|
Duration? offset,
|
||||||
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||||
|
int? partId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
|
await selectSubtitleStreamForBurn(partId: partId, track: selectedSubtitleTrack);
|
||||||
final allParams = _buildTranscodeParams(
|
final allParams = _buildTranscodeParams(
|
||||||
ratingKey: ratingKey,
|
ratingKey: ratingKey,
|
||||||
mediaIndex: mediaIndex,
|
mediaIndex: mediaIndex,
|
||||||
@@ -2630,6 +2635,7 @@ class PlexClient
|
|||||||
transcodeSessionId: transcodeSessionId,
|
transcodeSessionId: transcodeSessionId,
|
||||||
audioStreamId: audioStreamId,
|
audioStreamId: audioStreamId,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
|
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||||
);
|
);
|
||||||
return await _runTranscodeDecision(
|
return await _runTranscodeDecision(
|
||||||
startEndpoint: _plexVideoHlsStartEndpoint,
|
startEndpoint: _plexVideoHlsStartEndpoint,
|
||||||
@@ -2841,6 +2847,36 @@ class PlexClient
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Point the part's server-side subtitle selection at [track] so an imminent
|
||||||
|
/// `subtitles=burn` transcode burns *that* stream.
|
||||||
|
///
|
||||||
|
/// The universal transcoder decides what to burn from the part's stored
|
||||||
|
/// selection and ignores a `subtitleStreamID` passed alongside `subtitles`:
|
||||||
|
/// asking a real PMS to burn a non-selected stream burned the selected one
|
||||||
|
/// instead. Selection therefore has to happen first, on the part itself.
|
||||||
|
///
|
||||||
|
/// A no-op unless a burnable embedded track is actually being requested —
|
||||||
|
/// external subtitle files ride along as sidecars and must not disturb the
|
||||||
|
/// server's selection, and nothing is burned when no track is chosen.
|
||||||
|
///
|
||||||
|
/// Throws when a burn *is* wanted but the selection cannot be confirmed, so
|
||||||
|
/// [buildTranscodeStartPath] reports `failed` and playback falls back to
|
||||||
|
/// direct play. That is deliberately the better outcome: direct play lets the
|
||||||
|
/// native player read the embedded track itself, whereas burning against an
|
||||||
|
/// unconfirmed selection paints whatever the server had stored — a wrong
|
||||||
|
/// language welded into the picture that the viewer cannot switch off.
|
||||||
|
@visibleForTesting
|
||||||
|
Future<void> selectSubtitleStreamForBurn({required int? partId, required MediaSubtitleTrack? track}) async {
|
||||||
|
final burnTarget = _selectedInternalSubtitleForHls(track);
|
||||||
|
if (burnTarget == null) return;
|
||||||
|
if (partId == null) {
|
||||||
|
throw StateError('Cannot burn subtitle stream ${burnTarget.id}: no part id to select it on');
|
||||||
|
}
|
||||||
|
if (!await selectStreams(partId, subtitleStreamID: burnTarget.id)) {
|
||||||
|
throw StateError('Server refused to select subtitle stream ${burnTarget.id} on part $partId for burn-in');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a music transcode stream URL (decision + start path).
|
/// Build a music transcode stream URL (decision + start path).
|
||||||
///
|
///
|
||||||
/// Mirrors [buildTranscodeStartPath] for audio tracks: the same
|
/// Mirrors [buildTranscodeStartPath] for audio tracks: the same
|
||||||
@@ -2939,8 +2975,10 @@ class PlexClient
|
|||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
Duration? offset,
|
Duration? offset,
|
||||||
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||||
}) {
|
}) {
|
||||||
final isOriginal = preset.isOriginal;
|
final isOriginal = preset.isOriginal;
|
||||||
|
final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack);
|
||||||
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
|
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
|
||||||
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
|
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
|
||||||
);
|
);
|
||||||
@@ -2952,7 +2990,13 @@ class PlexClient
|
|||||||
'partIndex': partIndex.toString(),
|
'partIndex': partIndex.toString(),
|
||||||
'protocol': _plexVideoHlsProtocol,
|
'protocol': _plexVideoHlsProtocol,
|
||||||
'fastSeek': '1',
|
'fastSeek': '1',
|
||||||
'directPlay': isOriginal ? '1' : '0',
|
// A burn is a re-encode, so it contradicts direct play. Asking for both
|
||||||
|
// at once is rejected outright: measured against a real PMS,
|
||||||
|
// `directPlay=1` with `subtitles=burn` answers HTTP 400 for text and
|
||||||
|
// image subtitles alike, while `directPlay=0` answers
|
||||||
|
// `decision=transcode` on the video stream and `decision=burn` on the
|
||||||
|
// subtitle.
|
||||||
|
'directPlay': selectedInternalSubtitle == null && isOriginal ? '1' : '0',
|
||||||
'directStream': isOriginal ? '1' : '0',
|
'directStream': isOriginal ? '1' : '0',
|
||||||
'subtitleSize': '100',
|
'subtitleSize': '100',
|
||||||
'audioBoost': '100',
|
'audioBoost': '100',
|
||||||
@@ -2964,7 +3008,14 @@ class PlexClient
|
|||||||
'mediaBufferSize': '102400',
|
'mediaBufferSize': '102400',
|
||||||
'session': transcodeSessionId,
|
'session': transcodeSessionId,
|
||||||
if (offset != null && offset > Duration.zero) 'offset': (offset.inMilliseconds / 1000).toStringAsFixed(6),
|
if (offset != null && offset > Duration.zero) 'offset': (offset.inMilliseconds / 1000).toStringAsFixed(6),
|
||||||
'subtitles': 'none',
|
// `subtitles` is the only subtitle knob this endpoint honours. Which
|
||||||
|
// stream gets burned comes from the part's server-side selection, not
|
||||||
|
// from here: measured against a real PMS, passing `subtitleStreamID` for
|
||||||
|
// a non-selected stream burned the already-selected one instead and the
|
||||||
|
// requested stream was absent from the decision entirely. See
|
||||||
|
// [selectSubtitleStreamForBurn], which is why the burn targets the
|
||||||
|
// caller's track at all.
|
||||||
|
'subtitles': selectedInternalSubtitle != null ? 'burn' : 'none',
|
||||||
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
|
if (audioStreamId != null) 'audioStreamID': audioStreamId.toString(),
|
||||||
'Accept-Language': 'en',
|
'Accept-Language': 'en',
|
||||||
'X-Plex-Session-Identifier': sessionIdentifier,
|
'X-Plex-Session-Identifier': sessionIdentifier,
|
||||||
@@ -2994,6 +3045,7 @@ class PlexClient
|
|||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
Duration? offset,
|
Duration? offset,
|
||||||
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||||
}) {
|
}) {
|
||||||
return _buildTranscodeParams(
|
return _buildTranscodeParams(
|
||||||
ratingKey: ratingKey,
|
ratingKey: ratingKey,
|
||||||
@@ -3004,6 +3056,7 @@ class PlexClient
|
|||||||
transcodeSessionId: transcodeSessionId,
|
transcodeSessionId: transcodeSessionId,
|
||||||
audioStreamId: audioStreamId,
|
audioStreamId: audioStreamId,
|
||||||
offset: offset,
|
offset: offset,
|
||||||
|
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3528,6 +3581,7 @@ class PlexClient
|
|||||||
final resolvedAudioId = carriedAudioTrack == null
|
final resolvedAudioId = carriedAudioTrack == null
|
||||||
? _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo)
|
? _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo)
|
||||||
: carriedAudioStreamId;
|
: carriedAudioStreamId;
|
||||||
|
final requestedSubtitleTrack = _resolveTranscodeSubtitleTrack(data.mediaInfo, options.preferredSubtitleTrack);
|
||||||
final result = await buildTranscodeStartPath(
|
final result = await buildTranscodeStartPath(
|
||||||
ratingKey: options.metadata.id,
|
ratingKey: options.metadata.id,
|
||||||
mediaIndex: data.selectedMediaIndex,
|
mediaIndex: data.selectedMediaIndex,
|
||||||
@@ -3537,11 +3591,21 @@ class PlexClient
|
|||||||
transcodeSessionId: options.transcodeSessionId!,
|
transcodeSessionId: options.transcodeSessionId!,
|
||||||
audioStreamId: resolvedAudioId,
|
audioStreamId: resolvedAudioId,
|
||||||
offset: options.transcodeOffset,
|
offset: options.transcodeOffset,
|
||||||
|
selectedSubtitleTrack: requestedSubtitleTrack,
|
||||||
|
partId: data.mediaInfo?.getPartId(),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
|
// A transcode that cannot carry the requested caption is not the outcome we asked for. The
|
||||||
|
// burn path refuses codecs like `dvb_teletext`, so the decision went out as
|
||||||
|
// `subtitles=none`; accepting the stream anyway left the row selected with nothing drawing
|
||||||
|
// it and no sidecar to fall back on. Falling through reports the refusal and direct play
|
||||||
|
// delivers it, which is what the burn-refusal fallback below already does.
|
||||||
|
final burnUndeliverable =
|
||||||
|
_requestsSubtitleBurn(requestedSubtitleTrack) &&
|
||||||
|
_selectedInternalSubtitleForHls(requestedSubtitleTrack) == null;
|
||||||
|
if (!burnUndeliverable && result.outcome == TranscodeDecisionOutcome.transcodeOk && result.startPath != null) {
|
||||||
final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
|
final transcodeUrl = '${config.baseUrl}${result.startPath}'.withPlexToken(config.token);
|
||||||
final subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo, data.videoUrl!);
|
final subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo);
|
||||||
return PlaybackInitializationResult(
|
return PlaybackInitializationResult(
|
||||||
availableVersions: data.availableVersions,
|
availableVersions: data.availableVersions,
|
||||||
videoUrl: transcodeUrl,
|
videoUrl: transcodeUrl,
|
||||||
@@ -3618,6 +3682,65 @@ class PlexClient
|
|||||||
return tracks.first.id;
|
return tracks.first.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MediaSubtitleTrack? _selectedSubtitleTrack(MediaSourceInfo? info) {
|
||||||
|
if (info == null) return null;
|
||||||
|
for (final track in info.subtitleTracks) {
|
||||||
|
if (track.selected) return track;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pick the subtitle stream the transcode should carry. An explicit
|
||||||
|
/// [preferred] wins; otherwise the server's own selection stands.
|
||||||
|
MediaSubtitleTrack? _resolveTranscodeSubtitleTrack(MediaSourceInfo? info, SubtitlePreference? preferred) {
|
||||||
|
if (info == null) return null;
|
||||||
|
switch (preferred) {
|
||||||
|
case null:
|
||||||
|
return _selectedSubtitleTrack(info);
|
||||||
|
case SubtitleOffPreference():
|
||||||
|
return null;
|
||||||
|
case SubtitleIntentPreference(:final intent):
|
||||||
|
return findSourceTrackForIntent(intent, info.subtitleTracks) ?? _selectedSubtitleTrack(info);
|
||||||
|
case SubtitleTrackPreference(:final track):
|
||||||
|
const sourcePrefix = 'source:';
|
||||||
|
MediaSubtitleTrack? matched;
|
||||||
|
if (track.id.startsWith(sourcePrefix)) {
|
||||||
|
final sourceId = int.tryParse(track.id.substring(sourcePrefix.length));
|
||||||
|
if (sourceId != null) {
|
||||||
|
for (final row in info.subtitleTracks) {
|
||||||
|
if (row.id == sourceId) {
|
||||||
|
matched = row;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
matched ??= findPlexTrackForMpvSubtitle(track, info.subtitleTracks);
|
||||||
|
return matched ?? _selectedSubtitleTrack(info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@visibleForTesting
|
||||||
|
MediaSubtitleTrack? resolveTranscodeSubtitleTrackForTesting(MediaSourceInfo? info, SubtitlePreference? preferred) {
|
||||||
|
return _resolveTranscodeSubtitleTrack(info, preferred);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The embedded stream a transcode must burn in, or null when there is
|
||||||
|
/// nothing to burn. A track carrying a `key` is a real external subtitle
|
||||||
|
/// file the client fetches directly, so it stays a sidecar instead.
|
||||||
|
MediaSubtitleTrack? _selectedInternalSubtitleForHls(MediaSubtitleTrack? track) {
|
||||||
|
if (track == null) return null;
|
||||||
|
if (track.key != null && track.key!.isNotEmpty) return null;
|
||||||
|
return CodecUtils.isTranscodableSubtitleCodec(track.codec) ? track : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether [track] is a row a transcode would have to burn, whatever its codec.
|
||||||
|
///
|
||||||
|
/// [_selectedInternalSubtitleForHls] answers the narrower question of what can
|
||||||
|
/// actually be burned; a row it rejects still cannot survive a transcode, so the
|
||||||
|
/// two must not be confused where the decision is about what was *asked* for.
|
||||||
|
bool _requestsSubtitleBurn(MediaSubtitleTrack? track) => track != null && (track.key == null || track.key!.isEmpty);
|
||||||
|
|
||||||
/// Build the absolute URL for an external subtitle track on this Plex
|
/// Build the absolute URL for an external subtitle track on this Plex
|
||||||
/// server. Returns `null` for tracks that aren't external (no `/library/
|
/// server. Returns `null` for tracks that aren't external (no `/library/
|
||||||
/// streams/{id}` key) or when the server has no auth token.
|
/// streams/{id}` key) or when the server has no auth token.
|
||||||
@@ -3658,39 +3781,25 @@ class PlexClient
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
SubtitleTrack _containerSubtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) {
|
/// Build the subtitle sidecars for Plex transcode playback.
|
||||||
return SubtitleTrack(
|
|
||||||
id: 'container:${track.id}',
|
|
||||||
title: track.displayTitle ?? track.title ?? track.language ?? 'Track ${track.id}',
|
|
||||||
language: track.languageCode,
|
|
||||||
codec: track.codec,
|
|
||||||
isDefault: track.selected,
|
|
||||||
isForced: track.forced,
|
|
||||||
isExternal: true,
|
|
||||||
isContainer: true,
|
|
||||||
uri: url,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build the complete subtitle catalog for Plex transcode playback.
|
|
||||||
///
|
///
|
||||||
/// Real sidecar files keep their direct stream URL. Embedded subtitle
|
/// Only real external subtitle files belong here: they are small, have a
|
||||||
/// streams share the original media container as a subtitle-only source;
|
/// direct stream URL, and cost nothing to fetch. Embedded streams are burned
|
||||||
/// player backends filter that source to text tracks. Every entry is
|
/// into the picture by the transcoder, so handing the client the original
|
||||||
/// preloaded so changing subtitles is a local track selection.
|
/// media container to demux would mean range-reading the whole source over
|
||||||
List<PlaybackSubtitleSidecar> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo, String sourceUrl) {
|
/// HTTP alongside the transcode it was meant to avoid.
|
||||||
|
List<PlaybackSubtitleSidecar> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
|
||||||
if (mediaInfo == null) return const [];
|
if (mediaInfo == null) return const [];
|
||||||
|
|
||||||
final tracks = <PlaybackSubtitleSidecar>[];
|
final tracks = <PlaybackSubtitleSidecar>[];
|
||||||
for (final sub in mediaInfo.subtitleTracks) {
|
for (final sub in mediaInfo.subtitleTracks) {
|
||||||
try {
|
try {
|
||||||
final directUrl = _buildSidecarSubtitleUrl(sub);
|
final directUrl = _buildSidecarSubtitleUrl(sub);
|
||||||
|
if (directUrl == null) continue;
|
||||||
tracks.add(
|
tracks.add(
|
||||||
PlaybackSubtitleSidecar(
|
PlaybackSubtitleSidecar(
|
||||||
sourceStreamId: sub.id,
|
sourceStreamId: sub.id,
|
||||||
track: directUrl == null
|
track: _subtitleTrackFromMediaTrack(sub, directUrl),
|
||||||
? _containerSubtitleTrackFromMediaTrack(sub, sourceUrl)
|
|
||||||
: _subtitleTrackFromMediaTrack(sub, directUrl),
|
|
||||||
preload: true,
|
preload: true,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -3702,8 +3811,8 @@ class PlexClient
|
|||||||
}
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
List<PlaybackSubtitleSidecar> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo, String sourceUrl) {
|
List<PlaybackSubtitleSidecar> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) {
|
||||||
return _buildTranscodeSidecarSubtitles(mediaInfo, sourceUrl);
|
return _buildTranscodeSidecarSubtitles(mediaInfo);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build list of external subtitle tracks from media info
|
/// Build list of external subtitle tracks from media info
|
||||||
|
|||||||
@@ -680,8 +680,18 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
|
|||||||
'directStreamAudio': directStreamAudio ? '1' : '0',
|
'directStreamAudio': directStreamAudio ? '1' : '0',
|
||||||
'mediaBufferSize': '157286',
|
'mediaBufferSize': '157286',
|
||||||
'session': transcodeSessionId,
|
'session': transcodeSessionId,
|
||||||
// Prevent Plex from auto-selecting and burning tuner captions into the video.
|
// Deliberately NOT the VOD policy, which burns the selected embedded
|
||||||
// Captions that survive direct stream remain player-selectable text tracks.
|
// stream. This path sets `directStream: 1` above, so Plex copies the
|
||||||
|
// video rather than re-encoding it: burning here would force a full
|
||||||
|
// re-encode of a live stream for a caption track that already arrives
|
||||||
|
// for free. Broadcast captions (CEA-608/708) ride inside the copied
|
||||||
|
// video bitstream and stay player-selectable, so there is nothing to
|
||||||
|
// deliver and no stream id to send. Asking for a burn would also let
|
||||||
|
// Plex auto-select a caption track the viewer never chose.
|
||||||
|
//
|
||||||
|
// Not covered: a DVB tuner's bitmap subtitles are separate streams
|
||||||
|
// rather than in-band, so whether they survive the remux is unverified
|
||||||
|
// and needs a DVB source to check.
|
||||||
'subtitles': 'none',
|
'subtitles': 'none',
|
||||||
'copyts': '0',
|
'copyts': '0',
|
||||||
'Accept-Language': 'en',
|
'Accept-Language': 'en',
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ typedef TrackPreferencePersister =
|
|||||||
/// automatic track selection, server preference sync, and cycling.
|
/// automatic track selection, server preference sync, and cycling.
|
||||||
///
|
///
|
||||||
/// Follows the same manager pattern as [VideoFilterManager]:
|
/// Follows the same manager pattern as [VideoFilterManager]:
|
||||||
/// constructed with a [Player] + callbacks, mutated via public setters,
|
|
||||||
/// disposed when the player screen tears down.
|
/// disposed when the player screen tears down.
|
||||||
class TrackManager {
|
class TrackManager {
|
||||||
final Player player;
|
final Player player;
|
||||||
@@ -54,6 +53,10 @@ class TrackManager {
|
|||||||
SubtitlePreference? preferredSubtitleTrack;
|
SubtitlePreference? preferredSubtitleTrack;
|
||||||
SubtitlePreference? preferredSecondarySubtitleTrack;
|
SubtitlePreference? preferredSecondarySubtitleTrack;
|
||||||
|
|
||||||
|
/// The primary subtitle is burned into the video by the server, so it needs no native track and
|
||||||
|
/// none is ever coming. Set on a transcode whose selected row has no sidecar.
|
||||||
|
bool primarySubtitleIsServerRendered = false;
|
||||||
|
|
||||||
// ── Internal state ─────────────────────────────────────────────────
|
// ── Internal state ─────────────────────────────────────────────────
|
||||||
|
|
||||||
bool waitingForExternalSubsTrackSelection = false;
|
bool waitingForExternalSubsTrackSelection = false;
|
||||||
@@ -99,6 +102,7 @@ class TrackManager {
|
|||||||
this.preferredAudioTrack,
|
this.preferredAudioTrack,
|
||||||
this.preferredSubtitleTrack,
|
this.preferredSubtitleTrack,
|
||||||
this.preferredSecondarySubtitleTrack,
|
this.preferredSecondarySubtitleTrack,
|
||||||
|
this.primarySubtitleIsServerRendered = false,
|
||||||
this.showMessage,
|
this.showMessage,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,16 +273,35 @@ class TrackManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bool _tracksReadyForSelection(Tracks tracks) {
|
bool _tracksReadyForSelection(Tracks tracks) {
|
||||||
|
final realSubtitleTracks = tracks.subtitle
|
||||||
|
.where((track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id)
|
||||||
|
.toList(growable: false);
|
||||||
|
final service = TrackSelectionService(metadata: metadata, plexMediaInfo: mediaInfo);
|
||||||
|
|
||||||
|
// A burned-in primary is already in the picture, so no native track is ever coming for it.
|
||||||
|
// Waiting for one holds up audio and rate setup for the five-second fallback and then logs a
|
||||||
|
// missed deadline twenty-five seconds later, for a selection already on screen.
|
||||||
|
//
|
||||||
|
// Answered before the empty-list guard below: a silent video whose primary is burned and whose
|
||||||
|
// secondary is unset legitimately exposes no tracks at all, and treating that as "not ready"
|
||||||
|
// spent both waits plus selection's own ten seconds on a catalog that was already complete.
|
||||||
|
//
|
||||||
|
// A carried *secondary* is a real native track that may still be on its way, though, and only
|
||||||
|
// one selection pass ever runs - so retiring the wait here would drop it for good. Neither is
|
||||||
|
// audio: the source can advertise tracks the native catalog has not published yet, and answering
|
||||||
|
// "ready" on the subtitle question alone retired the listener before they arrived, leaving the
|
||||||
|
// preferred track unselected and playback on the engine's default.
|
||||||
|
if (primarySubtitleIsServerRendered) {
|
||||||
|
if (!_secondaryPreferenceResolves(service, realSubtitleTracks)) return false;
|
||||||
|
return !_awaitingAdvertisedAudio(tracks);
|
||||||
|
}
|
||||||
|
|
||||||
final hasAnyTracks = tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty;
|
final hasAnyTracks = tracks.audio.isNotEmpty || tracks.subtitle.isNotEmpty;
|
||||||
if (!hasAnyTracks) return false;
|
if (!hasAnyTracks) return false;
|
||||||
|
|
||||||
final realAudioTracks = tracks.audio
|
final realAudioTracks = tracks.audio
|
||||||
.where((track) => track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id)
|
.where((track) => track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id)
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
final realSubtitleTracks = tracks.subtitle
|
|
||||||
.where((track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id)
|
|
||||||
.toList(growable: false);
|
|
||||||
final service = TrackSelectionService(metadata: metadata, plexMediaInfo: mediaInfo);
|
|
||||||
final selectedAudioTrack = service.selectAudioTrack(realAudioTracks, preferredAudioTrack)?.track;
|
final selectedAudioTrack = service.selectAudioTrack(realAudioTracks, preferredAudioTrack)?.track;
|
||||||
|
|
||||||
// Selection owns the catalog-completeness decision. A null subtitle result
|
// Selection owns the catalog-completeness decision. A null subtitle result
|
||||||
@@ -286,6 +309,33 @@ class TrackManager {
|
|||||||
return service.selectSubtitleTrack(realSubtitleTracks, preferredSubtitleTrack, selectedAudioTrack) != null;
|
return service.selectSubtitleTrack(realSubtitleTracks, preferredSubtitleTrack, selectedAudioTrack) != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the carried secondary subtitle, if there is one, has a native track to land on.
|
||||||
|
/// Vacuously true when none is wanted, or when the backend has no secondary lane at all.
|
||||||
|
bool _secondaryPreferenceResolves(TrackSelectionService service, List<SubtitleTrack> realSubtitleTracks) {
|
||||||
|
final preference = preferredSecondarySubtitleTrack;
|
||||||
|
if (preference == null || preference is SubtitleOffPreference) return true;
|
||||||
|
if (!player.supportsSecondarySubtitles) return true;
|
||||||
|
final match = switch (preference) {
|
||||||
|
SubtitleOffPreference() => null,
|
||||||
|
SubtitleTrackPreference(:final track) =>
|
||||||
|
track.id == 'no' ? null : service.findBestSubtitleMatch(realSubtitleTracks, track),
|
||||||
|
SubtitleIntentPreference(:final intent) => findNativeTrackForIntent(intent, realSubtitleTracks),
|
||||||
|
};
|
||||||
|
return match != null && match.id != 'no';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the source advertises audio the native catalog has not published yet.
|
||||||
|
///
|
||||||
|
/// Only asked on the burned-subtitle shortcut, which otherwise answers the
|
||||||
|
/// subtitle question alone and would retire the track listener while audio was
|
||||||
|
/// still arriving - leaving the preferred track unselected. A source that
|
||||||
|
/// advertises none (a genuinely silent video) is never waited for.
|
||||||
|
bool _awaitingAdvertisedAudio(Tracks tracks) {
|
||||||
|
final sourceAdvertisesAudio = mediaInfo?.audioTracks.isNotEmpty ?? false;
|
||||||
|
if (!sourceAdvertisesAudio) return false;
|
||||||
|
return !tracks.audio.any((track) => track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id);
|
||||||
|
}
|
||||||
|
|
||||||
/// Core track selection: delegates to [TrackSelectionService]. Returns
|
/// Core track selection: delegates to [TrackSelectionService]. Returns
|
||||||
/// whether every player mutation completed for this still-active owner.
|
/// whether every player mutation completed for this still-active owner.
|
||||||
///
|
///
|
||||||
@@ -337,6 +387,7 @@ class TrackManager {
|
|||||||
isActive: selectionIsActive,
|
isActive: selectionIsActive,
|
||||||
onPlayerMutationDispatched: _trackDispatchedPlayerMutation,
|
onPlayerMutationDispatched: _trackDispatchedPlayerMutation,
|
||||||
waitForPendingSource: waitForPendingSource,
|
waitForPendingSource: waitForPendingSource,
|
||||||
|
primarySubtitleIsServerRendered: primarySubtitleIsServerRendered,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.w('Failed to apply track selection', error: e);
|
appLogger.w('Failed to apply track selection', error: e);
|
||||||
|
|||||||
@@ -916,6 +916,23 @@ class TrackSelectionService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (preferred.id.startsWith('source:')) {
|
if (preferred.id.startsWith('source:')) {
|
||||||
|
// A source row delivered as its own *file* carries that file's URL on the preference, and the
|
||||||
|
// loaded track is external. `findMpvTrackForPlexSubtitle` pairs a source row with the
|
||||||
|
// container's own tracks by metadata, so it cannot see that external track at all - which
|
||||||
|
// left an extracted secondary waiting out the deadline and never appearing. The URL is both
|
||||||
|
// stronger and unambiguous, so it is tried first; a unique hit is the same file by
|
||||||
|
// definition, whatever id either side chose for it.
|
||||||
|
//
|
||||||
|
// Container tracks are excluded on purpose: several source rows share one container URL, so a
|
||||||
|
// URL hit there says nothing about *which* row it is, and the metadata matcher below is what
|
||||||
|
// waits for the intended one to be discovered.
|
||||||
|
final sidecarUri = preferred.uri;
|
||||||
|
if (sidecarUri != null && sidecarUri.isNotEmpty) {
|
||||||
|
final uriMatches = availableTracks
|
||||||
|
.where((track) => track.uri == sidecarUri && !track.isContainer)
|
||||||
|
.toList(growable: false);
|
||||||
|
if (uriMatches.length == 1) return uriMatches.single;
|
||||||
|
}
|
||||||
final sourceTrack = _sourceSubtitleTrack(preferred.id);
|
final sourceTrack = _sourceSubtitleTrack(preferred.id);
|
||||||
if (sourceTrack == null) return null;
|
if (sourceTrack == null) return null;
|
||||||
return findMpvTrackForPlexSubtitle(sourceTrack, availableTracks, allPlexTracks: plexMediaInfo?.subtitleTracks);
|
return findMpvTrackForPlexSubtitle(sourceTrack, availableTracks, allPlexTracks: plexMediaInfo?.subtitleTracks);
|
||||||
@@ -1234,6 +1251,11 @@ class TrackSelectionService {
|
|||||||
bool Function()? isActive,
|
bool Function()? isActive,
|
||||||
void Function(Future<void> mutation)? onPlayerMutationDispatched,
|
void Function(Future<void> mutation)? onPlayerMutationDispatched,
|
||||||
bool waitForPendingSource = true,
|
bool waitForPendingSource = true,
|
||||||
|
|
||||||
|
/// The primary is painted into the picture, so no native subtitle track is
|
||||||
|
/// coming for it. With no secondary wanted either, a silent video legitimately
|
||||||
|
/// exposes no tracks at all and the wait below can only time out.
|
||||||
|
bool primarySubtitleIsServerRendered = false,
|
||||||
}) async {
|
}) async {
|
||||||
final player = this.player;
|
final player = this.player;
|
||||||
if (player == null) {
|
if (player == null) {
|
||||||
@@ -1243,8 +1265,14 @@ class TrackSelectionService {
|
|||||||
|
|
||||||
if (!canMutatePlayer()) return false;
|
if (!canMutatePlayer()) return false;
|
||||||
|
|
||||||
// Wait for tracks to be loaded
|
// Wait for tracks to be loaded, unless nothing can arrive: a burned-in primary with no
|
||||||
if (player.state.tracks.audio.isEmpty && player.state.tracks.subtitle.isEmpty) {
|
// secondary wanted has a complete catalog at zero tracks, and waiting ten seconds for one
|
||||||
|
// held the saved playback rate back with it. An explicit off is as settled as an absent
|
||||||
|
// preference, which is how `TrackManager._secondaryPreferenceResolves` reads it too.
|
||||||
|
final nothingToWaitFor =
|
||||||
|
primarySubtitleIsServerRendered &&
|
||||||
|
(preferredSecondarySubtitleTrack == null || preferredSecondarySubtitleTrack is SubtitleOffPreference);
|
||||||
|
if (!nothingToWaitFor && player.state.tracks.audio.isEmpty && player.state.tracks.subtitle.isEmpty) {
|
||||||
try {
|
try {
|
||||||
await player.streams.tracks
|
await player.streams.tracks
|
||||||
.where((t) => t.audio.isNotEmpty || t.subtitle.isNotEmpty)
|
.where((t) => t.audio.isNotEmpty || t.subtitle.isNotEmpty)
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ class CodecUtils {
|
|||||||
'dvdsub' ||
|
'dvdsub' ||
|
||||||
'vobsub' ||
|
'vobsub' ||
|
||||||
'dvb_sub' ||
|
'dvb_sub' ||
|
||||||
|
// Jellyfin's own spelling, which is what the transcode profile asks it to burn.
|
||||||
|
'dvbsub' ||
|
||||||
'dvb_subtitle' => true,
|
'dvb_subtitle' => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A burned-in subtitle is pixels rather than a track: there is nothing selected to hide, and
|
||||||
|
// `setSubtitleVisibility` could not remove painted pixels anyway. Only a new negotiation can,
|
||||||
|
// and that is a real subtitle *choice* - it re-encodes the stream and the server remembers it.
|
||||||
|
// Doing that behind a transient visibility shortcut would silently overwrite the viewer's saved
|
||||||
|
// selection with Off, so the shortcut says where the control actually lives instead of
|
||||||
|
// pretending to work or doing nothing at all.
|
||||||
|
if (_hasBurnedSourceSubtitle()) {
|
||||||
|
showAppSnackBar(context, t.messages.burnedSubtitlesUseMenu);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final currentTrack = widget.player.state.track.subtitle;
|
final currentTrack = widget.player.state.track.subtitle;
|
||||||
// Nothing to hide when no subtitle track is selected.
|
// Nothing to hide when no subtitle track is selected.
|
||||||
if (currentTrack == null || currentTrack.id == SubtitleTrack.off.id) return;
|
if (currentTrack == null || currentTrack.id == SubtitleTrack.off.id) return;
|
||||||
@@ -19,6 +30,26 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
|||||||
_setSubtitleVisibility(false);
|
_setSubtitleVisibility(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the server burned the selected subtitle into the picture.
|
||||||
|
///
|
||||||
|
/// The same rule the player screen applies to a subtitle *change*, asked with an off target: only
|
||||||
|
/// a burned current selection forces the server's hand, and a selection delivered as a file stays
|
||||||
|
/// an ordinary native track the player can hide itself. Shared rather than restated so the two
|
||||||
|
/// cannot drift.
|
||||||
|
bool _hasBurnedSourceSubtitle() {
|
||||||
|
final choice = widget.selectedSubtitleChoice;
|
||||||
|
final sourceStreamId = choice != null && !choice.isOff ? choice.sourceStreamId : null;
|
||||||
|
return PlaybackSubtitleResolver.burnRequiresRenegotiation(
|
||||||
|
isTranscoding: widget.isTranscoding,
|
||||||
|
currentSourceStreamId: sourceStreamId,
|
||||||
|
currentSelectionHasSidecar:
|
||||||
|
sourceStreamId != null &&
|
||||||
|
widget.sourceSubtitleSidecars.any((sidecar) => sidecar.sourceStreamId == sourceStreamId),
|
||||||
|
targetIsOff: true,
|
||||||
|
targetIsExternalFile: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
void _onSubtitleTrackChanged(SubtitleTrack track) {
|
void _onSubtitleTrackChanged(SubtitleTrack track) {
|
||||||
// Reset visibility when user explicitly picks a new subtitle track
|
// Reset visibility when user explicitly picks a new subtitle track
|
||||||
if (track.id != 'no' && !_subtitlesVisible) {
|
if (track.id != 'no' && !_subtitlesVisible) {
|
||||||
|
|||||||
@@ -105,13 +105,15 @@ part 'parts/visibility.dart';
|
|||||||
/// Subtitle tracks offered in the player's "source" subtitle list.
|
/// Subtitle tracks offered in the player's "source" subtitle list.
|
||||||
///
|
///
|
||||||
/// Direct play exposes embedded tracks in the native player and can attach
|
/// Direct play exposes embedded tracks in the native player and can attach
|
||||||
/// arbitrary sidecars itself. A transcode can only deliver external sidecars
|
/// arbitrary sidecars itself. A transcode can only deliver a resolved sidecar or
|
||||||
/// or embedded codecs that the server can convert/burn into the rendition.
|
/// an embedded codec the server can burn into the rendition — which every
|
||||||
|
/// backend can be asked to do (Plex `subtitles=burn`, Jellyfin/Emby an `Encode`
|
||||||
|
/// subtitle profile plus `SubtitleStreamIndex`), so the codec is the only
|
||||||
|
/// discriminator here.
|
||||||
List<MediaSubtitleTrack> selectableSourceSubtitleTracks(
|
List<MediaSubtitleTrack> selectableSourceSubtitleTracks(
|
||||||
List<MediaSubtitleTrack> tracks, {
|
List<MediaSubtitleTrack> tracks, {
|
||||||
required bool isTranscoding,
|
required bool isTranscoding,
|
||||||
required Set<int> sidecarSourceIds,
|
required Set<int> sidecarSourceIds,
|
||||||
required bool supportsEmbeddedTranscodeSelection,
|
|
||||||
}) {
|
}) {
|
||||||
if (!isTranscoding) {
|
if (!isTranscoding) {
|
||||||
return tracks
|
return tracks
|
||||||
@@ -123,11 +125,24 @@ List<MediaSubtitleTrack> selectableSourceSubtitleTracks(
|
|||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
return tracks
|
return tracks
|
||||||
.where(
|
.where((track) {
|
||||||
(track) =>
|
if (sidecarSourceIds.contains(track.id)) return true;
|
||||||
sidecarSourceIds.contains(track.id) ||
|
// A row the client has to fetch itself is selectable only once its sidecar resolved: when that
|
||||||
(supportsEmbeddedTranscodeSelection && CodecUtils.isTranscodableSubtitleCodec(track.codec)),
|
// build failed nothing can put it on screen and reloading cannot help.
|
||||||
)
|
//
|
||||||
|
// Bitmaps are not fetched at all. The transcode profile offers image formats as `Embed` with
|
||||||
|
// no `External` entry, so the server burns them into the picture - external files included -
|
||||||
|
// and they never get a sidecar by design. Gating those on one made the active track vanish
|
||||||
|
// from the picker while its captions were still burned into the video, with no row left to
|
||||||
|
// switch or turn off. So burn eligibility is decided by the codec, not by where the row came
|
||||||
|
// from.
|
||||||
|
final burnedByServer = CodecUtils.isImageSubtitleCodec(track.codec);
|
||||||
|
final requiresSidecar =
|
||||||
|
!burnedByServer &&
|
||||||
|
(track.isExternalFile || (!track.usesExternalDelivery && track.key != null && track.key!.isNotEmpty));
|
||||||
|
if (requiresSidecar) return false;
|
||||||
|
return CodecUtils.isTranscodableSubtitleCodec(track.codec);
|
||||||
|
})
|
||||||
.toList(growable: false);
|
.toList(growable: false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -828,14 +828,444 @@ void main() {
|
|||||||
expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
|
expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
|
||||||
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
|
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
|
||||||
expect(result.mediaInfo!.subtitleTracks.single.isExternalFile, isFalse);
|
expect(result.mediaInfo!.subtitleTracks.single.isExternalFile, isFalse);
|
||||||
expect(result.mediaInfo!.subtitleTracks.single.usesExternalDelivery, isTrue);
|
// Nothing is selected and the fixture declares no default, so the server burns nothing and
|
||||||
expect(result.externalSubtitles, hasLength(1));
|
// this embedded row stays fetchable as an extracted file.
|
||||||
expect(result.subtitleSidecars.single.sourceStreamId, 2);
|
expect(result.subtitleSidecars.single.sourceStreamId, 2);
|
||||||
expect(result.externalSubtitles.single.title, 'English');
|
expect(result.externalSubtitles.single.title, 'English');
|
||||||
expect(result.externalSubtitles.single.language, 'eng');
|
expect(result.externalSubtitles.single.language, 'eng');
|
||||||
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
|
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
|
||||||
expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/2/Stream.srt');
|
expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/2/Stream.srt');
|
||||||
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
|
});
|
||||||
|
|
||||||
|
/// Jellyfin picks a subtitle's delivery from the profile, and matches an external profile on
|
||||||
|
/// text-vs-image format without ever consulting whether the stream is embedded or a real file.
|
||||||
|
/// So withholding `External` is the only lever that makes it burn, and offering it at all is
|
||||||
|
/// what made an embedded stream arrive as a sidecar on a transcode.
|
||||||
|
test('subtitle delivery profile withholds External only when a transcode is requested', () async {
|
||||||
|
Future<List<Map<String, Object?>>> profileFor({required bool original}) async {
|
||||||
|
final bodies = <String>[];
|
||||||
|
final scoped = JellyfinClient.forTesting(
|
||||||
|
connection: _conn(),
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
if (request.url.path == '/Users/user-1/Items/item-1') {
|
||||||
|
return jsonResponse({
|
||||||
|
'Id': 'item-1',
|
||||||
|
'Type': 'Movie',
|
||||||
|
'Name': 'Movie',
|
||||||
|
'MediaSources': [
|
||||||
|
{'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': []},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.url.path == '/Items/item-1/PlaybackInfo') {
|
||||||
|
bodies.add(request.body);
|
||||||
|
return jsonResponse({
|
||||||
|
'MediaSources': [
|
||||||
|
{'Id': 'src-1', 'Container': 'mkv', 'MediaStreams': []},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return http.Response('not used', 500);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(scoped.close);
|
||||||
|
await scoped.getPlaybackInitialization(
|
||||||
|
PlaybackInitializationOptions(
|
||||||
|
metadata: testMediaItem(
|
||||||
|
id: 'item-1',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.movie,
|
||||||
|
serverId: 'srv-1',
|
||||||
|
),
|
||||||
|
selectedMediaIndex: 0,
|
||||||
|
qualityPreset: original ? TranscodeQualityPreset.original : TranscodeQualityPreset.p720_2mbps,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final body = jsonDecode(bodies.single) as Map<String, dynamic>;
|
||||||
|
final profile = body['DeviceProfile'] as Map<String, dynamic>;
|
||||||
|
return (profile['SubtitleProfiles'] as List).cast<Map<String, Object?>>();
|
||||||
|
}
|
||||||
|
|
||||||
|
final capped = await profileFor(original: false);
|
||||||
|
expect(
|
||||||
|
capped.where((entry) => entry['Method'] == 'External'),
|
||||||
|
isEmpty,
|
||||||
|
reason: 'a transcode must find no external profile so it falls through to Encode',
|
||||||
|
);
|
||||||
|
expect(capped.where((entry) => entry['Method'] == 'Embed'), isNotEmpty);
|
||||||
|
|
||||||
|
final untouched = await profileFor(original: true);
|
||||||
|
expect(
|
||||||
|
untouched.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']),
|
||||||
|
containsAll(<String>['srt', 'ass', 'ssa', 'vtt']),
|
||||||
|
reason: 'direct playback keeps text External, which is how a real file is delivered',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
untouched.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']),
|
||||||
|
isNot(contains('pgssub')),
|
||||||
|
reason: 'an image format the client cannot render is never offered as a file',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The two rules are only expressible per request, so the selection decides: an embedded
|
||||||
|
/// stream is burned, a real external file is still delivered as a file. Both on a transcode.
|
||||||
|
test('a selected external file keeps External and is still sidecarred on a transcode', () async {
|
||||||
|
final bodies = <String>[];
|
||||||
|
const externalStream = {
|
||||||
|
'Index': 3,
|
||||||
|
'Type': 'Subtitle',
|
||||||
|
'Codec': 'srt',
|
||||||
|
'Language': 'eng',
|
||||||
|
'DisplayTitle': 'English - SRT',
|
||||||
|
'IsExternal': true,
|
||||||
|
'DeliveryMethod': 'External',
|
||||||
|
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt',
|
||||||
|
};
|
||||||
|
final scoped = JellyfinClient.forTesting(
|
||||||
|
connection: _conn(),
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
if (request.url.path == '/Users/user-1/Items/item-1') {
|
||||||
|
return jsonResponse({
|
||||||
|
'Id': 'item-1',
|
||||||
|
'Type': 'Movie',
|
||||||
|
'Name': 'Movie',
|
||||||
|
'MediaSources': [
|
||||||
|
{
|
||||||
|
'Id': 'src-1',
|
||||||
|
'Container': 'mkv',
|
||||||
|
'MediaStreams': [externalStream],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.url.path == '/Items/item-1/PlaybackInfo') {
|
||||||
|
bodies.add(request.body);
|
||||||
|
return jsonResponse({
|
||||||
|
'MediaSources': [
|
||||||
|
{
|
||||||
|
'Id': 'src-1',
|
||||||
|
'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=s1',
|
||||||
|
'MediaStreams': [externalStream],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return http.Response('not used', 500);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(scoped.close);
|
||||||
|
|
||||||
|
final result = await scoped.getPlaybackInitialization(
|
||||||
|
PlaybackInitializationOptions(
|
||||||
|
metadata: testMediaItem(
|
||||||
|
id: 'item-1',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.movie,
|
||||||
|
serverId: 'srv-1',
|
||||||
|
),
|
||||||
|
selectedMediaIndex: 0,
|
||||||
|
qualityPreset: TranscodeQualityPreset.p720_2mbps,
|
||||||
|
preferredSubtitleTrack: const SubtitlePreference.intent(
|
||||||
|
SubtitleIntent(language: 'eng', forced: false, title: 'English - SRT', codec: 'srt', isExternal: true),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isTranscoding, isTrue);
|
||||||
|
final profile =
|
||||||
|
((jsonDecode(bodies.single) as Map<String, dynamic>)['DeviceProfile']
|
||||||
|
as Map<String, dynamic>)['SubtitleProfiles']
|
||||||
|
as List;
|
||||||
|
expect(
|
||||||
|
profile.cast<Map<String, Object?>>().where((entry) => entry['Method'] == 'External'),
|
||||||
|
isNotEmpty,
|
||||||
|
reason: 'burning a file the client already holds would be a re-encode for nothing',
|
||||||
|
);
|
||||||
|
expect(result.subtitleSidecars.single.sourceStreamId, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The normal launch path sends no preferred track and lets the server's default decide, so the
|
||||||
|
/// burn decision has to read the effective selection. Reading only the explicit request burned
|
||||||
|
/// a default that is a real file *and* fetched it as a sidecar -- the same subtitle twice, over
|
||||||
|
/// a transcode nobody needed.
|
||||||
|
test('a defaulted external file is not burned and is not duplicated', () async {
|
||||||
|
final bodies = <String>[];
|
||||||
|
const externalStream = {
|
||||||
|
'Index': 3,
|
||||||
|
'Type': 'Subtitle',
|
||||||
|
'Codec': 'srt',
|
||||||
|
'Language': 'eng',
|
||||||
|
'DisplayTitle': 'English - SRT',
|
||||||
|
'IsExternal': true,
|
||||||
|
'DeliveryMethod': 'External',
|
||||||
|
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt',
|
||||||
|
};
|
||||||
|
const source = {
|
||||||
|
'Id': 'src-1',
|
||||||
|
'Container': 'mkv',
|
||||||
|
'DefaultSubtitleStreamIndex': 3,
|
||||||
|
'MediaStreams': [externalStream],
|
||||||
|
};
|
||||||
|
final scoped = JellyfinClient.forTesting(
|
||||||
|
connection: _conn(),
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
if (request.url.path == '/Users/user-1/Items/item-1') {
|
||||||
|
return jsonResponse({
|
||||||
|
'Id': 'item-1',
|
||||||
|
'Type': 'Movie',
|
||||||
|
'Name': 'Movie',
|
||||||
|
'MediaSources': [source],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.url.path == '/Items/item-1/PlaybackInfo') {
|
||||||
|
bodies.add(request.body);
|
||||||
|
return jsonResponse({
|
||||||
|
'MediaSources': [
|
||||||
|
{...source, 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=s1'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return http.Response('not used', 500);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(scoped.close);
|
||||||
|
|
||||||
|
final result = await scoped.getPlaybackInitialization(
|
||||||
|
PlaybackInitializationOptions(
|
||||||
|
metadata: testMediaItem(
|
||||||
|
id: 'item-1',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.movie,
|
||||||
|
serverId: 'srv-1',
|
||||||
|
),
|
||||||
|
selectedMediaIndex: 0,
|
||||||
|
qualityPreset: TranscodeQualityPreset.p720_2mbps,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isTranscoding, isTrue);
|
||||||
|
final profile =
|
||||||
|
((jsonDecode(bodies.single) as Map<String, dynamic>)['DeviceProfile']
|
||||||
|
as Map<String, dynamic>)['SubtitleProfiles']
|
||||||
|
as List;
|
||||||
|
expect(
|
||||||
|
profile.cast<Map<String, Object?>>().where((entry) => entry['Method'] == 'External'),
|
||||||
|
isNotEmpty,
|
||||||
|
reason: 'the default selection is a real file, so it must not be burned',
|
||||||
|
);
|
||||||
|
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// The profile only ever offers `External` for text, so a bitmap falls through to `Encode` and
|
||||||
|
/// is burned whatever the client asks for -- an external bitmap *file* included. Treating one as
|
||||||
|
/// externally delivered left the client fetching a copy of pixels already in the video, and let
|
||||||
|
/// "off" stay local over a burn.
|
||||||
|
test('an external bitmap file is treated as burned, not fetched', () async {
|
||||||
|
final bodies = <String>[];
|
||||||
|
const externalBitmap = {
|
||||||
|
'Index': 3,
|
||||||
|
'Type': 'Subtitle',
|
||||||
|
'Codec': 'pgssub',
|
||||||
|
'Language': 'eng',
|
||||||
|
'DisplayTitle': 'English - PGS',
|
||||||
|
'IsExternal': true,
|
||||||
|
'DeliveryMethod': 'External',
|
||||||
|
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.sup',
|
||||||
|
};
|
||||||
|
const source = {
|
||||||
|
'Id': 'src-1',
|
||||||
|
'Container': 'mkv',
|
||||||
|
'DefaultSubtitleStreamIndex': 3,
|
||||||
|
'MediaStreams': [externalBitmap],
|
||||||
|
};
|
||||||
|
final scoped = JellyfinClient.forTesting(
|
||||||
|
connection: _conn(),
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
if (request.url.path == '/Users/user-1/Items/item-1') {
|
||||||
|
return jsonResponse({
|
||||||
|
'Id': 'item-1',
|
||||||
|
'Type': 'Movie',
|
||||||
|
'Name': 'Movie',
|
||||||
|
'MediaSources': [source],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.url.path == '/Items/item-1/PlaybackInfo') {
|
||||||
|
bodies.add(request.body);
|
||||||
|
return jsonResponse({
|
||||||
|
'MediaSources': [
|
||||||
|
{...source, 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=s1'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return http.Response('not used', 500);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(scoped.close);
|
||||||
|
|
||||||
|
final result = await scoped.getPlaybackInitialization(
|
||||||
|
PlaybackInitializationOptions(
|
||||||
|
metadata: testMediaItem(
|
||||||
|
id: 'item-1',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.movie,
|
||||||
|
serverId: 'srv-1',
|
||||||
|
),
|
||||||
|
selectedMediaIndex: 0,
|
||||||
|
qualityPreset: TranscodeQualityPreset.p720_2mbps,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isTranscoding, isTrue);
|
||||||
|
final profile =
|
||||||
|
((jsonDecode(bodies.single) as Map<String, dynamic>)['DeviceProfile']
|
||||||
|
as Map<String, dynamic>)['SubtitleProfiles']
|
||||||
|
as List;
|
||||||
|
expect(
|
||||||
|
profile.cast<Map<String, Object?>>().where((entry) => entry['Method'] == 'External'),
|
||||||
|
isEmpty,
|
||||||
|
reason: 'a bitmap cannot be delivered externally, so nothing is gained by offering it',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.subtitleSidecars,
|
||||||
|
isEmpty,
|
||||||
|
reason: 'the server burns it in, so fetching the file would draw it twice',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Unburned embedded rows are fetchable so a secondary track can still render, but only text
|
||||||
|
/// ones: a separate bitmap stream is not renderable alongside a transcode, which is exactly why
|
||||||
|
/// the profile withholds `External` for those formats in the first place.
|
||||||
|
test('an unburned embedded bitmap row is not offered as a sidecar', () async {
|
||||||
|
const textRow = {
|
||||||
|
'Index': 2,
|
||||||
|
'Type': 'Subtitle',
|
||||||
|
'Codec': 'ass',
|
||||||
|
'Language': 'eng',
|
||||||
|
'DisplayTitle': 'English - ASS',
|
||||||
|
};
|
||||||
|
const bitmapRow = {
|
||||||
|
'Index': 3,
|
||||||
|
'Type': 'Subtitle',
|
||||||
|
'Codec': 'pgssub',
|
||||||
|
'Language': 'swe',
|
||||||
|
'DisplayTitle': 'Swedish - PGS',
|
||||||
|
};
|
||||||
|
const source = {
|
||||||
|
'Id': 'src-1',
|
||||||
|
'Container': 'mkv',
|
||||||
|
'DefaultSubtitleStreamIndex': 2,
|
||||||
|
'MediaStreams': [textRow, bitmapRow],
|
||||||
|
};
|
||||||
|
final scoped = JellyfinClient.forTesting(
|
||||||
|
connection: _conn(),
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
if (request.url.path == '/Users/user-1/Items/item-1') {
|
||||||
|
return jsonResponse({
|
||||||
|
'Id': 'item-1',
|
||||||
|
'Type': 'Movie',
|
||||||
|
'Name': 'Movie',
|
||||||
|
'MediaSources': [source],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.url.path == '/Items/item-1/PlaybackInfo') {
|
||||||
|
return jsonResponse({
|
||||||
|
'MediaSources': [
|
||||||
|
{...source, 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=s1'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return http.Response('not used', 500);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(scoped.close);
|
||||||
|
|
||||||
|
final result = await scoped.getPlaybackInitialization(
|
||||||
|
PlaybackInitializationOptions(
|
||||||
|
metadata: testMediaItem(
|
||||||
|
id: 'item-1',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.movie,
|
||||||
|
serverId: 'srv-1',
|
||||||
|
),
|
||||||
|
selectedMediaIndex: 0,
|
||||||
|
qualityPreset: TranscodeQualityPreset.p720_2mbps,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isTranscoding, isTrue);
|
||||||
|
expect(
|
||||||
|
result.subtitleSidecars,
|
||||||
|
isEmpty,
|
||||||
|
reason: 'stream 2 is burned in and stream 3 is a bitmap nothing could draw',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Jellyfin reports SRT as `subrip` and WebVTT as `webvtt`, but its extraction endpoint keys off
|
||||||
|
/// the format, so the raw codec name asks for a file that does not exist. Only load-bearing
|
||||||
|
/// since extracted rows without a `DeliveryUrl` started being fetched.
|
||||||
|
test('an extracted row uses the endpoint format, not the reported codec name', () async {
|
||||||
|
const burnedPrimary = {
|
||||||
|
'Index': 2,
|
||||||
|
'Type': 'Subtitle',
|
||||||
|
'Codec': 'ass',
|
||||||
|
'Language': 'eng',
|
||||||
|
'DisplayTitle': 'English - ASS',
|
||||||
|
};
|
||||||
|
const aliasedSecondary = {
|
||||||
|
'Index': 3,
|
||||||
|
'Type': 'Subtitle',
|
||||||
|
'Codec': 'subrip',
|
||||||
|
'Language': 'swe',
|
||||||
|
'DisplayTitle': 'Swedish - SRT',
|
||||||
|
};
|
||||||
|
const source = {
|
||||||
|
'Id': 'src-1',
|
||||||
|
'Container': 'mkv',
|
||||||
|
'DefaultSubtitleStreamIndex': 2,
|
||||||
|
'MediaStreams': [burnedPrimary, aliasedSecondary],
|
||||||
|
};
|
||||||
|
final scoped = JellyfinClient.forTesting(
|
||||||
|
connection: _conn(),
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
if (request.url.path == '/Users/user-1/Items/item-1') {
|
||||||
|
return jsonResponse({
|
||||||
|
'Id': 'item-1',
|
||||||
|
'Type': 'Movie',
|
||||||
|
'Name': 'Movie',
|
||||||
|
'MediaSources': [source],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (request.url.path == '/Items/item-1/PlaybackInfo') {
|
||||||
|
return jsonResponse({
|
||||||
|
'MediaSources': [
|
||||||
|
{...source, 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=s1'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return http.Response('not used', 500);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(scoped.close);
|
||||||
|
|
||||||
|
final result = await scoped.getPlaybackInitialization(
|
||||||
|
PlaybackInitializationOptions(
|
||||||
|
metadata: testMediaItem(
|
||||||
|
id: 'item-1',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.movie,
|
||||||
|
serverId: 'srv-1',
|
||||||
|
),
|
||||||
|
selectedMediaIndex: 0,
|
||||||
|
qualityPreset: TranscodeQualityPreset.p720_2mbps,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.subtitleSidecars.single.sourceStreamId, 3);
|
||||||
|
expect(
|
||||||
|
Uri.parse(result.externalSubtitles.single.uri!).path,
|
||||||
|
'/Videos/item-1/src-1/Subtitles/3/Stream.srt',
|
||||||
|
reason: '`subrip` is the codec name; `srt` is what the endpoint serves',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getPlaybackInitialization strips sidecar identity from direct-played embedded subtitles', () async {
|
test('getPlaybackInitialization strips sidecar identity from direct-played embedded subtitles', () async {
|
||||||
@@ -1185,13 +1615,11 @@ void main() {
|
|||||||
|
|
||||||
expect(result.playMethod, 'Transcode');
|
expect(result.playMethod, 'Transcode');
|
||||||
expect(result.mediaInfo!.subtitleTracks, hasLength(3));
|
expect(result.mediaInfo!.subtitleTracks, hasLength(3));
|
||||||
expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue);
|
// The last request matched no row, so the effective selection is the server's default
|
||||||
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4, 5]);
|
// (`DefaultSubtitleStreamIndex: 4`) and that is the stream the server burns in. It is the
|
||||||
expect(result.externalSubtitles.map((subtitle) => Uri.parse(subtitle.uri!).path), [
|
// one row not fetched: a sidecar for it would paint a second copy over the burned pixels.
|
||||||
'/Videos/item-1/src-1/Subtitles/3/Stream.srt',
|
// The other two stay fetchable, which is what keeps a secondary track renderable.
|
||||||
'/Videos/item-1/src-1/Subtitles/4/Stream.srt',
|
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 5]);
|
||||||
'/Videos/item-1/src-1/Subtitles/5/Stream.srt',
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async {
|
test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async {
|
||||||
@@ -1736,6 +2164,32 @@ void main() {
|
|||||||
expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-1');
|
expect(Uri.parse(result.videoUrl!).queryParameters['MediaSourceId'], 'src-1');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a source above the cap without a transcode is still a refusal', () async {
|
||||||
|
final scoped = _clientWithPlaybackInfo(
|
||||||
|
(_) async => jsonResponse({
|
||||||
|
'MediaSources': [
|
||||||
|
{'Id': 'src-1', 'Bitrate': 8000000},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(scoped.close);
|
||||||
|
|
||||||
|
final result = await scoped.getPlaybackInitialization(
|
||||||
|
PlaybackInitializationOptions(
|
||||||
|
metadata: testMediaItem(
|
||||||
|
id: 'item-1',
|
||||||
|
backend: MediaBackend.jellyfin,
|
||||||
|
kind: MediaKind.movie,
|
||||||
|
serverId: 'srv-1',
|
||||||
|
),
|
||||||
|
selectedMediaIndex: 0,
|
||||||
|
qualityPreset: TranscodeQualityPreset.p720_2mbps,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.fallbackReason, TranscodeFallbackReason.directPlayOnly);
|
||||||
|
});
|
||||||
|
|
||||||
test('video download rejects authentication and cancellation', () async {
|
test('video download rejects authentication and cancellation', () async {
|
||||||
final cases = <(String, Future<http.Response> Function(http.Request))>[
|
final cases = <(String, Future<http.Response> Function(http.Request))>[
|
||||||
('401', (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})),
|
('401', (_) async => http.Response('{}', 401, headers: {'content-type': 'application/json'})),
|
||||||
@@ -1855,7 +2309,7 @@ void main() {
|
|||||||
expect(capturedUri.toString(), contains('/Items/folder%2Fitem%20%231%3Fx/PlaybackInfo'));
|
expect(capturedUri.toString(), contains('/Items/folder%2Fitem%20%231%3Fx/PlaybackInfo'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('getPlaybackInfo advertises embedded and external subtitle delivery', () async {
|
test('getPlaybackInfo advertises embedded delivery for every format, external for text only', () async {
|
||||||
Uri? capturedUri;
|
Uri? capturedUri;
|
||||||
String? capturedBody;
|
String? capturedBody;
|
||||||
final scoped = JellyfinClient.forTesting(
|
final scoped = JellyfinClient.forTesting(
|
||||||
@@ -1894,20 +2348,22 @@ void main() {
|
|||||||
expect(directPlayProfile['AudioCodec'], contains('mp2'));
|
expect(directPlayProfile['AudioCodec'], contains('mp2'));
|
||||||
expect(profile['TranscodingProfiles'], isNotEmpty);
|
expect(profile['TranscodingProfiles'], isNotEmpty);
|
||||||
expect(profile['CodecProfiles'], isEmpty);
|
expect(profile['CodecProfiles'], isEmpty);
|
||||||
const subtitleFormats = ['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub'];
|
const textSubtitleFormats = ['srt', 'ass', 'ssa', 'vtt'];
|
||||||
|
const imageSubtitleFormats = ['pgssub', 'dvdsub', 'dvbsub'];
|
||||||
final subtitleProfiles = [
|
final subtitleProfiles = [
|
||||||
for (final entry in profile['SubtitleProfiles'] as List<dynamic>) entry as Map<String, dynamic>,
|
for (final entry in profile['SubtitleProfiles'] as List<dynamic>) entry as Map<String, dynamic>,
|
||||||
];
|
];
|
||||||
// Every format is offered both ways, Embed first: the server picks per
|
// Embed is offered for every format and listed first, so a direct play
|
||||||
// play method, so direct play reports its container streams as embedded
|
// or mkv remux reports its container streams as embedded. External is
|
||||||
// while a remux or transcode still hands back sidecar URLs.
|
// text-only: the server extracts those into a small subtitle file, while
|
||||||
expect(
|
// an image format finds no external match and gets burned in instead.
|
||||||
subtitleProfiles.where((entry) => entry['Method'] == 'Embed').map((entry) => entry['Format']),
|
expect(subtitleProfiles.where((entry) => entry['Method'] == 'Embed').map((entry) => entry['Format']), [
|
||||||
subtitleFormats,
|
...textSubtitleFormats,
|
||||||
);
|
...imageSubtitleFormats,
|
||||||
|
]);
|
||||||
expect(
|
expect(
|
||||||
subtitleProfiles.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']),
|
subtitleProfiles.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']),
|
||||||
subtitleFormats,
|
textSubtitleFormats,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
subtitleProfiles.indexWhere((entry) => entry['Method'] == 'Embed'),
|
subtitleProfiles.indexWhere((entry) => entry['Method'] == 'Embed'),
|
||||||
@@ -1915,6 +2371,58 @@ void main() {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('image subtitle formats are declared Embed-only so a transcode burns them in', () async {
|
||||||
|
String? capturedBody;
|
||||||
|
final scoped = JellyfinClient.forTesting(
|
||||||
|
connection: _conn(),
|
||||||
|
httpClient: MockClient((request) async {
|
||||||
|
capturedBody = request.body;
|
||||||
|
return jsonResponse({'MediaSources': []});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
addTearDown(scoped.close);
|
||||||
|
|
||||||
|
await scoped.getPlaybackInfo('item-1');
|
||||||
|
|
||||||
|
final body = jsonDecode(capturedBody!) as Map<String, dynamic>;
|
||||||
|
final profile = body['DeviceProfile'] as Map<String, dynamic>;
|
||||||
|
final subtitleProfiles = [
|
||||||
|
for (final entry in profile['SubtitleProfiles'] as List<dynamic>) entry as Map<String, dynamic>,
|
||||||
|
];
|
||||||
|
final externalFormats = subtitleProfiles
|
||||||
|
.where((entry) => entry['Method'] == 'External')
|
||||||
|
.map((entry) => entry['Format'])
|
||||||
|
.toSet();
|
||||||
|
final embedFormats = subtitleProfiles
|
||||||
|
.where((entry) => entry['Method'] == 'Embed')
|
||||||
|
.map((entry) => entry['Format'])
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
for (final format in ['pgssub', 'dvdsub', 'dvbsub']) {
|
||||||
|
expect(
|
||||||
|
externalFormats,
|
||||||
|
isNot(contains(format)),
|
||||||
|
reason:
|
||||||
|
'Declaring $format as External makes Jellyfin match it as an external image '
|
||||||
|
'subtitle and hand back a stream the client cannot render on a transcode. '
|
||||||
|
'With no image entry the server falls through to Encode and burns it in, '
|
||||||
|
'which is the only way a bitmap subtitle reaches the viewer while transcoding.',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
embedFormats,
|
||||||
|
contains(format),
|
||||||
|
reason: 'Direct play and mkv remux read $format out of the container, so Embed must stay.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (final format in ['srt', 'ass', 'ssa', 'vtt']) {
|
||||||
|
expect(
|
||||||
|
externalFormats,
|
||||||
|
contains(format),
|
||||||
|
reason: 'The server extracts $format into a small subtitle file; that path is cheap and must stay.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('path-encodes reserved ids for browse and watch-state endpoints', () async {
|
test('path-encodes reserved ids for browse and watch-state endpoints', () async {
|
||||||
final captured = <Uri>[];
|
final captured = <Uri>[];
|
||||||
final scoped = JellyfinClient.forTesting(
|
final scoped = JellyfinClient.forTesting(
|
||||||
|
|||||||
@@ -228,6 +228,33 @@ void main() {
|
|||||||
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/2.srt');
|
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/2.srt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a transcode drops a carried secondary it cannot deliver', () {
|
||||||
|
// The burn covers the primary, and an embedded secondary has neither a sidecar to fetch nor a
|
||||||
|
// native track to land on. Kept selected, it made `TrackManager` wait out its thirty-second
|
||||||
|
// deadline and then read as active while nothing was on screen.
|
||||||
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
|
metadata: metadata,
|
||||||
|
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true), _sourceSubtitle(3, language: 'swe')]),
|
||||||
|
sidecars: const [],
|
||||||
|
preferredSecondarySubtitleTrack: SubtitlePreference.track(const SubtitleTrack(id: 'source:3', language: 'swe')),
|
||||||
|
isTranscoding: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.secondaryTrack, isNull, reason: 'nothing can carry it, so it must not stay selected');
|
||||||
|
expect(result.secondarySourceStreamId, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a direct play keeps a carried secondary the player can select natively', () {
|
||||||
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
|
metadata: metadata,
|
||||||
|
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true), _sourceSubtitle(3, language: 'swe')]),
|
||||||
|
sidecars: const [],
|
||||||
|
preferredSecondarySubtitleTrack: SubtitlePreference.track(const SubtitleTrack(id: 'source:3', language: 'swe')),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.secondarySourceStreamId, 3, reason: 'the container still carries it');
|
||||||
|
});
|
||||||
|
|
||||||
test('explicit off produces an open with zero sidecars', () {
|
test('explicit off produces an open with zero sidecars', () {
|
||||||
final result = PlaybackSubtitleResolver.resolve(
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
@@ -711,6 +738,59 @@ void main() {
|
|||||||
expect(track.channels, 6);
|
expect(track.channels, 6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('burned-in subtitles force a renegotiation', () {
|
||||||
|
bool needsServer({
|
||||||
|
bool isTranscoding = true,
|
||||||
|
int? currentSourceStreamId,
|
||||||
|
bool currentSelectionHasSidecar = false,
|
||||||
|
bool targetIsOff = false,
|
||||||
|
bool targetIsExternalFile = false,
|
||||||
|
}) => PlaybackSubtitleResolver.burnRequiresRenegotiation(
|
||||||
|
isTranscoding: isTranscoding,
|
||||||
|
currentSourceStreamId: currentSourceStreamId,
|
||||||
|
currentSelectionHasSidecar: currentSelectionHasSidecar,
|
||||||
|
targetIsOff: targetIsOff,
|
||||||
|
targetIsExternalFile: targetIsExternalFile,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Burned pixels are not a track: off leaves them on screen, anything else is drawn over them.
|
||||||
|
// So from a burned selection the target is irrelevant - every change goes back to the server.
|
||||||
|
test('every change from a burned selection goes back to the server', () {
|
||||||
|
expect(needsServer(currentSourceStreamId: 3, targetIsOff: true), isTrue);
|
||||||
|
expect(needsServer(currentSourceStreamId: 3, targetIsExternalFile: true), isTrue);
|
||||||
|
expect(needsServer(currentSourceStreamId: 3), isTrue, reason: 'another embedded track');
|
||||||
|
});
|
||||||
|
|
||||||
|
// The other direction: nothing is burned yet, but the target can only arrive burned.
|
||||||
|
test('an embedded target has to be negotiated even with nothing burned', () {
|
||||||
|
expect(
|
||||||
|
needsServer(currentSourceStreamId: null),
|
||||||
|
isTrue,
|
||||||
|
reason: 'applying it locally attaches nothing and reports success over an unchanged picture',
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
needsServer(currentSourceStreamId: 3, currentSelectionHasSidecar: true),
|
||||||
|
isTrue,
|
||||||
|
reason: 'a sidecar-backed current selection does not make an embedded target local',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('what the client already holds stays local', () {
|
||||||
|
expect(needsServer(currentSourceStreamId: null, targetIsOff: true), isFalse);
|
||||||
|
expect(needsServer(currentSourceStreamId: null, targetIsExternalFile: true), isFalse);
|
||||||
|
expect(
|
||||||
|
needsServer(currentSourceStreamId: 3, currentSelectionHasSidecar: true, targetIsOff: true),
|
||||||
|
isFalse,
|
||||||
|
reason: 'a sidecar was delivered as a file, so turning it off is a real local change',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a direct play never burns, whatever is selected', () {
|
||||||
|
expect(needsServer(isTranscoding: false, currentSourceStreamId: 3), isFalse);
|
||||||
|
expect(needsServer(isTranscoding: false), isFalse);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('selected embedded subtitle keeps sidecars out of the open', () {
|
test('selected embedded subtitle keeps sidecars out of the open', () {
|
||||||
final result = PlaybackSubtitleResolver.resolve(
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import 'package:plezy/media/media_kind.dart';
|
|||||||
import 'package:plezy/media/media_source_info.dart';
|
import 'package:plezy/media/media_source_info.dart';
|
||||||
import 'package:plezy/mpv/mpv.dart';
|
import 'package:plezy/mpv/mpv.dart';
|
||||||
import 'package:plezy/models/transcode_quality_preset.dart';
|
import 'package:plezy/models/transcode_quality_preset.dart';
|
||||||
|
import 'package:plezy/services/subtitle_preference.dart';
|
||||||
import 'package:plezy/services/playback_initialization_types.dart';
|
import 'package:plezy/services/playback_initialization_types.dart';
|
||||||
import 'package:plezy/services/plex_api_cache.dart';
|
import 'package:plezy/services/plex_api_cache.dart';
|
||||||
import 'package:plezy/services/plex_client.dart';
|
import 'package:plezy/services/plex_client.dart';
|
||||||
@@ -132,10 +133,7 @@ void main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
List<PlaybackSubtitleSidecar> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
|
List<PlaybackSubtitleSidecar> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
|
||||||
return client.buildTranscodeSidecarSubtitlesForTesting(
|
return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks));
|
||||||
mediaInfoWithSubtitles(subtitleTracks),
|
|
||||||
'https://plex.example.com/video.mkv?X-Plex-Token=token',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test('selectStreams sends audio stream selection with allParts', () async {
|
test('selectStreams sends audio stream selection with allParts', () async {
|
||||||
@@ -247,7 +245,7 @@ void main() {
|
|||||||
expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue);
|
expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('transcode initialization wires a subtitle-free HLS request to the complete sidecar catalog', () async {
|
test('transcode initialization burns the selected embedded stream and sidecars only the external file', () async {
|
||||||
final requests = <http.Request>[];
|
final requests = <http.Request>[];
|
||||||
final client = makeClient((request) async {
|
final client = makeClient((request) async {
|
||||||
requests.add(request);
|
requests.add(request);
|
||||||
@@ -310,6 +308,9 @@ void main() {
|
|||||||
headers: {'content-type': 'application/json'},
|
headers: {'content-type': 'application/json'},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (request.method == 'PUT' && request.url.path == '/library/parts/99') {
|
||||||
|
return http.Response('', 200);
|
||||||
|
}
|
||||||
return http.Response('unexpected request', 500);
|
return http.Response('unexpected request', 500);
|
||||||
});
|
});
|
||||||
addTearDown(client.close);
|
addTearDown(client.close);
|
||||||
@@ -328,19 +329,30 @@ void main() {
|
|||||||
final decisionRequest = requests.singleWhere(
|
final decisionRequest = requests.singleWhere(
|
||||||
(request) => request.url.path == '/video/:/transcode/universal/decision',
|
(request) => request.url.path == '/video/:/transcode/universal/decision',
|
||||||
);
|
);
|
||||||
expect(decisionRequest.url.queryParameters['subtitles'], 'none');
|
expect(decisionRequest.url.queryParameters['subtitles'], 'burn');
|
||||||
expect(decisionRequest.url.queryParameters['offset'], '495.000000');
|
expect(decisionRequest.url.queryParameters['offset'], '495.000000');
|
||||||
expect(decisionRequest.url.queryParameters.containsKey('subtitleStreamID'), isFalse);
|
expect(
|
||||||
|
decisionRequest.url.queryParameters.containsKey('subtitleStreamID'),
|
||||||
|
isFalse,
|
||||||
|
reason: 'the universal transcoder ignores it; the part selection below is what picks the stream',
|
||||||
|
);
|
||||||
expect(decisionRequest.url.queryParameters.containsKey('advancedSubtitles'), isFalse);
|
expect(decisionRequest.url.queryParameters.containsKey('advancedSubtitles'), isFalse);
|
||||||
expect(decisionRequest.url.queryParameters['X-Plex-Incomplete-Segments'], '1');
|
expect(decisionRequest.url.queryParameters['X-Plex-Incomplete-Segments'], '1');
|
||||||
|
|
||||||
|
// What actually aims the burn: the embedded ASS track is selected on the
|
||||||
|
// part first, so the server burns 401 rather than whatever it had stored.
|
||||||
|
final selection = requests.singleWhere(
|
||||||
|
(request) => request.method == 'PUT' && request.url.path == '/library/parts/99',
|
||||||
|
);
|
||||||
|
expect(selection.url.queryParameters['subtitleStreamID'], '401');
|
||||||
|
expect(selection.url.queryParameters.containsKey('audioStreamID'), isFalse);
|
||||||
expect(result.isTranscoding, isTrue);
|
expect(result.isTranscoding, isTrue);
|
||||||
expect(result.videoUrl, contains('/video/:/transcode/universal/start.m3u8?'));
|
expect(result.videoUrl, contains('/video/:/transcode/universal/start.m3u8?'));
|
||||||
expect(Uri.parse(result.videoUrl!).queryParameters['offset'], '495.000000');
|
expect(Uri.parse(result.videoUrl!).queryParameters['offset'], '495.000000');
|
||||||
expect(PlexClient.transcodeStreamOffsetFromUrl(result.videoUrl!), const Duration(minutes: 8, seconds: 15));
|
expect(PlexClient.transcodeStreamOffsetFromUrl(result.videoUrl!), const Duration(minutes: 8, seconds: 15));
|
||||||
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [401, 402]);
|
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [402]);
|
||||||
expect(result.subtitleSidecars.every((sidecar) => sidecar.preload), isTrue);
|
expect(result.subtitleSidecars.single.preload, isTrue);
|
||||||
expect(result.subtitleSidecars.first.track.isContainer, isTrue);
|
expect(result.subtitleSidecars.single.track.uri, contains('/library/streams/402.srt'));
|
||||||
expect(result.subtitleSidecars.last.track.uri, contains('/library/streams/402.srt'));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('playback uses metadata availability flags without probing part URLs', () async {
|
test('playback uses metadata availability flags without probing part URLs', () async {
|
||||||
@@ -545,37 +557,39 @@ void main() {
|
|||||||
expect(result.selectedMediaIndex, 1);
|
expect(result.selectedMediaIndex, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('transcode subtitle catalog includes embedded and keyed Plex streams', () {
|
test('transcode subtitle catalog carries only real external subtitle files', () {
|
||||||
|
// Embedded streams are burned into the picture by the transcoder. Handing them
|
||||||
|
// back as sidecars on the source container would make the client range-read the
|
||||||
|
// whole remux over HTTP while the transcode is already streaming (#1815, #1738).
|
||||||
final client = makeClient((_) async => http.Response('not used', 500));
|
final client = makeClient((_) async => http.Response('not used', 500));
|
||||||
addTearDown(client.close);
|
addTearDown(client.close);
|
||||||
|
|
||||||
final subtitles = buildTranscodeSubtitles(client, [
|
final subtitles = buildTranscodeSubtitles(client, [
|
||||||
MediaSubtitleTrack(id: 401, codec: 'ass', languageCode: 'eng', title: 'Embedded', selected: true, forced: false),
|
MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', languageCode: 'eng', selected: true, forced: false),
|
||||||
|
MediaSubtitleTrack(id: 402, index: 4, codec: 'pgs', languageCode: 'eng', selected: false, forced: false),
|
||||||
MediaSubtitleTrack(
|
MediaSubtitleTrack(
|
||||||
id: 402,
|
id: 403,
|
||||||
|
index: 5,
|
||||||
codec: 'srt',
|
codec: 'srt',
|
||||||
languageCode: 'swe',
|
languageCode: 'swe',
|
||||||
title: 'External',
|
title: 'External',
|
||||||
selected: false,
|
selected: false,
|
||||||
forced: false,
|
forced: false,
|
||||||
key: '/library/streams/402',
|
key: '/library/streams/403',
|
||||||
external: true,
|
external: true,
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(subtitles, hasLength(2));
|
expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [403]);
|
||||||
expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [401, 402]);
|
expect(subtitles.single.preload, isTrue);
|
||||||
expect(subtitles.every((sidecar) => sidecar.preload), isTrue);
|
expect(subtitles.single.track.isContainer, isFalse);
|
||||||
expect(subtitles.first.track.isContainer, isTrue);
|
|
||||||
expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv?X-Plex-Token=token');
|
|
||||||
expect(subtitles.last.track.isContainer, isFalse);
|
|
||||||
expect(
|
expect(
|
||||||
subtitles.last.track.uri,
|
subtitles.single.track.uri,
|
||||||
'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token',
|
'https://plex.example.com/library/streams/403.srt?encoding=utf-8&X-Plex-Token=token',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('tokenless transcode keeps embedded and keyed subtitle sources', () {
|
test('tokenless transcode still sidecars the external subtitle file', () {
|
||||||
final client = testPlexClient(
|
final client = testPlexClient(
|
||||||
serverId: ServerId('server-id'),
|
serverId: ServerId('server-id'),
|
||||||
token: null,
|
token: null,
|
||||||
@@ -596,17 +610,235 @@ void main() {
|
|||||||
external: true,
|
external: true,
|
||||||
),
|
),
|
||||||
]),
|
]),
|
||||||
'https://plex.example.com/video.mkv',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(subtitles, hasLength(2));
|
expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [402]);
|
||||||
expect(subtitles.first.track.isContainer, isTrue);
|
expect(subtitles.single.track.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8');
|
||||||
expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv');
|
|
||||||
expect(subtitles.last.track.isContainer, isFalse);
|
|
||||||
expect(subtitles.last.track.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('video transcode stays subtitle-free while preserving the HLS profile', () {
|
test('transcode burns the selected embedded stream whatever its format', () {
|
||||||
|
// Burn covers text as well as image: it is the one mechanism that keeps ASS/SSA
|
||||||
|
// styling intact through an HLS transcode.
|
||||||
|
final client = makeClient((_) async => http.Response('not used', 500));
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
Map<String, String> paramsForSelected(MediaSubtitleTrack track) => client.buildTranscodeParamsForTesting(
|
||||||
|
ratingKey: '42',
|
||||||
|
mediaIndex: 0,
|
||||||
|
preset: TranscodeQualityPreset.p720_3mbps,
|
||||||
|
sessionIdentifier: 'session-id',
|
||||||
|
transcodeSessionId: 'transcode-id',
|
||||||
|
selectedSubtitleTrack: track,
|
||||||
|
);
|
||||||
|
|
||||||
|
final textParams = paramsForSelected(
|
||||||
|
MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', languageCode: 'eng', selected: true, forced: false),
|
||||||
|
);
|
||||||
|
expect(textParams['subtitles'], 'burn');
|
||||||
|
expect(textParams.containsKey('subtitleStreamID'), isFalse);
|
||||||
|
expect(textParams.containsKey('advancedSubtitles'), isFalse);
|
||||||
|
|
||||||
|
final imageParams = paramsForSelected(
|
||||||
|
MediaSubtitleTrack(id: 402, index: 4, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
|
||||||
|
);
|
||||||
|
expect(imageParams['subtitles'], 'burn');
|
||||||
|
expect(imageParams.containsKey('subtitleStreamID'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a burn aims at the caller track by selecting it on the part first', () async {
|
||||||
|
// The universal transcoder burns whatever the part has selected, so this
|
||||||
|
// PUT is the only thing that makes `subtitles=burn` hit the chosen stream.
|
||||||
|
final requests = <http.Request>[];
|
||||||
|
final client = makeClient((request) async {
|
||||||
|
requests.add(request);
|
||||||
|
return http.Response('', 200);
|
||||||
|
});
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
await client.selectSubtitleStreamForBurn(
|
||||||
|
partId: 99,
|
||||||
|
track: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false),
|
||||||
|
);
|
||||||
|
|
||||||
|
final put = requests.singleWhere((request) => request.method == 'PUT');
|
||||||
|
expect(put.url.path, '/library/parts/99');
|
||||||
|
expect(put.url.queryParameters['subtitleStreamID'], '401');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sidecarred external file leaves the server selection untouched', () async {
|
||||||
|
// External files are fetched directly and never burned; rewriting the
|
||||||
|
// part's selection here would change what other Plex clients see.
|
||||||
|
final requests = <http.Request>[];
|
||||||
|
final client = makeClient((request) async {
|
||||||
|
requests.add(request);
|
||||||
|
return http.Response('', 200);
|
||||||
|
});
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
await client.selectSubtitleStreamForBurn(
|
||||||
|
partId: 99,
|
||||||
|
track: MediaSubtitleTrack(
|
||||||
|
id: 403,
|
||||||
|
index: 5,
|
||||||
|
codec: 'srt',
|
||||||
|
selected: true,
|
||||||
|
forced: false,
|
||||||
|
key: '/library/streams/403',
|
||||||
|
external: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await client.selectSubtitleStreamForBurn(partId: 99, track: null);
|
||||||
|
|
||||||
|
expect(requests, isEmpty);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unaimable burn refuses rather than letting the server pick', () async {
|
||||||
|
// Proceeding would burn whatever the part already had selected, welding a
|
||||||
|
// language the viewer never chose into the picture.
|
||||||
|
final client = makeClient((_) async => http.Response('', 200));
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
client.selectSubtitleStreamForBurn(
|
||||||
|
partId: null,
|
||||||
|
track: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false),
|
||||||
|
),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a selection the server did not commit refuses the burn too', () async {
|
||||||
|
// 204 rather than 200: no HTTP error to raise, but nothing was stored
|
||||||
|
// either, so the burn target is still whatever the part had before.
|
||||||
|
final client = makeClient((_) async => http.Response('', 204));
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
await expectLater(
|
||||||
|
client.selectSubtitleStreamForBurn(
|
||||||
|
partId: 99,
|
||||||
|
track: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false),
|
||||||
|
),
|
||||||
|
throwsStateError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a burn that cannot be aimed never reaches the decision endpoint', () async {
|
||||||
|
// The whole transcode is abandoned: the caller falls back to direct play,
|
||||||
|
// where the native player reads the embedded track itself.
|
||||||
|
final requests = <http.Request>[];
|
||||||
|
final client = makeClient((request) async {
|
||||||
|
requests.add(request);
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001},
|
||||||
|
}),
|
||||||
|
request.method == 'PUT' ? 403 : 200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
final result = await client.buildTranscodeStartPath(
|
||||||
|
ratingKey: '42',
|
||||||
|
mediaIndex: 0,
|
||||||
|
preset: TranscodeQualityPreset.p720_3mbps,
|
||||||
|
sessionIdentifier: 'session-id',
|
||||||
|
transcodeSessionId: 'transcode-id',
|
||||||
|
partId: 99,
|
||||||
|
selectedSubtitleTrack: MediaSubtitleTrack(id: 401, index: 3, codec: 'ass', selected: true, forced: false),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.outcome, TranscodeDecisionOutcome.failed);
|
||||||
|
expect(result.startPath, isNull);
|
||||||
|
expect(
|
||||||
|
requests.where((request) => request.url.path.contains('/transcode/universal')),
|
||||||
|
isEmpty,
|
||||||
|
reason: 'no burn may be requested once the target could not be selected',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('transcode leaves a real external subtitle file to the sidecar instead of burning it', () {
|
||||||
|
final client = makeClient((_) async => http.Response('not used', 500));
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
final params = client.buildTranscodeParamsForTesting(
|
||||||
|
ratingKey: '42',
|
||||||
|
mediaIndex: 0,
|
||||||
|
preset: TranscodeQualityPreset.p720_3mbps,
|
||||||
|
sessionIdentifier: 'session-id',
|
||||||
|
transcodeSessionId: 'transcode-id',
|
||||||
|
selectedSubtitleTrack: MediaSubtitleTrack(
|
||||||
|
id: 403,
|
||||||
|
index: 5,
|
||||||
|
codec: 'srt',
|
||||||
|
languageCode: 'swe',
|
||||||
|
selected: true,
|
||||||
|
forced: false,
|
||||||
|
key: '/library/streams/403',
|
||||||
|
external: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(params['subtitles'], 'none');
|
||||||
|
expect(params.containsKey('subtitleStreamID'), isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no burn keeps the per-preset directPlay/directStream pinning', () {
|
||||||
|
final client = makeClient((_) async => http.Response('not used', 500));
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
final originalParams = client.buildTranscodeParamsForTesting(
|
||||||
|
ratingKey: '42',
|
||||||
|
mediaIndex: 0,
|
||||||
|
preset: TranscodeQualityPreset.original,
|
||||||
|
sessionIdentifier: 'session-id',
|
||||||
|
transcodeSessionId: 'transcode-id',
|
||||||
|
);
|
||||||
|
expect(originalParams['directPlay'], '1');
|
||||||
|
expect(originalParams['directStream'], '1');
|
||||||
|
|
||||||
|
final cappedParams = client.buildTranscodeParamsForTesting(
|
||||||
|
ratingKey: '42',
|
||||||
|
mediaIndex: 0,
|
||||||
|
preset: TranscodeQualityPreset.p720_3mbps,
|
||||||
|
sessionIdentifier: 'session-id',
|
||||||
|
transcodeSessionId: 'transcode-id',
|
||||||
|
);
|
||||||
|
expect(cappedParams['directPlay'], '0');
|
||||||
|
expect(cappedParams['directStream'], '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a burn withdraws directPlay, because painting the subtitle in is a re-encode', () {
|
||||||
|
// Not a preference: a real PMS answers HTTP 400 to `directPlay=1` combined
|
||||||
|
// with `subtitles=burn`, for text and image subtitles alike. With
|
||||||
|
// directPlay withdrawn it answers `decision=transcode` on the video stream
|
||||||
|
// and `decision=burn` on the subtitle.
|
||||||
|
final client = makeClient((_) async => http.Response('not used', 500));
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
Map<String, String> paramsFor(String codec, TranscodeQualityPreset preset) => client.buildTranscodeParamsForTesting(
|
||||||
|
ratingKey: '42',
|
||||||
|
mediaIndex: 0,
|
||||||
|
preset: preset,
|
||||||
|
sessionIdentifier: 'session-id',
|
||||||
|
transcodeSessionId: 'transcode-id',
|
||||||
|
selectedSubtitleTrack: MediaSubtitleTrack(id: 401, index: 3, codec: codec, selected: true, forced: false),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final codec in ['ass', 'srt', 'pgs']) {
|
||||||
|
final params = paramsFor(codec, TranscodeQualityPreset.p720_3mbps);
|
||||||
|
expect(params['subtitles'], 'burn', reason: '$codec should burn');
|
||||||
|
expect(params['directPlay'], '0', reason: '$codec burn must not also advertise direct play');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Even an original-quality session must drop directPlay once it burns.
|
||||||
|
final originalBurn = paramsFor('ass', TranscodeQualityPreset.original);
|
||||||
|
expect(originalBurn['subtitles'], 'burn');
|
||||||
|
expect(originalBurn['directPlay'], '0');
|
||||||
|
expect(originalBurn['directStream'], '1');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('video transcode requests no subtitles when none is selected, preserving the HLS profile', () {
|
||||||
final client = makeClient((_) async => http.Response('not used', 500));
|
final client = makeClient((_) async => http.Response('not used', 500));
|
||||||
addTearDown(client.close);
|
addTearDown(client.close);
|
||||||
|
|
||||||
@@ -701,6 +933,7 @@ void main() {
|
|||||||
),
|
),
|
||||||
const Duration(minutes: 8, seconds: 15),
|
const Duration(minutes: 8, seconds: 15),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
PlexClient.transcodeStreamOffsetFromUrl(
|
PlexClient.transcodeStreamOffsetFromUrl(
|
||||||
'https://plex.example.com/video/:/transcode/universal/start.m3u8?session=abc',
|
'https://plex.example.com/video/:/transcode/universal/start.m3u8?session=abc',
|
||||||
@@ -777,6 +1010,137 @@ void main() {
|
|||||||
expect(acceptHeaders, everyElement('*/*'));
|
expect(acceptHeaders, everyElement('*/*'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an embedded subtitle this server cannot burn falls back as a failure', () async {
|
||||||
|
// The row has no `key`, so it is embedded and a transcode would have to burn it; `dvb_teletext`
|
||||||
|
// is not a codec the burn path accepts. Treating that as "no burn requested" sent
|
||||||
|
// `subtitles=none` with the row still selected, so the caption vanished while state reported it
|
||||||
|
// active *and* the capped preset was silently retained.
|
||||||
|
final client = makeClient((request) async {
|
||||||
|
if (request.url.path == '/library/metadata/42') {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'MediaContainer': {
|
||||||
|
'Metadata': [
|
||||||
|
{
|
||||||
|
'ratingKey': '42',
|
||||||
|
'Media': [
|
||||||
|
{
|
||||||
|
'id': 1,
|
||||||
|
'Part': [
|
||||||
|
{
|
||||||
|
'id': 11,
|
||||||
|
'key': '/library/parts/11/file.mkv',
|
||||||
|
'Stream': <dynamic>[
|
||||||
|
{'id': 31, 'streamType': 3, 'index': 2, 'codec': 'dvb_teletext', 'language': 'English'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (request.url.path == '/video/:/transcode/universal/decision') {
|
||||||
|
// The server answers direct-play-only, which is what refusing the burn looks like.
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'MediaContainer': {'generalDecisionCode': 1000, 'transcodeDecisionCode': 1000},
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response('unexpected request', 500);
|
||||||
|
});
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
final result = await client.getPlaybackInitialization(
|
||||||
|
PlaybackInitializationOptions(
|
||||||
|
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
|
||||||
|
selectedMediaIndex: 0,
|
||||||
|
qualityPreset: TranscodeQualityPreset.p720_3mbps,
|
||||||
|
sessionIdentifier: 'session-id',
|
||||||
|
transcodeSessionId: 'transcode-id',
|
||||||
|
preferredSubtitleTrack: const SubtitlePreference.track(
|
||||||
|
SubtitleTrack(id: 'source:31', codec: 'dvb_teletext', language: 'English'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.playMethod, 'DirectPlay', reason: 'direct play is the route that can deliver it');
|
||||||
|
expect(result.fallbackReason, isNotNull, reason: 'a dropped burn is a refusal to warn about, not a silent success');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a valid transcode is refused when it cannot carry the selected subtitle', () async {
|
||||||
|
// The server happily transcodes the video, but the decision went out as `subtitles=none` because
|
||||||
|
// `dvb_teletext` cannot be burned. Accepting that stream left the row selected with nothing
|
||||||
|
// drawing it and no sidecar behind it, so the caption was simply gone.
|
||||||
|
final client = makeClient((request) async {
|
||||||
|
if (request.url.path == '/library/metadata/42') {
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'MediaContainer': {
|
||||||
|
'Metadata': [
|
||||||
|
{
|
||||||
|
'ratingKey': '42',
|
||||||
|
'Media': [
|
||||||
|
{
|
||||||
|
'id': 1,
|
||||||
|
'Part': [
|
||||||
|
{
|
||||||
|
'id': 11,
|
||||||
|
'key': '/library/parts/11/file.mkv',
|
||||||
|
'Stream': <dynamic>[
|
||||||
|
{'id': 31, 'streamType': 3, 'index': 2, 'codec': 'dvb_teletext', 'language': 'English'},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (request.url.path == '/video/:/transcode/universal/decision') {
|
||||||
|
// 1001 = the server will transcode. Without the refusal this is accepted outright.
|
||||||
|
return http.Response(
|
||||||
|
jsonEncode({
|
||||||
|
'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001},
|
||||||
|
}),
|
||||||
|
200,
|
||||||
|
headers: {'content-type': 'application/json'},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return http.Response('unexpected request', 500);
|
||||||
|
});
|
||||||
|
addTearDown(client.close);
|
||||||
|
|
||||||
|
final result = await client.getPlaybackInitialization(
|
||||||
|
PlaybackInitializationOptions(
|
||||||
|
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'server-id'),
|
||||||
|
selectedMediaIndex: 0,
|
||||||
|
qualityPreset: TranscodeQualityPreset.p720_3mbps,
|
||||||
|
sessionIdentifier: 'session-id',
|
||||||
|
transcodeSessionId: 'transcode-id',
|
||||||
|
preferredSubtitleTrack: const SubtitlePreference.track(
|
||||||
|
SubtitleTrack(id: 'source:31', codec: 'dvb_teletext', language: 'English'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.isTranscoding, isFalse, reason: 'the stream could not carry the caption that was asked for');
|
||||||
|
expect(result.playMethod, 'DirectPlay', reason: 'direct play lets the native player read it');
|
||||||
|
});
|
||||||
|
|
||||||
test('readiness probe target selection walks segment durations to the offset', () {
|
test('readiness probe target selection walks segment durations to the offset', () {
|
||||||
const mediaPlaylist =
|
const mediaPlaylist =
|
||||||
'#EXTM3U\n'
|
'#EXTM3U\n'
|
||||||
@@ -1052,20 +1416,28 @@ void main() {
|
|||||||
expect(params['partIndex'], '2');
|
expect(params['partIndex'], '2');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('image-based embedded subtitles use the shared container sidecar', () {
|
test('image-based embedded subtitles are burned rather than sidecarred', () {
|
||||||
final client = makeClient((_) async => http.Response('not used', 500));
|
final client = makeClient((_) async => http.Response('not used', 500));
|
||||||
addTearDown(client.close);
|
addTearDown(client.close);
|
||||||
|
|
||||||
final subtitles = buildTranscodeSubtitles(client, [
|
final tracks = [
|
||||||
MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
|
MediaSubtitleTrack(id: 401, index: 3, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
|
||||||
MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: false, forced: false),
|
MediaSubtitleTrack(id: 402, index: 4, codec: 'dvd_subtitle', languageCode: 'eng', selected: false, forced: false),
|
||||||
]);
|
];
|
||||||
|
|
||||||
expect(subtitles, hasLength(2));
|
expect(buildTranscodeSubtitles(client, tracks), isEmpty);
|
||||||
expect(subtitles.every((sidecar) => sidecar.track.isContainer), isTrue);
|
|
||||||
expect(subtitles.map((sidecar) => sidecar.track.uri).toSet(), {
|
final params = client.buildTranscodeParamsForTesting(
|
||||||
'https://plex.example.com/video.mkv?X-Plex-Token=token',
|
ratingKey: '42',
|
||||||
});
|
mediaIndex: 0,
|
||||||
|
preset: TranscodeQualityPreset.p720_3mbps,
|
||||||
|
sessionIdentifier: 'session-id',
|
||||||
|
transcodeSessionId: 'transcode-id',
|
||||||
|
selectedSubtitleTrack: client.resolveTranscodeSubtitleTrackForTesting(mediaInfoWithSubtitles(tracks), null),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(params['subtitles'], 'burn');
|
||||||
|
expect(params.containsKey('subtitleStreamID'), isFalse);
|
||||||
});
|
});
|
||||||
|
|
||||||
group('playback metadata failure contract', () {
|
group('playback metadata failure contract', () {
|
||||||
|
|||||||
@@ -133,6 +133,16 @@ class _FakePlayer with PlayerStreamControllersMixin implements Player {
|
|||||||
if (selectSubtitleError case final error?) throw error;
|
if (selectSubtitleError case final error?) throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get supportsSecondarySubtitles => true;
|
||||||
|
|
||||||
|
final List<SubtitleTrack> selectedSecondarySubtitle = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> selectSecondarySubtitleTrack(SubtitleTrack t) async {
|
||||||
|
selectedSecondarySubtitle.add(t);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> setRate(double rate) async {
|
Future<void> setRate(double rate) async {
|
||||||
rates.add(rate);
|
rates.add(rate);
|
||||||
@@ -153,6 +163,8 @@ TrackManager _make({
|
|||||||
SubtitleTrack? preferredSubtitleTrack,
|
SubtitleTrack? preferredSubtitleTrack,
|
||||||
void Function(String, {Duration? duration})? showMessage,
|
void Function(String, {Duration? duration})? showMessage,
|
||||||
TrackPreferencePersister? persister,
|
TrackPreferencePersister? persister,
|
||||||
|
bool primarySubtitleIsServerRendered = false,
|
||||||
|
SubtitleTrack? preferredSecondarySubtitleTrack,
|
||||||
}) {
|
}) {
|
||||||
return TrackManager(
|
return TrackManager(
|
||||||
player: player,
|
player: player,
|
||||||
@@ -164,6 +176,8 @@ TrackManager _make({
|
|||||||
mediaInfo: mediaInfo,
|
mediaInfo: mediaInfo,
|
||||||
preferredAudioTrack: preferredAudioTrack,
|
preferredAudioTrack: preferredAudioTrack,
|
||||||
preferredSubtitleTrack: SubtitlePreference.trackOrNull(preferredSubtitleTrack),
|
preferredSubtitleTrack: SubtitlePreference.trackOrNull(preferredSubtitleTrack),
|
||||||
|
primarySubtitleIsServerRendered: primarySubtitleIsServerRendered,
|
||||||
|
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(preferredSecondarySubtitleTrack),
|
||||||
showMessage: showMessage,
|
showMessage: showMessage,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,6 +202,17 @@ MediaSourceInfo _metadataFreeDirectMediaInfo({bool selected = true}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A source with no audio at all: the only shape for which a burned primary and no
|
||||||
|
/// secondary really means "the catalog is already complete".
|
||||||
|
MediaSourceInfo _silentBurnedMediaInfo() {
|
||||||
|
return MediaSourceInfo(
|
||||||
|
videoUrl: 'https://example.com/video.mp4',
|
||||||
|
audioTracks: const [],
|
||||||
|
subtitleTracks: [MediaSubtitleTrack(id: 20, codec: 'ass', selected: true, forced: false)],
|
||||||
|
chapters: const [],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _drainAsync() async {
|
Future<void> _drainAsync() async {
|
||||||
for (var i = 0; i < 5; i++) {
|
for (var i = 0; i < 5; i++) {
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
@@ -383,6 +408,125 @@ void main() {
|
|||||||
expect(player.selectedSubtitle.single.id, 'no');
|
expect(player.selectedSubtitle.single.id, 'no');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a server-rendered primary does not wait for a native subtitle track', () async {
|
||||||
|
// The burned-in case: the row stays advertised and selected, but the picture already carries
|
||||||
|
// it and the transcode exposes no subtitle track for it. Waiting held audio and rate setup
|
||||||
|
// for the five-second fallback and then logged a missed deadline twenty-five seconds later.
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
await settings.write(SettingsService.defaultPlaybackSpeed, 1.5);
|
||||||
|
|
||||||
|
fakeAsync((async) {
|
||||||
|
final player = _FakePlayer(
|
||||||
|
tracks: const Tracks(
|
||||||
|
audio: [AudioTrack(id: 'native-audio', language: 'eng')],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final mgr = _make(
|
||||||
|
player: player,
|
||||||
|
mediaInfo: _mediaInfoWithSubtitles(selected: true),
|
||||||
|
primarySubtitleIsServerRendered: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
mgr.applyTrackSelectionWhenReady();
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(player.rates, [1.5], reason: 'rate setup ran immediately instead of waiting');
|
||||||
|
expect(
|
||||||
|
async.nonPeriodicTimerCount,
|
||||||
|
0,
|
||||||
|
reason: 'no five-second fallback is armed for a track that is never coming',
|
||||||
|
);
|
||||||
|
mgr.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a server-rendered primary still waits for a carried secondary', () async {
|
||||||
|
// Only one selection pass ever runs, so treating a burned primary as "ready" while a carried
|
||||||
|
// secondary is still in flight retires the subscription and drops the secondary for good.
|
||||||
|
await SettingsService.getInstance();
|
||||||
|
final player = _FakePlayer(
|
||||||
|
tracks: const Tracks(
|
||||||
|
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final mgr = _make(
|
||||||
|
player: player,
|
||||||
|
mediaInfo: _mediaInfoWithSubtitles(selected: true),
|
||||||
|
primarySubtitleIsServerRendered: true,
|
||||||
|
preferredSecondarySubtitleTrack: const SubtitleTrack(id: '10', language: 'eng'),
|
||||||
|
);
|
||||||
|
addTearDown(mgr.dispose);
|
||||||
|
|
||||||
|
mgr.applyTrackSelectionWhenReady();
|
||||||
|
await _drainAsync();
|
||||||
|
expect(player.selectedSecondarySubtitle, isEmpty, reason: 'the secondary has not arrived yet');
|
||||||
|
|
||||||
|
player.emitTracks(
|
||||||
|
const Tracks(
|
||||||
|
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||||
|
subtitle: [SubtitleTrack(id: '10', language: 'eng')],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _drainAsync();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
player.selectedSecondarySubtitle.map((track) => track.id),
|
||||||
|
['10'],
|
||||||
|
reason: 'the wait stayed armed, so the late secondary still landed',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a sidecar-backed secondary source row resolves against its loaded track', () async {
|
||||||
|
// The realistic Jellyfin transcode shape: the secondary is an embedded row delivered as a
|
||||||
|
// server-extracted file, so the preference carries a `source:` id while the loaded track is
|
||||||
|
// external. A `source:` id skips URI matching entirely, so this pins that the source-row
|
||||||
|
// matcher still pairs the two - if it did not, the carried secondary would wait out the
|
||||||
|
// deadline and never appear.
|
||||||
|
await SettingsService.getInstance();
|
||||||
|
final player = _FakePlayer(
|
||||||
|
tracks: const Tracks(
|
||||||
|
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final mgr = _make(
|
||||||
|
player: player,
|
||||||
|
mediaInfo: _mediaInfoWithSubtitles(selected: true),
|
||||||
|
primarySubtitleIsServerRendered: true,
|
||||||
|
preferredSecondarySubtitleTrack: const SubtitleTrack(
|
||||||
|
id: 'source:10',
|
||||||
|
uri: 'https://example.com/Subtitles/10/Stream.srt',
|
||||||
|
language: 'eng',
|
||||||
|
codec: 'srt',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
addTearDown(mgr.dispose);
|
||||||
|
|
||||||
|
mgr.applyTrackSelectionWhenReady();
|
||||||
|
await _drainAsync();
|
||||||
|
|
||||||
|
player.emitTracks(
|
||||||
|
const Tracks(
|
||||||
|
audio: [AudioTrack(id: '1', language: 'eng')],
|
||||||
|
subtitle: [
|
||||||
|
SubtitleTrack(
|
||||||
|
id: '1',
|
||||||
|
uri: 'https://example.com/Subtitles/10/Stream.srt',
|
||||||
|
language: 'eng',
|
||||||
|
codec: 'srt',
|
||||||
|
isExternal: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await _drainAsync();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
player.selectedSecondarySubtitle.map((track) => track.id),
|
||||||
|
['1'],
|
||||||
|
reason: 'the extracted file is the secondary row, however the two sides label it',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('complete metadata-free direct catalog applies tracks without the five-second fallback', () async {
|
test('complete metadata-free direct catalog applies tracks without the five-second fallback', () async {
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
await settings.write(SettingsService.defaultPlaybackSpeed, 1.5);
|
await settings.write(SettingsService.defaultPlaybackSpeed, 1.5);
|
||||||
@@ -435,6 +579,83 @@ void main() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a burned primary with no tracks at all is ready immediately', () async {
|
||||||
|
// A silent video transcoded with its primary burned in exposes neither audio nor subtitle
|
||||||
|
// tracks. The empty-list guard used to answer first, so selection spent the five-second
|
||||||
|
// fallback, then a twenty-five-second deadline, on a catalog that was already complete -
|
||||||
|
// holding the saved playback rate back with it.
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
await settings.write(SettingsService.defaultPlaybackSpeed, 1.5);
|
||||||
|
|
||||||
|
fakeAsync((async) {
|
||||||
|
final player = _FakePlayer(tracks: const Tracks());
|
||||||
|
final mgr = _make(player: player, mediaInfo: _silentBurnedMediaInfo(), primarySubtitleIsServerRendered: true);
|
||||||
|
|
||||||
|
mgr.applyTrackSelectionWhenReady();
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(player.rates, [1.5], reason: 'the rate must not wait for tracks that cannot arrive');
|
||||||
|
expect(async.nonPeriodicTimerCount, 0, reason: 'no five-second fallback should be armed');
|
||||||
|
mgr.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a burned primary with an explicit secondary off is ready immediately too', () async {
|
||||||
|
// An explicit off is as settled as an absent preference: nothing is wanted, so nothing is
|
||||||
|
// coming. Treating only null that way left this case in the ten-second track-loading wait.
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
await settings.write(SettingsService.defaultPlaybackSpeed, 1.5);
|
||||||
|
|
||||||
|
fakeAsync((async) {
|
||||||
|
final player = _FakePlayer(tracks: const Tracks());
|
||||||
|
final mgr = _make(
|
||||||
|
player: player,
|
||||||
|
mediaInfo: _silentBurnedMediaInfo(),
|
||||||
|
primarySubtitleIsServerRendered: true,
|
||||||
|
preferredSecondarySubtitleTrack: SubtitleTrack.off,
|
||||||
|
);
|
||||||
|
|
||||||
|
mgr.applyTrackSelectionWhenReady();
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(player.rates, [1.5], reason: 'an explicit off has nothing to wait for either');
|
||||||
|
mgr.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a burned primary still waits for audio the source advertises', () async {
|
||||||
|
// The burned subtitle needs no native track, but audio does: answering "ready" on the subtitle
|
||||||
|
// question alone retired the track listener while the catalog was still filling, and the
|
||||||
|
// preferred audio track was never selected - playback stayed on the engine's default.
|
||||||
|
final settings = await SettingsService.getInstance();
|
||||||
|
await settings.write(SettingsService.defaultPlaybackSpeed, 1.5);
|
||||||
|
|
||||||
|
fakeAsync((async) {
|
||||||
|
final player = _FakePlayer(tracks: const Tracks());
|
||||||
|
final mgr = _make(
|
||||||
|
player: player,
|
||||||
|
mediaInfo: _metadataFreeDirectMediaInfo(),
|
||||||
|
primarySubtitleIsServerRendered: true,
|
||||||
|
preferredAudioTrack: const AudioTrack(id: 'native-audio', language: 'eng'),
|
||||||
|
);
|
||||||
|
|
||||||
|
mgr.applyTrackSelectionWhenReady();
|
||||||
|
async.flushMicrotasks();
|
||||||
|
expect(player.rates, isEmpty, reason: 'the advertised audio track has not arrived yet');
|
||||||
|
|
||||||
|
player.emitTracks(
|
||||||
|
const Tracks(
|
||||||
|
audio: [AudioTrack(id: 'native-audio', language: 'eng')],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
async.flushMicrotasks();
|
||||||
|
|
||||||
|
expect(player.selectedAudio.map((track) => track.id), ['native-audio']);
|
||||||
|
expect(player.rates, [1.5], reason: 'selection runs once the catalog is complete');
|
||||||
|
mgr.dispose();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('waits through a partial catalog until the selected Plex subtitle arrives', () async {
|
test('waits through a partial catalog until the selected Plex subtitle arrives', () async {
|
||||||
await SettingsService.getInstance();
|
await SettingsService.getInstance();
|
||||||
final player = _FakePlayer(
|
final player = _FakePlayer(
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ void main() {
|
|||||||
'dvdsub',
|
'dvdsub',
|
||||||
'vobsub',
|
'vobsub',
|
||||||
'dvb_sub',
|
'dvb_sub',
|
||||||
|
// Jellyfin's own spelling. The transcode profile asks it to burn `dvbsub`, so a picker that
|
||||||
|
// does not recognise the name never offers the track it just negotiated.
|
||||||
|
'dvbsub',
|
||||||
'dvb_subtitle',
|
'dvb_subtitle',
|
||||||
'PGS',
|
'PGS',
|
||||||
]) {
|
]) {
|
||||||
|
|||||||
@@ -133,15 +133,7 @@ void main() {
|
|||||||
|
|
||||||
test('returns the full list unchanged when not transcoding', () {
|
test('returns the full list unchanged when not transcoding', () {
|
||||||
final tracks = [sub(1, codec: 'srt'), sub(2, codec: 'pgs'), sub(3, codec: 'weird')];
|
final tracks = [sub(1, codec: 'srt'), sub(2, codec: 'pgs'), sub(3, codec: 'weird')];
|
||||||
expect(
|
expect(selectableSourceSubtitleTracks(tracks, isTranscoding: false, sidecarSourceIds: const {}), tracks);
|
||||||
selectableSourceSubtitleTracks(
|
|
||||||
tracks,
|
|
||||||
isTranscoding: false,
|
|
||||||
sidecarSourceIds: const {},
|
|
||||||
supportsEmbeddedTranscodeSelection: false,
|
|
||||||
),
|
|
||||||
tracks,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('keeps text, image and keyed tracks while transcoding', () {
|
test('keeps text, image and keyed tracks while transcoding', () {
|
||||||
@@ -152,7 +144,6 @@ void main() {
|
|||||||
[text, image, keyed],
|
[text, image, keyed],
|
||||||
isTranscoding: true,
|
isTranscoding: true,
|
||||||
sidecarSourceIds: {keyed.id},
|
sidecarSourceIds: {keyed.id},
|
||||||
supportsEmbeddedTranscodeSelection: true,
|
|
||||||
);
|
);
|
||||||
expect(result, [text, image, keyed]);
|
expect(result, [text, image, keyed]);
|
||||||
});
|
});
|
||||||
@@ -164,24 +155,47 @@ void main() {
|
|||||||
[text, unsupported],
|
[text, unsupported],
|
||||||
isTranscoding: true,
|
isTranscoding: true,
|
||||||
sidecarSourceIds: const {},
|
sidecarSourceIds: const {},
|
||||||
supportsEmbeddedTranscodeSelection: true,
|
|
||||||
);
|
);
|
||||||
expect(result, [text]);
|
expect(result, [text]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('only offers resolved sidecars when embedded transcode selection is unsupported', () {
|
test('drops a keyed external row whose sidecar never resolved while transcoding', () {
|
||||||
final external = sub(1, codec: 'srt', key: '/Videos/item/source/Subtitles/1/Stream.srt');
|
// The client fetches this one itself, and on a transcode an external file is never a burn
|
||||||
|
// target - so with no sidecar built, nothing can draw it and reloading cannot help. Offering
|
||||||
|
// it left a selection that silently showed no caption. Its codec is text, which is exactly why
|
||||||
|
// the codec fallback must not cover keyed rows.
|
||||||
|
final unresolved = sub(1, codec: 'srt', key: '/library/streams/1');
|
||||||
final embedded = sub(2, codec: 'srt');
|
final embedded = sub(2, codec: 'srt');
|
||||||
final unavailableExternal = sub(3, codec: 'srt', key: '/missing');
|
final result = selectableSourceSubtitleTracks(
|
||||||
|
[unresolved, embedded],
|
||||||
|
isTranscoding: true,
|
||||||
|
sidecarSourceIds: const {},
|
||||||
|
);
|
||||||
|
expect(result, [embedded], reason: 'the embedded row can still be burned');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('offers a burnable embedded image track that has no sidecar of its own', () {
|
||||||
|
// The regression this pins: a transcode burns PGS server-side, so the
|
||||||
|
// track deliberately has no sidecar. Gating selection on a sidecar - or on
|
||||||
|
// the backend - dropped it from the menu entirely, which is issue #1738's
|
||||||
|
// "PGS subtitles wont appear at all while transcoding". Every backend can
|
||||||
|
// be asked to burn, so the codec is the only thing that may exclude it.
|
||||||
|
final image = sub(1, codec: 'pgssub');
|
||||||
|
final result = selectableSourceSubtitleTracks([image], isTranscoding: true, sidecarSourceIds: const {});
|
||||||
|
expect(result, [image]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('still offers an unresolved external file only once its sidecar exists', () {
|
||||||
|
final resolved = sub(1, codec: 'srt', key: '/Videos/item/source/Subtitles/1/Stream.srt');
|
||||||
|
final unresolved = sub(2, codec: 'weird', key: '/missing');
|
||||||
|
|
||||||
final result = selectableSourceSubtitleTracks(
|
final result = selectableSourceSubtitleTracks(
|
||||||
[external, embedded, unavailableExternal],
|
[resolved, unresolved],
|
||||||
isTranscoding: true,
|
isTranscoding: true,
|
||||||
sidecarSourceIds: {external.id},
|
sidecarSourceIds: {resolved.id},
|
||||||
supportsEmbeddedTranscodeSelection: false,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result, [external]);
|
expect(result, [resolved], reason: 'an unresolved key with an unburnable codec has no delivery route');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('only offers resolved file sidecars during direct play', () {
|
test('only offers resolved file sidecars during direct play', () {
|
||||||
@@ -193,7 +207,6 @@ void main() {
|
|||||||
[embedded, availableExternal, unavailableExternal],
|
[embedded, availableExternal, unavailableExternal],
|
||||||
isTranscoding: false,
|
isTranscoding: false,
|
||||||
sidecarSourceIds: {availableExternal.id},
|
sidecarSourceIds: {availableExternal.id},
|
||||||
supportsEmbeddedTranscodeSelection: false,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result, [embedded, availableExternal]);
|
expect(result, [embedded, availableExternal]);
|
||||||
@@ -213,7 +226,6 @@ void main() {
|
|||||||
[deliveryExternalEmbedded],
|
[deliveryExternalEmbedded],
|
||||||
isTranscoding: false,
|
isTranscoding: false,
|
||||||
sidecarSourceIds: const {},
|
sidecarSourceIds: const {},
|
||||||
supportsEmbeddedTranscodeSelection: false,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result, [deliveryExternalEmbedded]);
|
expect(result, [deliveryExternalEmbedded]);
|
||||||
|
|||||||
Reference in New Issue
Block a user