fix(jellyfin): auto-select direct-played embedded subtitles

Plezy's device profile declares every subtitle format with
`Method: External`, so Jellyfin answers PlaybackInfo with
`DeliveryMethod: External` and a `DeliveryUrl` even for streams embedded
in a direct-played container. Direct play never fetches those URLs, but
the rows kept the delivery URL as `MediaSubtitleTrack.key`, and keyed
rows only match a native track loaded from the same URL. No embedded
track could satisfy that, so `selectSubtitleTrack` reported "still
pending" forever: playback started with subtitles off and logged the
five- and thirty-second waits, and the server's default subtitle had to
be picked by hand on every item.

Restrict sidecar identity to the rows an open actually fetched as
sidecars. A row that stays in the container loses `key` and
`usesExternalDelivery` and matches on metadata again; genuine
`IsExternal` files keep theirs, and remuxed or transcoded renditions
still resolve their sidecars by URL.

Also declare every subtitle format Embed-first so a direct-played
container reports embedded delivery in the first place, and make the
pending contract match its purpose on every backend. The
complete-catalog escape is no longer Plex-only, so a Jellyfin row the
native player has not produced keeps the pass pending instead of
committing an unrelated default and retiring the listener that was
waiting for the real track. A source id absent from the catalog no
longer defers a decision that can never change, and the thirty-second
deadline resolves from what has arrived instead of re-deriving the same
deferral and applying nothing.

close #1696
This commit is contained in:
edde746
2026-07-28 15:22:23 +02:00
parent 19542e57f4
commit 126f5e3aa6
9 changed files with 499 additions and 76 deletions
+126 -5
View File
@@ -725,6 +725,114 @@ void main() {
expect(subtitleUri.queryParameters['api_key'], 'tok-abc');
});
test('getPlaybackInitialization strips sidecar identity from direct-played embedded subtitles', () async {
// Jellyfin answers `Method: External` subtitle profiles with an
// `External` delivery method plus a DeliveryUrl even for streams that
// stay inside a direct-played container. Direct play never fetches those
// URLs, so the rows must not keep an identity that makes track matching
// wait for a sidecar (issue #1696).
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') {
return jsonResponse({
'MediaSources': [
{
'Id': 'src-1',
'Container': 'mkv',
'SupportsDirectPlay': true,
'DefaultSubtitleStreamIndex': 3,
'MediaStreams': [
{'Index': 1, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'IsDefault': true},
{
'Index': 3,
'Type': 'Subtitle',
'Codec': 'ass',
'Language': 'eng',
'DisplayTitle': 'English Forced - ASS',
'IsDefault': true,
'IsForced': true,
'IsExternal': false,
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/0/Stream.ass',
},
{
'Index': 4,
'Type': 'Subtitle',
'Codec': 'ass',
'Language': 'eng',
'DisplayTitle': 'English - ASS',
'IsExternal': false,
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/0/Stream.ass',
},
{
'Index': 5,
'Type': 'Subtitle',
'Codec': 'srt',
'Language': 'swe',
'DisplayTitle': 'Swedish - SRT',
'IsExternal': true,
'DeliveryMethod': 'External',
'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/0/Stream.srt',
},
],
},
],
});
}
return http.Response('{}', 404);
}),
);
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,
),
);
expect(result.isTranscoding, isFalse);
expect(result.playMethod, 'DirectPlay');
final tracks = result.mediaInfo!.subtitleTracks;
expect(tracks.map((track) => track.id), [3, 4, 5]);
// Embedded rows lose the delivery hint they cannot honour.
for (final track in tracks.where((track) => track.id != 5)) {
expect(track.key, isNull, reason: 'embedded row ${track.id} kept a delivery URL');
expect(track.usesExternalDelivery, isFalse);
expect(track.isExternal, isFalse);
}
// A genuine separate file is absent from the container either way, and
// direct play does load it, so it keeps its sidecar identity.
final sidecarRow = tracks.singleWhere((track) => track.id == 5);
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]);
// The server default survives normalization so selection can honour it.
expect(result.mediaInfo!.defaultSubtitleStreamIndex, 3);
expect(tracks.singleWhere((track) => track.id == 3).selected, isTrue);
});
test('getPlaybackInitialization uses negotiated DirectStreamUrl when transcode URL is absent', () async {
final scoped = JellyfinClient.forTesting(
connection: _conn(),
@@ -1578,7 +1686,7 @@ void main() {
expect(capturedUri.toString(), contains('/Items/folder%2Fitem%20%231%3Fx/PlaybackInfo'));
});
test('getPlaybackInfo advertises external subtitle support', () async {
test('getPlaybackInfo advertises embedded and external subtitle delivery', () async {
Uri? capturedUri;
String? capturedBody;
final scoped = JellyfinClient.forTesting(
@@ -1617,12 +1725,25 @@ void main() {
expect(directPlayProfile['AudioCodec'], contains('mp2'));
expect(profile['TranscodingProfiles'], isNotEmpty);
expect(profile['CodecProfiles'], isEmpty);
final subtitleProfiles = profile['SubtitleProfiles'] as List<dynamic>;
const subtitleFormats = ['srt', 'ass', 'ssa', 'vtt', '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.map((profile) => (profile as Map<String, dynamic>)['Format']),
containsAll(['srt', 'ass', 'ssa', 'vtt', 'pgssub', 'dvdsub', 'dvbsub']),
subtitleProfiles.where((entry) => entry['Method'] == 'Embed').map((entry) => entry['Format']),
subtitleFormats,
);
expect(
subtitleProfiles.where((entry) => entry['Method'] == 'External').map((entry) => entry['Format']),
subtitleFormats,
);
expect(
subtitleProfiles.indexWhere((entry) => entry['Method'] == 'Embed'),
lessThan(subtitleProfiles.indexWhere((entry) => entry['Method'] == 'External')),
);
expect(subtitleProfiles.every((profile) => (profile as Map<String, dynamic>)['Method'] == 'External'), isTrue);
});
test('path-encodes reserved ids for browse and watch-state endpoints', () async {
@@ -16,6 +16,7 @@ MediaSubtitleTrack _sourceSubtitle(
bool selected = false,
bool external = false,
bool usesExternalDelivery = false,
String? key,
}) {
return MediaSubtitleTrack(
id: id,
@@ -25,6 +26,9 @@ MediaSubtitleTrack _sourceSubtitle(
title: 'Subtitle $id',
selected: selected,
forced: forced,
// Mirrors JellyfinFileInfoStreamReader: the server ships a delivery URL
// with every row it marks external, and _sidecar's URL contains it.
key: key ?? (external || usesExternalDelivery ? '/subtitles/$id.srt' : null),
external: external,
usesExternalDelivery: usesExternalDelivery,
);
@@ -130,8 +134,10 @@ void main() {
);
});
test('fuzzy-matches Jellyfin external-delivery rows that remain embedded in direct play', () {
final source = _sourceSubtitle(2, language: 'eng', usesExternalDelivery: true);
test('fuzzy-matches a Jellyfin external-delivery row once direct play strips its sidecar identity', () {
// JellyfinClient.getPlaybackInitialization normalizes rows it did not
// fetch as sidecars, which is what makes the embedded stream reachable.
final source = _sourceSubtitle(2, language: 'eng', usesExternalDelivery: true).withoutSidecarIdentity();
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
expect(
@@ -145,6 +151,23 @@ void main() {
native,
);
});
test('a row that kept its sidecar identity never fuzzy-matches a native track', () {
final source = _sourceSubtitle(2, language: 'eng', usesExternalDelivery: true);
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
expect(
PlaybackSubtitleResolver.nativeTrackForSource(
sourceTrack: source,
nativeTracks: const [native],
allSourceTracks: [source],
isResolvedSidecar: false,
isContainerSidecar: false,
),
isNull,
);
});
test('matches a requested source among tracks from one container sidecar', () {
final sources = [_sourceSubtitle(2, language: 'eng'), _sourceSubtitle(3, language: 'eng')];
const nativeTracks = [
+48
View File
@@ -990,6 +990,54 @@ void main() {
mgr.dispose();
});
});
test('thirty-second deadline resolves a subtitle the source never delivered', () async {
await SettingsService.getInstance();
fakeAsync((async) {
// A keyed sidecar that never attaches: the catalog can never prove it
// is complete, so selection defers until the deadline gives up on it.
final mediaInfo = MediaSourceInfo(
videoUrl: 'https://example.com/video.mp4',
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
subtitleTracks: [
MediaSubtitleTrack(
id: 10,
languageCode: 'eng',
codec: 'srt',
selected: true,
forced: false,
key: '/library/streams/10',
external: true,
),
],
chapters: const [],
);
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
subtitle: [SubtitleTrack(id: '10', language: 'eng', codec: 'srt', isDefault: true)],
),
);
final mgr = _make(player: player, mediaInfo: mediaInfo);
mgr.applyTrackSelectionWhenReady();
async.elapse(const Duration(seconds: 5));
async.flushMicrotasks();
// The five-second pass applies ready audio and keeps waiting.
expect(player.selectedAudio, hasLength(1));
expect(player.selectedSubtitle, isEmpty);
async.elapse(const Duration(seconds: 25));
async.flushMicrotasks();
// The deadline must decide rather than defer a third time.
expect(player.selectedSubtitle.map((track) => track.id), ['10']);
expect(async.nonPeriodicTimerCount, 0);
mgr.dispose();
});
});
});
// ============================================================
@@ -701,6 +701,142 @@ void main() {
expect(result.track.language, 'fre');
});
group('Jellyfin direct play (issue #1696)', () {
// JellyfinClient strips sidecar identity from rows it did not fetch, so
// a direct-played embedded stream reaches selection as a plain row.
MediaSourceInfo directPlayInfo() => _info(
defaultSubtitleStreamIndex: 3,
subs: [
_plexSub(
3,
index: 3,
languageCode: 'eng',
title: 'English Forced',
codec: 'ass',
selected: true,
forced: true,
),
_plexSub(4, index: 4, languageCode: 'eng', title: 'English', codec: 'ass'),
],
);
final nativeTracks = [
_sub('1', lang: 'eng', title: 'English Forced', codec: 'ass', isForced: true),
_sub('2', lang: 'eng', title: 'English', codec: 'ass'),
];
test('applies the server default instead of waiting for a sidecar', () {
final result = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
info: directPlayInfo(),
).selectSubtitleTrack(nativeTracks, null, null);
expect(result?.priority, TrackSelectionPriority.serverSelected);
expect(result?.track.id, '1');
});
test('resolves a source preference carried over from the open', () {
final result =
_svc(
metadata: _meta(backend: MediaBackend.jellyfin),
info: directPlayInfo(),
).selectSubtitleTrack(
nativeTracks,
_sub('source:3', lang: 'eng', title: 'English Forced', codec: 'ass', isForced: true, isDefault: true),
null,
);
expect(result?.priority, TrackSelectionPriority.navigation);
expect(result?.track.id, '1');
});
test('an unmatched source preference stops waiting once the catalog is complete', () {
// Both source rows are present natively, so nothing more can arrive:
// the unresolvable preference must fall through, not defer forever.
final result = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
info: directPlayInfo(),
).selectSubtitleTrack(nativeTracks, _sub('source:9', lang: 'kor', codec: 'srt'), null);
expect(result, isNotNull);
expect(result!.track.id, '1');
});
test('waits for a server-selected sidecar even when another native track has arrived', () {
// Transcode: the selected row is delivered as a sidecar and has not
// attached yet, while a different sidecar already has. Committing that
// unrelated track would also mark the pass ready and retire the
// listener, so the real selection could never land.
final info = _info(
defaultSubtitleStreamIndex: 3,
subs: [
_plexSub(3, index: 3, languageCode: 'eng', codec: 'srt', key: '/Subtitles/3', selected: true),
_plexSub(4, index: 4, languageCode: 'swe', codec: 'srt', key: '/Subtitles/4'),
],
);
final arrivedTracks = [_sub('sw', lang: 'swe', codec: 'srt', isDefault: true, isExternal: true)];
final service = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
info: info,
);
expect(service.selectSubtitleTrack(arrivedTracks, null, null), isNull);
// Once the selected sidecar attaches, its keyed identity resolves.
final selectedNative = SubtitleTrack(
id: 'en',
language: 'eng',
codec: 'srt',
isExternal: true,
uri: 'https://jf.example.com/Subtitles/3?api_key=tok',
);
final resolved = service.selectSubtitleTrack([...arrivedTracks, selectedNative], null, null);
expect(resolved?.priority, TrackSelectionPriority.serverSelected);
expect(resolved?.track.id, 'en');
});
});
group('deadline resolution', () {
test('waitForPendingSource: false resolves a source that never arrived', () {
// A sidecar-delivered catalog can never prove completeness, so this
// stays pending until the caller gives up on it.
final info = _info(
subs: [
_plexSub(3, index: 3, languageCode: 'eng', codec: 'srt', key: '/Subtitles/3', selected: true),
_plexSub(4, index: 4, languageCode: 'swe', codec: 'srt', key: '/Subtitles/4'),
],
);
final nativeTracks = [_sub('1', lang: 'eng', codec: 'srt', isDefault: true)];
final service = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
info: info,
);
final preferred = _sub('source:3', lang: 'eng', codec: 'srt');
expect(service.selectSubtitleTrack(nativeTracks, preferred, null), isNull);
final resolved = service.selectSubtitleTrack(nativeTracks, preferred, null, waitForPendingSource: false);
expect(resolved?.priority, TrackSelectionPriority.defaultTrack);
expect(resolved?.track.id, '1');
});
test('waitForPendingSource: false turns an empty native catalog into an explicit off', () {
final info = _info(
subs: [_plexSub(3, index: 3, languageCode: 'eng', codec: 'srt', key: '/Subtitles/3')],
);
final service = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
info: info,
);
expect(service.selectSubtitleTrack(const [], null, null), isNull);
expect(
service.selectSubtitleTrack(const [], null, null, waitForPendingSource: false)?.track.id,
SubtitleTrack.off.id,
);
});
});
test('Jellyfin explicit DefaultSubtitleStreamIndex=-1 forces subtitles off', () {
final tracks = [_sub('1', lang: 'eng', isDefault: true), _sub('2', lang: 'fre')];
final info = _info(