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:
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user