fix(subtitles): load external subtitle files with the media whether or not selected

Since a1b6a8971 only the selected sidecar attached at open, so mpv's
track-list carried one external subtitle and the track sheet could only
offer the rest as primary source switches - tap-and-hold on a
non-selected external track selected it as primary instead of secondary.

Real external files are cheap static fetches, so Jellyfin, Plex direct
play, and offline discovery now mark them preload and they ride along in
sub-files at open, keeping every external track selectable as a
secondary subtitle without a reopen. Embedded rows extracted on a
transcode stay lazy: extraction can stall behind the transcoder, which
is exactly what used to trip the sidecar open guard.

close #1860
This commit is contained in:
edde746
2026-08-10 21:23:55 +02:00
parent ea356a6112
commit c2bd1d28fd
7 changed files with 133 additions and 0 deletions
@@ -828,6 +828,11 @@ void main() {
// 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.subtitleSidecars.single.preload,
isFalse,
reason: 'an extracted embedded row stays lazy: extraction can stall behind the transcoder (#1738)',
);
expect(result.externalSubtitles.single.title, 'English');
expect(result.externalSubtitles.single.language, 'eng');
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
@@ -1367,6 +1372,11 @@ void main() {
expect(sidecarRow.key, '/Videos/item-1/src-1/Subtitles/5/0/Stream.srt');
expect(sidecarRow.isExternalFile, isTrue);
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [5]);
expect(
result.subtitleSidecars.single.preload,
isTrue,
reason: 'a real file loads with the media so it stays selectable as secondary (#1860)',
);
// The server default survives normalization so selection can honour it.
expect(result.mediaInfo!.defaultSubtitleStreamIndex, 3);
@@ -1465,6 +1475,7 @@ void main() {
expect(result.mediaInfo!.subtitleTracks, hasLength(1));
expect(result.externalSubtitles, hasLength(1));
expect(result.subtitleSidecars.single.sourceStreamId, 3);
expect(result.subtitleSidecars.single.preload, isTrue);
expect(result.externalSubtitles.single.title, 'English');
final subtitleUri = Uri.parse(result.externalSubtitles.single.uri!);
expect(subtitleUri.path, '/Videos/item-1/src-1/Subtitles/3/Stream.srt');
@@ -1617,6 +1628,11 @@ void main() {
// 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]);
expect(
result.subtitleSidecars.map((sidecar) => sidecar.preload),
everyElement(isFalse),
reason: 'extraction-backed rows must not gate the open on the transcoder',
);
});
test('getPlaybackInitialization ignores TranscodingUrl for original playback static fallback', () async {
@@ -356,6 +356,11 @@ void main() {
expect(result.videoUrl, 'content://offline/movie-1');
expect(result.externalSubtitles, hasLength(1));
expect(result.externalSubtitles.single.uri, Uri.file(subtitlePath).toString());
expect(
result.subtitleSidecars.single.preload,
isTrue,
reason: 'local sidecars load with the media so they stay selectable as secondary subtitles (#1860)',
);
});
test('cache-only playback extras fills missing Plex marker types from chapters', () async {
@@ -228,6 +228,29 @@ void main() {
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/2.srt');
});
test('a preloaded sidecar attaches at open even when not selected', () {
// The clients mark real external files preload, so the non-selected file
// still loads with the media and stays selectable as a secondary subtitle
// without a reopen (#1860).
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: _mediaInfo([
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
]),
sidecars: [
_sidecar(2, preload: true),
_sidecar(3, language: 'swe', preload: true),
],
);
expect(result.primarySourceStreamId, 2);
expect(result.sidecarsAtOpen.map((track) => track.uri), [
'https://example.test/subtitles/2.srt',
'https://example.test/subtitles/3.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
@@ -377,6 +377,81 @@ void main() {
expect(result.subtitleSidecars.single.track.uri, contains('/library/streams/402.srt'));
});
test('direct play preloads every external subtitle file, not just the selected one', () async {
// Two sidecar files next to the video: only one is selected, but both must
// load with the media so the other stays selectable as a secondary
// subtitle without a reopen (#1860). The embedded row is the container's
// job on direct play and gets no sidecar.
final client = makeClient((request) async {
if (request.url.path == '/library/metadata/42') {
return http.Response(
jsonEncode({
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'movie',
'title': 'Movie',
'Media': [
{
'id': 7,
'container': 'mp4',
'Part': [
{
'id': 99,
'key': '/library/parts/99/file.mp4',
'Stream': [
{'streamType': 1, 'id': 300, 'codec': 'h264'},
{'streamType': 2, 'id': 301, 'index': 0, 'languageCode': 'eng', 'selected': true},
{
'streamType': 3,
'id': 401,
'index': 1,
'codec': 'srt',
'languageCode': 'deu',
'key': '/library/streams/401',
'external': true,
'selected': true,
},
{
'streamType': 3,
'id': 402,
'index': 2,
'codec': 'srt',
'languageCode': 'fra',
'key': '/library/streams/402',
'external': true,
},
{'streamType': 3, 'id': 403, 'index': 3, 'codec': 'ass', 'languageCode': 'eng'},
],
},
],
},
],
},
],
},
}),
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,
),
);
expect(result.playMethod, 'DirectPlay');
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [401, 402]);
expect(result.subtitleSidecars.map((sidecar) => sidecar.preload), everyElement(isTrue));
});
test('playback uses metadata availability flags without probing part URLs', () async {
final requests = <http.Request>[];
final client = makeClient((request) async {