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
+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]);