fix(subtitles): let the server deliver subtitles on a transcode

Two regressions since 2.9.1 broke subtitles on transcoded playback. Since
a1b6a8971 sidecars 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. Since 2b3853a88 every 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:
edde746
2026-08-09 07:30:47 +02:00
parent 8740a19f36
commit f4ce60611b
45 changed files with 1817 additions and 187 deletions
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Jurnallar yükləndi",
"logsUploadFailed": "Jurnallar yüklənə bilmədi",
"logId": "Jurnal ID-si"
"logId": "Jurnal ID-si",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Mətn",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Логовете са качени",
"logsUploadFailed": "Неуспешно качване на логовете",
"logId": "ID на лога"
"logId": "ID на лога",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Текст",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Logfilerne blev uploadet",
"logsUploadFailed": "Logfilerne kunne ikke uploades",
"logId": "Log-ID"
"logId": "Log-ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Tekst",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Protokolle hochgeladen",
"logsUploadFailed": "Protokolle konnten nicht hochgeladen werden",
"logId": "Protokoll-ID"
"logId": "Protokoll-ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Text",
+2 -1
View File
@@ -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.",
"logsUploaded": "Logs uploaded",
"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": {
"text": "Text",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Registros subidos",
"logsUploadFailed": "Error al subir registros",
"logId": "ID de registro"
"logId": "ID de registro",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Texto",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Journaux envoyés",
"logsUploadFailed": "Échec de lenvoi des journaux",
"logId": "Identifiant du journal"
"logId": "Identifiant du journal",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Texte",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Naplók feltöltve",
"logsUploadFailed": "Nem sikerült a naplók feltöltése",
"logId": "Naplóazonosító"
"logId": "Naplóazonosító",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Szöveg",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Log caricati",
"logsUploadFailed": "Impossibile caricare i log",
"logId": "ID log"
"logId": "ID log",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Testo",
+2 -1
View File
@@ -744,7 +744,8 @@
"serverBusyBody": "",
"logsUploaded": "ログをアップロードしました",
"logsUploadFailed": "ログのアップロードに失敗しました",
"logId": "ログID"
"logId": "ログID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "テキスト",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Журналдар жүктелді",
"logsUploadFailed": "Журналдарды жүктеу мүмкін болмады",
"logId": "Журнал ID-сі"
"logId": "Журнал ID-сі",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Мәтін",
+2 -1
View File
@@ -744,7 +744,8 @@
"serverBusyBody": "",
"logsUploaded": "로그 업로드 완료",
"logsUploadFailed": "로그 업로드 실패",
"logId": "로그 ID"
"logId": "로그 ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "텍스트",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Logger lastet opp",
"logsUploadFailed": "Kunne ikke laste opp logger",
"logId": "Logg-ID"
"logId": "Logg-ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Tekst",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Logbestanden geüpload",
"logsUploadFailed": "Uploaden van logbestanden mislukt",
"logId": "Logboek-ID"
"logId": "Logboek-ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Tekst",
+2 -1
View File
@@ -756,7 +756,8 @@
"serverBusyBody": "",
"logsUploaded": "Logi przesłane",
"logsUploadFailed": "Nie udało się przesłać logów",
"logId": "ID logu"
"logId": "ID logu",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Tekst",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Logs enviados",
"logsUploadFailed": "Falha ao enviar logs",
"logId": "ID do log"
"logId": "ID do log",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Texto",
+2 -1
View File
@@ -756,7 +756,8 @@
"serverBusyBody": "",
"logsUploaded": "Логи загружены",
"logsUploadFailed": "Не удалось загрузить логи",
"logId": "ID лога"
"logId": "ID лога",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Текст",
+1 -1
View File
@@ -4,7 +4,7 @@
/// To regenerate, run: `dart run slang`
///
/// Locales: 22
/// Strings: 38876 (1767 per locale)
/// Strings: 38877 (1767 per locale)
// coverage:ignore-file
// ignore_for_file: type=lint, unused_import
+6 -2
View File
@@ -2223,6 +2223,9 @@ class Translations$messages$en {
/// en: '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
@@ -6829,6 +6832,7 @@ extension on Translations {
'messages.logsUploaded' => 'Logs uploaded',
'messages.logsUploadFailed' => 'Failed to upload logs',
'messages.logId' => 'Log ID',
'messages.burnedSubtitlesUseMenu' => 'Subtitles are burned into this stream. Change them from the subtitle menu.',
'subtitlingStyling.text' => 'Text',
'subtitlingStyling.border' => 'Border',
'subtitlingStyling.background' => 'Background',
@@ -7152,9 +7156,9 @@ extension on Translations {
'explore.badge.available' => 'Available',
'explore.badge.partiallyAvailable' => 'Partly available',
'explore.badge.availableIn4k' => '4K available',
'explore.badge.requested' => 'Requested',
_ => null,
} ?? switch (path) {
'explore.badge.requested' => 'Requested',
'explore.badge.pendingApproval' => 'Pending approval',
'explore.badge.processing' => 'Processing',
'explore.badge.declined' => 'Declined',
@@ -7666,9 +7670,9 @@ extension on Translations {
'performanceOverlay.renderFps' => 'Render FPS',
'performanceOverlay.displayFps' => 'Display FPS',
'performanceOverlay.avSync' => 'A/V Sync',
'performanceOverlay.dropped' => 'Dropped',
_ => null,
} ?? switch (path) {
'performanceOverlay.dropped' => 'Dropped',
'performanceOverlay.dvRpus' => 'DV RPUs',
'performanceOverlay.dvRpuAverage' => 'DV RPU Avg',
'performanceOverlay.dvSampleAverage' => 'DV Sample Avg',
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Loggarna har laddats upp",
"logsUploadFailed": "Det gick inte att ladda upp loggarna",
"logId": "Logg-ID"
"logId": "Logg-ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Text",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Günlükler yüklendi",
"logsUploadFailed": "Günlükler yüklenemedi",
"logId": "Günlük Kimliği (ID)"
"logId": "Günlük Kimliği (ID)",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Metin",
+2 -1
View File
@@ -748,7 +748,8 @@
"serverBusyBody": "",
"logsUploaded": "Jurnallar yuklandi",
"logsUploadFailed": "Jurnallarni yuklab boʻlmadi",
"logId": "Jurnal ID-si"
"logId": "Jurnal ID-si",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "Matn",
+2 -1
View File
@@ -744,7 +744,8 @@
"serverBusyBody": "",
"logsUploaded": "日誌已上傳",
"logsUploadFailed": "上傳日誌失敗",
"logId": "日誌 ID"
"logId": "日誌 ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "文字",
+2 -1
View File
@@ -744,7 +744,8 @@
"serverBusyBody": "",
"logsUploaded": "日志已上传",
"logsUploadFailed": "上传日志失败",
"logId": "日志 ID"
"logId": "日志 ID",
"burnedSubtitlesUseMenu": ""
},
"subtitlingStyling": {
"text": "文本",
@@ -72,7 +72,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
_currentMediaInfo?.subtitleTracks ?? const <MediaSubtitleTrack>[],
isTranscoding: _isTranscoding,
sidecarSourceIds: sidecarSourceIds,
supportsEmbeddedTranscodeSelection: _currentMetadata.backend == MediaBackend.plex,
);
}
@@ -387,6 +387,38 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
PlaybackSourceSubtitleChoice choice, {
required bool Function() shouldContinue,
}) 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) {
await currentPlayer.selectSecondarySubtitleTrack(SubtitleTrack.off);
if (!shouldContinue()) return false;
@@ -850,6 +882,12 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
preferredSubtitleTrack:
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
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.cacheExternalSubtitles(subtitleSelection.sidecarsAtOpen);
@@ -99,6 +99,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
preserveSourceIdentity: preserveSubtitleSourceIdentity,
isTranscoding: result.isTranscoding,
);
}
@@ -467,6 +468,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
AudioTrack? preferredAudioTrack,
SubtitlePreference? preferredSubtitleTrack,
SubtitlePreference? preferredSecondarySubtitleTrack,
bool primarySubtitleIsServerRendered = false,
}) {
return TrackManager(
player: forPlayer,
@@ -481,6 +483,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: preferredSubtitleTrack,
preferredSecondarySubtitleTrack: preferredSecondarySubtitleTrack,
primarySubtitleIsServerRendered: primarySubtitleIsServerRendered,
showMessage: (message, {duration}) {
if (mounted) showAppSnackBar(context, message, duration: duration);
},
@@ -361,6 +361,12 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
preferredSubtitleTrack:
subtitleSelection.declinedPreference ?? SubtitlePreference.trackOrNull(subtitleSelection.primaryTrack),
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.
+1
View File
@@ -69,6 +69,7 @@ import 'scrub_preview_source.dart';
import 'subtitle_preference.dart';
import 'track_selection_service.dart';
import '../mpv/mpv.dart';
import '../utils/codec_utils.dart';
part 'jellyfin_client/parts/browse.dart';
part 'jellyfin_client/parts/music.dart';
@@ -31,6 +31,7 @@ mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals {
bool? allowVideoStreamCopy,
bool? allowAudioStreamCopy,
bool audioProfile,
bool burnSubtitles,
});
String _withApiKey(String urlOrPath);
+108 -27
View File
@@ -163,7 +163,6 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
);
var effectiveSourceId = bundle.selectedSourceId;
var effectiveContainer = bundle.container;
var includeExternalSubtitleDelivery = false;
String? videoUrl;
String? playSessionId;
@@ -186,6 +185,32 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
: findSourceAudioTrackForIntent(options.preferredAudioTrack!, mediaInfo.audioTracks)?.id
: _validJellyfinAudioStreamId(options.selectedAudioStreamId, 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
? null
: isTrack
@@ -207,6 +232,11 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
audioStreamIndex: requestedAudioStreamId,
subtitleStreamIndex: requestedSubtitleStreamId,
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);
} catch (error, stackTrace) {
@@ -249,7 +279,6 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
videoUrl = _withApiKey(transcodingUrl);
playMethod = 'Transcode';
isTranscoding = true;
includeExternalSubtitleDelivery = true;
} else if (!wantsOriginal) {
fallbackReason = TranscodeFallbackReason.directPlayOnly;
}
@@ -259,13 +288,26 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
mediaInfo = _withSelectedJellyfinAudioStream(mediaInfo, effectiveAudioStreamId);
// Tracks have no subtitle streams to assemble (a `Lyric` stream may be
// 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
? const <PlaybackSubtitleSidecar>[]
: _buildExternalSubtitles(
metadata.id,
effectiveSourceId,
mediaInfo,
includeExternalDelivery: includeExternalSubtitleDelivery,
isTranscoding: isTranscoding,
burnedSourceStreamId: burnedSourceStreamId,
);
mediaInfo = _withSidecarBackedSubtitleIdentity(mediaInfo, subtitleSidecars);
// 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
/// 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
/// `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
/// reads them out of the container instead — but the leftover delivery URL
/// 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 codec = track.codec;
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(
pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$codec'],
pathSegments: ['Videos', itemId, sourceId, 'Subtitles', streamIndex.toString(), 'Stream.$extension'],
).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(
String itemId,
String? mediaSourceId,
MediaSourceInfo mediaInfo, {
bool includeExternalDelivery = false,
bool isTranscoding = false,
int? burnedSourceStreamId,
}) {
final externalSubtitles = <PlaybackSubtitleSidecar>[];
for (final track in mediaInfo.subtitleTracks) {
if (!track.isExternalFile && !(includeExternalDelivery && track.usesExternalDelivery)) {
continue;
}
if (burnedSourceStreamId != null && track.id == burnedSourceStreamId) continue;
final isText = CodecUtils.isTextSubtitleCodec(track.codec);
if (isTranscoding && !isText) continue;
if (!track.isExternalFile && !isTranscoding) continue;
final path = track.key ?? _jellyfinSubtitleFallbackPath(itemId, mediaSourceId, track);
if (path == null) continue;
// Jellyfin's subtitle URL is a path relative to baseUrl; build the
@@ -601,6 +665,10 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
bool? allowVideoStreamCopy,
bool? allowAudioStreamCopy,
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 {
final query = <String, String>{
'userId': connection.userId,
@@ -683,27 +751,40 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
'AudioCodec': 'flac,mp3,aac,alac,opus,vorbis,wav,wma',
},
],
// Embed is listed first so a direct-played container reports its
// subtitle streams as `DeliveryMethod: Embed`, matching what the
// native player actually reads. External stays declared for every
// format because a remux or transcode drops those streams from the
// rendition and the server must hand us sidecar URLs instead; the
// server picks per play method, so both entries are required.
'SubtitleProfiles': const <Map<String, Object?>>[
{'Format': 'srt', 'Method': 'Embed'},
{'Format': 'ass', 'Method': 'Embed'},
{'Format': 'ssa', 'Method': 'Embed'},
{'Format': 'vtt', 'Method': 'Embed'},
{'Format': 'pgssub', 'Method': 'Embed'},
{'Format': 'dvdsub', 'Method': 'Embed'},
{'Format': 'dvbsub', 'Method': 'Embed'},
// `Embed` covers direct play and an mkv remux, where the native
// player reads the subtitle stream straight out of the container.
// Jellyfin only offers it when the delivered container can carry
// subtitles, so it is unreachable on an HLS transcode (ts/mp4) and
// is listed for every format purely for the direct paths.
//
// `External` asks the server to extract a stream and serve it as a
// subtitle file. It is offered only when the caller is not asking for
// a transcode: on a transcode the owner decision is that the server
// delivers the picture complete, so every subtitle is burned in and
// the client fetches nothing alongside it. Jellyfin matches an
// external profile by text-vs-image format and never consults whether
// the stream is embedded or a real file, so the list cannot express
// "files as files, embedded burned" - offering text `External` at all
// is what made embedded text arrive as a sidecar.
//
// With no matching `External` entry the server finds no external
// profile and falls through to `Encode`, which is also why image
// formats never appear here: a bitmap handed over as a separate
// stream alongside a transcode is not something the client can render.
'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'},
{'Format': 'pgssub', 'Method': 'External'},
{'Format': 'dvdsub', 'Method': 'External'},
{'Format': 'dvbsub', 'Method': 'External'},
],
],
},
},
@@ -160,6 +160,7 @@ class PlaybackSubtitleResolver {
SubtitlePreference? preferredSubtitleTrack,
SubtitlePreference? preferredSecondarySubtitleTrack,
bool preserveSourceIdentity = true,
bool isTranscoding = false,
}) {
final candidates = <_SubtitleCandidate>[];
final matchedSidecars = <PlaybackSubtitleSidecar>{};
@@ -238,6 +239,11 @@ class PlaybackSubtitleResolver {
secondaryCandidate = candidates
.where((candidate) => candidate.track.id == secondary?.id && candidate.track.id != primary.id)
.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(
@@ -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
/// this as the next open's preferred track makes it the highest-priority
/// choice without retaining a stale sidecar URL.
+141 -32
View File
@@ -74,6 +74,7 @@ import 'plex_lyrics_parser.dart';
import 'plex_mappers.dart';
import 'plex_playback_mapper.dart';
import 'playback_initialization_types.dart';
import 'subtitle_preference.dart';
import 'track_selection_service.dart';
part 'plex_client/parts/live_tv.dart';
@@ -2603,9 +2604,10 @@ class PlexClient
/// Build an HLS VOD transcode stream URL (decision + start path).
///
/// Subtitle delivery stays outside the HLS video stream. Callers attach
/// Plex subtitle sources independently, so changing subtitle tracks never
/// restarts the video transcode.
/// [selectedSubtitleTrack] is burned into the picture by the server, so
/// switching to a different embedded track needs a new transcode session.
/// Real external subtitle files are unaffected — they ride alongside as
/// sidecars the client fetches directly.
///
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
/// seeks + quality/version/audio switches within one playback so the
@@ -2619,8 +2621,11 @@ class PlexClient
required String transcodeSessionId,
int? audioStreamId,
Duration? offset,
MediaSubtitleTrack? selectedSubtitleTrack,
int? partId,
}) async {
try {
await selectSubtitleStreamForBurn(partId: partId, track: selectedSubtitleTrack);
final allParams = _buildTranscodeParams(
ratingKey: ratingKey,
mediaIndex: mediaIndex,
@@ -2630,6 +2635,7 @@ class PlexClient
transcodeSessionId: transcodeSessionId,
audioStreamId: audioStreamId,
offset: offset,
selectedSubtitleTrack: selectedSubtitleTrack,
);
return await _runTranscodeDecision(
startEndpoint: _plexVideoHlsStartEndpoint,
@@ -2841,6 +2847,36 @@ class PlexClient
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).
///
/// Mirrors [buildTranscodeStartPath] for audio tracks: the same
@@ -2939,8 +2975,10 @@ class PlexClient
required String transcodeSessionId,
int? audioStreamId,
Duration? offset,
MediaSubtitleTrack? selectedSubtitleTrack,
}) {
final isOriginal = preset.isOriginal;
final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack);
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
);
@@ -2952,7 +2990,13 @@ class PlexClient
'partIndex': partIndex.toString(),
'protocol': _plexVideoHlsProtocol,
'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',
'subtitleSize': '100',
'audioBoost': '100',
@@ -2964,7 +3008,14 @@ class PlexClient
'mediaBufferSize': '102400',
'session': transcodeSessionId,
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(),
'Accept-Language': 'en',
'X-Plex-Session-Identifier': sessionIdentifier,
@@ -2994,6 +3045,7 @@ class PlexClient
required String transcodeSessionId,
int? audioStreamId,
Duration? offset,
MediaSubtitleTrack? selectedSubtitleTrack,
}) {
return _buildTranscodeParams(
ratingKey: ratingKey,
@@ -3004,6 +3056,7 @@ class PlexClient
transcodeSessionId: transcodeSessionId,
audioStreamId: audioStreamId,
offset: offset,
selectedSubtitleTrack: selectedSubtitleTrack,
);
}
@@ -3528,6 +3581,7 @@ class PlexClient
final resolvedAudioId = carriedAudioTrack == null
? _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo)
: carriedAudioStreamId;
final requestedSubtitleTrack = _resolveTranscodeSubtitleTrack(data.mediaInfo, options.preferredSubtitleTrack);
final result = await buildTranscodeStartPath(
ratingKey: options.metadata.id,
mediaIndex: data.selectedMediaIndex,
@@ -3537,11 +3591,21 @@ class PlexClient
transcodeSessionId: options.transcodeSessionId!,
audioStreamId: resolvedAudioId,
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 subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo, data.videoUrl!);
final subtitleSidecars = _buildTranscodeSidecarSubtitles(data.mediaInfo);
return PlaybackInitializationResult(
availableVersions: data.availableVersions,
videoUrl: transcodeUrl,
@@ -3618,6 +3682,65 @@ class PlexClient
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
/// server. Returns `null` for tracks that aren't external (no `/library/
/// streams/{id}` key) or when the server has no auth token.
@@ -3658,39 +3781,25 @@ class PlexClient
);
}
SubtitleTrack _containerSubtitleTrackFromMediaTrack(MediaSubtitleTrack track, String url) {
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.
/// Build the subtitle sidecars for Plex transcode playback.
///
/// Real sidecar files keep their direct stream URL. Embedded subtitle
/// streams share the original media container as a subtitle-only source;
/// player backends filter that source to text tracks. Every entry is
/// preloaded so changing subtitles is a local track selection.
List<PlaybackSubtitleSidecar> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo, String sourceUrl) {
/// Only real external subtitle files belong here: they are small, have a
/// direct stream URL, and cost nothing to fetch. Embedded streams are burned
/// into the picture by the transcoder, so handing the client the original
/// media container to demux would mean range-reading the whole source over
/// HTTP alongside the transcode it was meant to avoid.
List<PlaybackSubtitleSidecar> _buildTranscodeSidecarSubtitles(MediaSourceInfo? mediaInfo) {
if (mediaInfo == null) return const [];
final tracks = <PlaybackSubtitleSidecar>[];
for (final sub in mediaInfo.subtitleTracks) {
try {
final directUrl = _buildSidecarSubtitleUrl(sub);
if (directUrl == null) continue;
tracks.add(
PlaybackSubtitleSidecar(
sourceStreamId: sub.id,
track: directUrl == null
? _containerSubtitleTrackFromMediaTrack(sub, sourceUrl)
: _subtitleTrackFromMediaTrack(sub, directUrl),
track: _subtitleTrackFromMediaTrack(sub, directUrl),
preload: true,
),
);
@@ -3702,8 +3811,8 @@ class PlexClient
}
@visibleForTesting
List<PlaybackSubtitleSidecar> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo, String sourceUrl) {
return _buildTranscodeSidecarSubtitles(mediaInfo, sourceUrl);
List<PlaybackSubtitleSidecar> buildTranscodeSidecarSubtitlesForTesting(MediaSourceInfo? mediaInfo) {
return _buildTranscodeSidecarSubtitles(mediaInfo);
}
/// Build list of external subtitle tracks from media info
+12 -2
View File
@@ -680,8 +680,18 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
'directStreamAudio': directStreamAudio ? '1' : '0',
'mediaBufferSize': '157286',
'session': transcodeSessionId,
// Prevent Plex from auto-selecting and burning tuner captions into the video.
// Captions that survive direct stream remain player-selectable text tracks.
// Deliberately NOT the VOD policy, which burns the selected embedded
// 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',
'copyts': '0',
'Accept-Language': 'en',
+56 -5
View File
@@ -24,7 +24,6 @@ typedef TrackPreferencePersister =
/// automatic track selection, server preference sync, and cycling.
///
/// Follows the same manager pattern as [VideoFilterManager]:
/// constructed with a [Player] + callbacks, mutated via public setters,
/// disposed when the player screen tears down.
class TrackManager {
final Player player;
@@ -54,6 +53,10 @@ class TrackManager {
SubtitlePreference? preferredSubtitleTrack;
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 ─────────────────────────────────────────────────
bool waitingForExternalSubsTrackSelection = false;
@@ -99,6 +102,7 @@ class TrackManager {
this.preferredAudioTrack,
this.preferredSubtitleTrack,
this.preferredSecondarySubtitleTrack,
this.primarySubtitleIsServerRendered = false,
this.showMessage,
});
@@ -269,16 +273,35 @@ class TrackManager {
}
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;
if (!hasAnyTracks) return false;
final realAudioTracks = tracks.audio
.where((track) => track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id)
.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;
// Selection owns the catalog-completeness decision. A null subtitle result
@@ -286,6 +309,33 @@ class TrackManager {
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
/// whether every player mutation completed for this still-active owner.
///
@@ -337,6 +387,7 @@ class TrackManager {
isActive: selectionIsActive,
onPlayerMutationDispatched: _trackDispatchedPlayerMutation,
waitForPendingSource: waitForPendingSource,
primarySubtitleIsServerRendered: primarySubtitleIsServerRendered,
);
} catch (e) {
appLogger.w('Failed to apply track selection', error: e);
+30 -2
View File
@@ -916,6 +916,23 @@ class TrackSelectionService {
}
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);
if (sourceTrack == null) return null;
return findMpvTrackForPlexSubtitle(sourceTrack, availableTracks, allPlexTracks: plexMediaInfo?.subtitleTracks);
@@ -1234,6 +1251,11 @@ class TrackSelectionService {
bool Function()? isActive,
void Function(Future<void> mutation)? onPlayerMutationDispatched,
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 {
final player = this.player;
if (player == null) {
@@ -1243,8 +1265,14 @@ class TrackSelectionService {
if (!canMutatePlayer()) return false;
// Wait for tracks to be loaded
if (player.state.tracks.audio.isEmpty && player.state.tracks.subtitle.isEmpty) {
// Wait for tracks to be loaded, unless nothing can arrive: a burned-in primary with no
// 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 {
await player.streams.tracks
.where((t) => t.audio.isNotEmpty || t.subtitle.isNotEmpty)
+2
View File
@@ -56,6 +56,8 @@ class CodecUtils {
'dvdsub' ||
'vobsub' ||
'dvb_sub' ||
// Jellyfin's own spelling, which is what the transcode profile asks it to burn.
'dvbsub' ||
'dvb_subtitle' => true,
_ => false,
};
@@ -12,6 +12,17 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
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;
// Nothing to hide when no subtitle track is selected.
if (currentTrack == null || currentTrack.id == SubtitleTrack.off.id) return;
@@ -19,6 +30,26 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
_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) {
// Reset visibility when user explicitly picks a new subtitle track
if (track.id != 'no' && !_subtitlesVisible) {
+23 -8
View File
@@ -105,13 +105,15 @@ part 'parts/visibility.dart';
/// Subtitle tracks offered in the player's "source" subtitle list.
///
/// Direct play exposes embedded tracks in the native player and can attach
/// arbitrary sidecars itself. A transcode can only deliver external sidecars
/// or embedded codecs that the server can convert/burn into the rendition.
/// arbitrary sidecars itself. A transcode can only deliver a resolved sidecar or
/// 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> tracks, {
required bool isTranscoding,
required Set<int> sidecarSourceIds,
required bool supportsEmbeddedTranscodeSelection,
}) {
if (!isTranscoding) {
return tracks
@@ -123,11 +125,24 @@ List<MediaSubtitleTrack> selectableSourceSubtitleTracks(
.toList(growable: false);
}
return tracks
.where(
(track) =>
sidecarSourceIds.contains(track.id) ||
(supportsEmbeddedTranscodeSelection && CodecUtils.isTranscodableSubtitleCodec(track.codec)),
)
.where((track) {
if (sidecarSourceIds.contains(track.id)) return true;
// A row the client has to fetch itself is selectable only once its sidecar resolved: when that
// 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);
}
+528 -20
View File
@@ -828,14 +828,444 @@ void main() {
expect(uri.queryParameters.containsKey('StartTimeTicks'), isFalse);
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
expect(result.mediaInfo!.subtitleTracks.single.isExternalFile, isFalse);
expect(result.mediaInfo!.subtitleTracks.single.usesExternalDelivery, isTrue);
expect(result.externalSubtitles, hasLength(1));
// Nothing is selected and the fixture declares no default, so the server burns nothing and
// this embedded row stays fetchable as an extracted file.
expect(result.subtitleSidecars.single.sourceStreamId, 2);
expect(result.externalSubtitles.single.title, 'English');
expect(result.externalSubtitles.single.language, 'eng');
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
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 {
@@ -1185,13 +1615,11 @@ void main() {
expect(result.playMethod, 'Transcode');
expect(result.mediaInfo!.subtitleTracks, hasLength(3));
expect(result.mediaInfo!.subtitleTracks.every((track) => track.usesExternalDelivery), isTrue);
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 4, 5]);
expect(result.externalSubtitles.map((subtitle) => Uri.parse(subtitle.uri!).path), [
'/Videos/item-1/src-1/Subtitles/3/Stream.srt',
'/Videos/item-1/src-1/Subtitles/4/Stream.srt',
'/Videos/item-1/src-1/Subtitles/5/Stream.srt',
]);
// The last request matched no row, so the effective selection is the server's default
// (`DefaultSubtitleStreamIndex: 4`) and that is the stream the server burns in. It is the
// one row not fetched: a sidecar for it would paint a second copy over the burned pixels.
// The other two stay fetchable, which is what keeps a secondary track renderable.
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [3, 5]);
});
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');
});
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 {
final cases = <(String, Future<http.Response> Function(http.Request))>[
('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'));
});
test('getPlaybackInfo advertises embedded and external subtitle delivery', () async {
test('getPlaybackInfo advertises embedded delivery for every format, external for text only', () async {
Uri? capturedUri;
String? capturedBody;
final scoped = JellyfinClient.forTesting(
@@ -1894,20 +2348,22 @@ void main() {
expect(directPlayProfile['AudioCodec'], contains('mp2'));
expect(profile['TranscodingProfiles'], isNotEmpty);
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 = [
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
// play method, so direct play reports its container streams as embedded
// while a remux or transcode still hands back sidecar URLs.
expect(
subtitleProfiles.where((entry) => entry['Method'] == 'Embed').map((entry) => entry['Format']),
subtitleFormats,
);
// Embed is offered for every format and listed first, so a direct play
// or mkv remux reports its container streams as embedded. External is
// text-only: the server extracts those into a small subtitle file, while
// an image format finds no external match and gets burned in instead.
expect(subtitleProfiles.where((entry) => entry['Method'] == 'Embed').map((entry) => entry['Format']), [
...textSubtitleFormats,
...imageSubtitleFormats,
]);
expect(
subtitleProfiles.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']),
subtitleFormats,
textSubtitleFormats,
);
expect(
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 {
final captured = <Uri>[];
final scoped = JellyfinClient.forTesting(
@@ -228,6 +228,33 @@ void main() {
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', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
@@ -711,6 +738,59 @@ void main() {
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', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
@@ -14,6 +14,7 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/mpv/mpv.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/plex_api_cache.dart';
import 'package:plezy/services/plex_client.dart';
@@ -132,10 +133,7 @@ void main() {
}
List<PlaybackSubtitleSidecar> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
return client.buildTranscodeSidecarSubtitlesForTesting(
mediaInfoWithSubtitles(subtitleTracks),
'https://plex.example.com/video.mkv?X-Plex-Token=token',
);
return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks));
}
test('selectStreams sends audio stream selection with allParts', () async {
@@ -247,7 +245,7 @@ void main() {
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 client = makeClient((request) async {
requests.add(request);
@@ -310,6 +308,9 @@ void main() {
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);
});
addTearDown(client.close);
@@ -328,19 +329,30 @@ void main() {
final decisionRequest = requests.singleWhere(
(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.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['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.videoUrl, contains('/video/:/transcode/universal/start.m3u8?'));
expect(Uri.parse(result.videoUrl!).queryParameters['offset'], '495.000000');
expect(PlexClient.transcodeStreamOffsetFromUrl(result.videoUrl!), const Duration(minutes: 8, seconds: 15));
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [401, 402]);
expect(result.subtitleSidecars.every((sidecar) => sidecar.preload), isTrue);
expect(result.subtitleSidecars.first.track.isContainer, isTrue);
expect(result.subtitleSidecars.last.track.uri, contains('/library/streams/402.srt'));
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [402]);
expect(result.subtitleSidecars.single.preload, isTrue);
expect(result.subtitleSidecars.single.track.uri, contains('/library/streams/402.srt'));
});
test('playback uses metadata availability flags without probing part URLs', () async {
@@ -545,37 +557,39 @@ void main() {
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));
addTearDown(client.close);
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(
id: 402,
id: 403,
index: 5,
codec: 'srt',
languageCode: 'swe',
title: 'External',
selected: false,
forced: false,
key: '/library/streams/402',
key: '/library/streams/403',
external: true,
),
]);
expect(subtitles, hasLength(2));
expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [401, 402]);
expect(subtitles.every((sidecar) => sidecar.preload), isTrue);
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(subtitles.map((sidecar) => sidecar.sourceStreamId), [403]);
expect(subtitles.single.preload, isTrue);
expect(subtitles.single.track.isContainer, isFalse);
expect(
subtitles.last.track.uri,
'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token',
subtitles.single.track.uri,
'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(
serverId: ServerId('server-id'),
token: null,
@@ -596,17 +610,235 @@ void main() {
external: true,
),
]),
'https://plex.example.com/video.mkv',
);
expect(subtitles, hasLength(2));
expect(subtitles.first.track.isContainer, isTrue);
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');
expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [402]);
expect(subtitles.single.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));
addTearDown(client.close);
@@ -701,6 +933,7 @@ void main() {
),
const Duration(minutes: 8, seconds: 15),
);
expect(
PlexClient.transcodeStreamOffsetFromUrl(
'https://plex.example.com/video/:/transcode/universal/start.m3u8?session=abc',
@@ -777,6 +1010,137 @@ void main() {
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', () {
const mediaPlaylist =
'#EXTM3U\n'
@@ -1052,20 +1416,28 @@ void main() {
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));
addTearDown(client.close);
final subtitles = buildTranscodeSubtitles(client, [
MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: false, forced: false),
]);
final tracks = [
MediaSubtitleTrack(id: 401, index: 3, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 402, index: 4, codec: 'dvd_subtitle', languageCode: 'eng', selected: false, forced: false),
];
expect(subtitles, hasLength(2));
expect(subtitles.every((sidecar) => sidecar.track.isContainer), isTrue);
expect(subtitles.map((sidecar) => sidecar.track.uri).toSet(), {
'https://plex.example.com/video.mkv?X-Plex-Token=token',
});
expect(buildTranscodeSubtitles(client, tracks), isEmpty);
final params = client.buildTranscodeParamsForTesting(
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', () {
+221
View File
@@ -133,6 +133,16 @@ class _FakePlayer with PlayerStreamControllersMixin implements Player {
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
Future<void> setRate(double rate) async {
rates.add(rate);
@@ -153,6 +163,8 @@ TrackManager _make({
SubtitleTrack? preferredSubtitleTrack,
void Function(String, {Duration? duration})? showMessage,
TrackPreferencePersister? persister,
bool primarySubtitleIsServerRendered = false,
SubtitleTrack? preferredSecondarySubtitleTrack,
}) {
return TrackManager(
player: player,
@@ -164,6 +176,8 @@ TrackManager _make({
mediaInfo: mediaInfo,
preferredAudioTrack: preferredAudioTrack,
preferredSubtitleTrack: SubtitlePreference.trackOrNull(preferredSubtitleTrack),
primarySubtitleIsServerRendered: primarySubtitleIsServerRendered,
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(preferredSecondarySubtitleTrack),
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 {
for (var i = 0; i < 5; i++) {
await Future<void>.delayed(Duration.zero);
@@ -383,6 +408,125 @@ void main() {
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 {
final settings = await SettingsService.getInstance();
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 {
await SettingsService.getInstance();
final player = _FakePlayer(
+3
View File
@@ -65,6 +65,9 @@ void main() {
'dvdsub',
'vobsub',
'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',
'PGS',
]) {
+32 -20
View File
@@ -133,15 +133,7 @@ void main() {
test('returns the full list unchanged when not transcoding', () {
final tracks = [sub(1, codec: 'srt'), sub(2, codec: 'pgs'), sub(3, codec: 'weird')];
expect(
selectableSourceSubtitleTracks(
tracks,
isTranscoding: false,
sidecarSourceIds: const {},
supportsEmbeddedTranscodeSelection: false,
),
tracks,
);
expect(selectableSourceSubtitleTracks(tracks, isTranscoding: false, sidecarSourceIds: const {}), tracks);
});
test('keeps text, image and keyed tracks while transcoding', () {
@@ -152,7 +144,6 @@ void main() {
[text, image, keyed],
isTranscoding: true,
sidecarSourceIds: {keyed.id},
supportsEmbeddedTranscodeSelection: true,
);
expect(result, [text, image, keyed]);
});
@@ -164,24 +155,47 @@ void main() {
[text, unsupported],
isTranscoding: true,
sidecarSourceIds: const {},
supportsEmbeddedTranscodeSelection: true,
);
expect(result, [text]);
});
test('only offers resolved sidecars when embedded transcode selection is unsupported', () {
final external = sub(1, codec: 'srt', key: '/Videos/item/source/Subtitles/1/Stream.srt');
test('drops a keyed external row whose sidecar never resolved while transcoding', () {
// 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 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(
[external, embedded, unavailableExternal],
[resolved, unresolved],
isTranscoding: true,
sidecarSourceIds: {external.id},
supportsEmbeddedTranscodeSelection: false,
sidecarSourceIds: {resolved.id},
);
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', () {
@@ -193,7 +207,6 @@ void main() {
[embedded, availableExternal, unavailableExternal],
isTranscoding: false,
sidecarSourceIds: {availableExternal.id},
supportsEmbeddedTranscodeSelection: false,
);
expect(result, [embedded, availableExternal]);
@@ -213,7 +226,6 @@ void main() {
[deliveryExternalEmbedded],
isTranscoding: false,
sidecarSourceIds: const {},
supportsEmbeddedTranscodeSelection: false,
);
expect(result, [deliveryExternalEmbedded]);