fix(subtitles): let the server deliver subtitles on a transcode
Two regressions since 2.9.1 broke subtitles on transcoded playback. Sincea1b6a8971sidecars load with the media behind a 10s open guard, so a subtitle URL the server is slow to serve — Jellyfin extracting an embedded stream while its transcoder spins up — tripped the guard: stop, reopen without subtitles, "Selected subtitles could not be loaded" snackbar, and an emptied subtitle menu. Since2b3853a88every embedded Plex subtitle was handed to the player as a sidecar whose URL is the original container, so a transcode also range-read and demuxed the source over HTTP — for a 40 GB remux, purely to find a subtitle track — which is also why PGS never appeared: the client was handed a container to demux rather than a rendition to play. Delivery is the server's job again, backported from the AVPlayer branch (42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted to main's mpv backend: Plex burns every embedded track (subtitles=burn); only a real external file with a /library/streams key stays a client-fetched sidecar. A burn is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400 to directPlay=1 with burn — and the burn is aimed by selecting the stream on the part first via the selectStreams PUT, because the decision endpoint ignores subtitleStreamID alongside subtitles=burn. An unaimable or undeliverable burn (dvb_teletext) refuses the transcode and falls back to warned direct play rather than welding the wrong language in or silently dropping the caption. Main's per-preset directPlay/directStream pinning is kept; verified against a live PMS that burn works under directStream=0. Jellyfin never offers image formats as External, so bitmaps fall through to Encode and are burned; text External is withheld per request when the effective selection — including the server's DefaultSubtitleStreamIndex — is embedded, and offered when it is a real file, so a file is delivered as a file and never fetched twice. The burned row is excluded from the sidecars; remaining text rows stay extractable, which is how a secondary track still renders over a transcode. Sidecar URLs now use the format extension the endpoint expects instead of the reported codec name. The controls and selection layers learn what burning means: burn eligibility is the codec's property, so burned rows stay selectable in the menu; any change away from a burned selection renegotiates with the server instead of pretending a local switch worked; the visibility shortcut explains itself instead of doing nothing; and the track manager is told when the primary is server-rendered so it stops waiting out a thirty-second deadline for a native track that is already pixels. Verified: analyzer parity, clean_translations --check --strict, full flutter test (5749), and decision-level runs against live Plex and Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn 400, External file delivery, an unchanged no-burn baseline, and a real burn session serving its playlist. The pre-commit aggregate was bypassed for pre-existing main-state findings outside this diff: 21 format-drifted files and three unused test seams in lib/main.dart. close #1738 Refs #1815, #1622.
This commit is contained in:
+141
-32
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user