fix(player): preserve transcoded subtitles at high speed

close #1622
This commit is contained in:
edde746
2026-07-25 04:21:37 +02:00
parent 0643787fbe
commit 2b3853a882
21 changed files with 1524 additions and 445 deletions
+130 -32
View File
@@ -244,7 +244,7 @@ void main() {
);
});
test('ExoPlayer forwards external subtitle metadata at open', () async {
test('ExoPlayer forwards complete container metadata and rejects empty sidecar URIs', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
@@ -252,18 +252,14 @@ void main() {
eventChannelName: 'com.plezy/exo_player/events',
methodHandler: (call) {
calls.add(call);
switch (call.method) {
case 'initialize':
return Future.value(true);
default:
return Future.value(null);
}
return call.method == 'initialize' ? Future.value(true) : Future.value(null);
},
testBody: () async {
final player = PlayerAndroid();
try {
const containerUri = 'https://example.test/movie.mkv';
await player.open(
Media('https://example.test/movie.mkv'),
Media('https://example.test/transcode.m3u8'),
externalSubtitles: const [
SubtitleTrack(
id: 'external-sub',
@@ -275,20 +271,44 @@ void main() {
isExternal: true,
uri: 'https://example.test/sub.srt',
),
SubtitleTrack(
id: 'container:1',
title: 'English Dialogue',
language: 'eng',
codec: 'ass',
isDefault: true,
isExternal: true,
isContainer: true,
uri: containerUri,
),
SubtitleTrack(
id: 'container:2',
title: 'English Signs',
language: 'eng',
codec: 'ass',
isForced: true,
isExternal: true,
isContainer: true,
uri: containerUri,
),
SubtitleTrack(id: 'invalid', uri: '', isExternal: true, isContainer: true),
],
);
final openCall = calls.singleWhere((call) => call.method == 'open');
final args = Map<Object?, Object?>.from(openCall.arguments as Map);
final external = args['externalSubtitles'] as List;
final subtitle = Map<Object?, Object?>.from(external.single as Map);
final external = (args['externalSubtitles'] as List)
.map((entry) => Map<Object?, Object?>.from(entry as Map))
.toList();
expect(subtitle['uri'], 'https://example.test/sub.srt');
expect(subtitle['title'], 'English Forced');
expect(subtitle['language'], 'eng');
expect(subtitle['codec'], 'srt');
expect(subtitle['isDefault'], isTrue);
expect(subtitle['isForced'], isTrue);
expect(external, hasLength(3));
expect(external.first['uri'], 'https://example.test/sub.srt');
expect(external.first['title'], 'English Forced');
expect(external.first['isDefault'], isTrue);
expect(external.first['isForced'], isTrue);
expect(external.skip(1).map((entry) => entry['uri']).toSet(), {containerUri});
expect(external.skip(1).map((entry) => entry['title']), ['English Dialogue', 'English Signs']);
expect(external.skip(1).every((entry) => entry['isContainer'] == true), isTrue);
} finally {
await player.dispose();
}
@@ -612,41 +632,119 @@ void main() {
);
});
test('MPV preserves external subtitle metadata for loadfile sidecars', () async {
test('MPV restores per-stream metadata while loading a shared container once', () async {
final calls = <MethodCall>[];
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) {
calls.add(call);
return call.method == 'initialize' ? Future.value(true) : Future.value(null);
},
testBody: () async {
final player = PlayerNative();
try {
const subtitleUri = 'https://example.test/subtitles/en-forced.srt';
const subtitleUri = 'https://example.test/movie.mkv?X-Plex-Token=secret';
await player.open(
Media('https://example.test/movie.mkv'),
Media('https://example.test/transcode.m3u8'),
externalSubtitles: const [
SubtitleTrack(
id: 'server-subtitle',
id: 'container:1',
uri: subtitleUri,
title: 'English Forced',
title: 'English Dialogue',
language: 'eng',
codec: 'srt',
codec: 'ass',
isDefault: true,
isExternal: true,
isContainer: true,
),
SubtitleTrack(
id: 'container:2',
uri: subtitleUri,
title: 'English Signs',
language: 'eng',
codec: 'ass',
isForced: true,
isExternal: true,
isContainer: true,
),
],
);
player.handlePropertyChange('track-list', const [
{'type': 'sub', 'id': '1', 'codec': 'subrip', 'external': true, 'external-filename': subtitleUri},
expect(_loadfileArgs(calls), [
'loadfile',
'https://example.test/transcode.m3u8',
'replace',
'-1',
'sub-files=${_fixedLengthPathList([subtitleUri])}',
]);
final subtitle = player.state.tracks.subtitle.single;
expect(subtitle.title, 'English Forced');
expect(subtitle.language, 'eng');
expect(subtitle.codec, 'srt');
expect(subtitle.isDefault, isTrue);
expect(subtitle.isForced, isTrue);
expect(subtitle.uri, subtitleUri);
player.handlePropertyChange('track-list', const [
{'type': 'audio', 'id': 'sidecar-audio', 'external': true, 'external-filename': subtitleUri},
{
'type': 'sub',
'id': '1',
'title': 'movie.mkv?X-Plex-Token=secret',
'default': false,
'forced': false,
'external': true,
'external-filename': subtitleUri,
},
{
'type': 'sub',
'id': '2',
'title': 'movie.mkv?X-Plex-Token=secret',
'default': false,
'forced': false,
'external': true,
'external-filename': subtitleUri,
},
]);
expect(player.state.tracks.audio, isEmpty);
final subtitles = player.state.tracks.subtitle;
expect(subtitles, hasLength(2));
expect(subtitles.map((track) => track.title), ['English Dialogue', 'English Signs']);
expect(subtitles.map((track) => track.language), ['eng', 'eng']);
expect(subtitles.map((track) => track.codec), ['ass', 'ass']);
expect(subtitles.map((track) => track.isDefault), [true, false]);
expect(subtitles.map((track) => track.isForced), [false, true]);
expect(subtitles.every((track) => track.uri == subtitleUri && track.isContainer), isTrue);
} finally {
await player.dispose();
}
},
);
});
test('MPV keeps native metadata fallbacks for non-container subtitles', () async {
await withMockPlayerChannels(
methodChannelName: 'com.plezy/mpv_player',
eventChannelName: 'com.plezy/mpv_player/events',
methodHandler: (call) => call.method == 'initialize' ? Future.value(true) : Future.value(null),
testBody: () async {
final player = PlayerNative();
try {
const sidecarUri = 'https://example.test/subtitle.srt';
await player.open(
Media('https://example.test/movie.mkv'),
externalSubtitles: const [SubtitleTrack(id: 'external', uri: sidecarUri, isExternal: true)],
);
player.handlePropertyChange('track-list', const [
{
'type': 'sub',
'id': 'external',
'title': 'English Dialogue - SRT',
'lang': 'eng',
'codec': 'subrip',
'external': true,
'external-filename': sidecarUri,
},
{'type': 'sub', 'id': 'embedded', 'title': 'French Dialogue - ASS', 'lang': 'fre', 'codec': 'ass'},
]);
expect(player.state.tracks.subtitle.map((track) => track.title), ['English Dialogue', 'French Dialogue']);
expect(player.state.tracks.subtitle.map((track) => track.language), ['eng', 'fre']);
} finally {
await player.dispose();
}
@@ -11,6 +11,7 @@ import '../test_helpers/media_items.dart';
MediaSubtitleTrack _sourceSubtitle(
int id, {
String language = 'eng',
String codec = 'srt',
bool forced = false,
bool selected = false,
bool external = false,
@@ -20,6 +21,7 @@ MediaSubtitleTrack _sourceSubtitle(
id: id,
language: language,
languageCode: language,
codec: codec,
title: 'Subtitle $id',
selected: selected,
forced: forced,
@@ -28,16 +30,30 @@ MediaSubtitleTrack _sourceSubtitle(
);
}
PlaybackSubtitleSidecar _sidecar(int id, {String language = 'eng', bool isDefault = false}) {
PlaybackSubtitleSidecar _sidecar(
int id, {
String language = 'eng',
bool isDefault = false,
bool preload = false,
bool isContainer = false,
String? uri,
}) {
final sidecarUri = uri ?? 'https://example.test/subtitles/$id.srt';
return PlaybackSubtitleSidecar(
sourceStreamId: id,
track: SubtitleTrack.uri(
'https://example.test/subtitles/$id.srt',
title: 'Subtitle $id',
language: language,
codec: 'srt',
isDefault: isDefault,
),
preload: preload,
track: isContainer
? SubtitleTrack(
id: 'container:$id',
title: 'Subtitle $id',
language: language,
codec: 'srt',
isDefault: isDefault,
isExternal: true,
isContainer: true,
uri: sidecarUri,
)
: SubtitleTrack.uri(sidecarUri, title: 'Subtitle $id', language: language, codec: 'srt', isDefault: isDefault),
);
}
@@ -46,17 +62,18 @@ MediaSourceInfo _mediaInfo(List<MediaSubtitleTrack> subtitles) {
}
void main() {
group('direct-play source routing', () {
group('source subtitle routing', () {
test('matches an embedded source to its loaded native track', () {
final source = _sourceSubtitle(2, language: 'eng');
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
expect(
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
PlaybackSubtitleResolver.nativeTrackForSource(
sourceTrack: source,
nativeTracks: const [native],
allSourceTracks: [source],
isResolvedSidecar: false,
isContainerSidecar: false,
),
native,
);
@@ -81,11 +98,12 @@ void main() {
);
expect(
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
PlaybackSubtitleResolver.nativeTrackForSource(
sourceTrack: source,
nativeTracks: const [other],
allSourceTracks: [source],
isResolvedSidecar: true,
isContainerSidecar: false,
),
isNull,
);
@@ -117,15 +135,50 @@ void main() {
const native = SubtitleTrack(id: '7', language: 'eng', codec: 'srt');
expect(
PlaybackSubtitleResolver.nativeTrackForDirectPlaySource(
PlaybackSubtitleResolver.nativeTrackForSource(
sourceTrack: source,
nativeTracks: const [native],
allSourceTracks: [source],
isResolvedSidecar: false,
isContainerSidecar: false,
),
native,
);
});
test('matches a requested source among tracks from one container sidecar', () {
final sources = [_sourceSubtitle(2, language: 'eng'), _sourceSubtitle(3, language: 'eng')];
const nativeTracks = [
SubtitleTrack(
id: '7',
title: 'Subtitle 2',
language: 'eng',
codec: 'srt',
isExternal: true,
isContainer: true,
uri: 'https://example.test/video.mkv',
),
SubtitleTrack(
id: '8',
title: 'Subtitle 3',
language: 'eng',
codec: 'srt',
isExternal: true,
isContainer: true,
uri: 'https://example.test/video.mkv',
),
];
expect(
PlaybackSubtitleResolver.nativeTrackForSource(
sourceTrack: sources.last,
nativeTracks: nativeTracks,
allSourceTracks: sources,
isResolvedSidecar: true,
isContainerSidecar: true,
),
nativeTracks.last,
);
});
});
final metadata = testMediaItem(id: 'movie-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie);
@@ -160,6 +213,24 @@ void main() {
expect(result.sidecarsAtOpen, isEmpty);
});
test('retains each source metadata row for a shared preloaded container', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true), _sourceSubtitle(3)]),
sidecars: [
_sidecar(2, preload: true, isContainer: true, uri: 'https://example.test/video.mkv'),
_sidecar(3, preload: true, isContainer: true, uri: 'https://example.test/video.mkv'),
],
preferredSubtitleTrack: SubtitleTrack.off,
);
expect(result.isOff, isTrue);
expect(result.sidecarsAtOpen, hasLength(2));
expect(result.sidecarsAtOpen.every((track) => track.isContainer), isTrue);
expect(result.sidecarsAtOpen.map((track) => track.id), ['container:2', 'container:3']);
expect(result.sidecarsAtOpen.map((track) => track.uri).toSet(), {'https://example.test/video.mkv'});
});
test('explicit source selection wins over the server default', () {
final mediaInfo = _mediaInfo([
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
@@ -179,6 +250,82 @@ void main() {
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/3.srt');
});
test('explicit source identity wins when subtitle metadata is identical', () {
final mediaInfo = _mediaInfo([
MediaSubtitleTrack(
id: 2,
language: 'eng',
languageCode: 'eng',
codec: 'ass',
title: 'English',
selected: true,
forced: false,
),
MediaSubtitleTrack(
id: 3,
language: 'eng',
languageCode: 'eng',
codec: 'ass',
title: 'English',
selected: false,
forced: false,
),
]);
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: mediaInfo,
sidecars: [
_sidecar(2, isContainer: true, uri: 'https://example.test/video.mkv'),
_sidecar(3, isContainer: true, uri: 'https://example.test/video.mkv'),
],
preferredSubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
);
expect(result.primarySourceStreamId, 3);
});
test('source identity is ignored across media-source changes', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: _mediaInfo([
_sourceSubtitle(3, language: 'eng', selected: true, usesExternalDelivery: true),
_sourceSubtitle(7, language: 'fra', usesExternalDelivery: true),
]),
sidecars: [
_sidecar(3),
_sidecar(7, language: 'fra'),
],
preferredSubtitleTrack: const SubtitleTrack(
id: 'source:3',
title: 'French from the previous source',
language: 'fra',
codec: 'srt',
isExternal: true,
uri: 'https://example.test/previous/3.srt',
),
preserveSourceIdentity: false,
);
expect(result.primarySourceStreamId, 7);
});
test('unmatched source identity cannot bind a reused id after a source change', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: _mediaInfo([_sourceSubtitle(3, language: 'eng'), _sourceSubtitle(7, language: 'spa', selected: true)]),
sidecars: const [],
preferredSubtitleTrack: const SubtitleTrack(
id: 'source:3',
title: 'French from the previous source',
language: 'fra',
codec: 'srt',
),
preserveSourceIdentity: false,
);
expect(result.primarySourceStreamId, 7);
});
test('item-change semantic preference selects the matching new sidecar', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
@@ -240,6 +387,17 @@ void main() {
expect(result.sidecarsAtOpen, isEmpty);
});
test('selected metadata-free embedded subtitle resolves by source identity', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
mediaInfo: _mediaInfo([MediaSubtitleTrack(id: 2, codec: 'ass', selected: true, forced: false)]),
sidecars: const [],
);
expect(result.isOff, isFalse);
expect(result.primarySourceStreamId, 2);
});
test('preferred secondary subtitle attaches a second distinct sidecar', () {
final mediaInfo = _mediaInfo([
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
@@ -10,7 +10,6 @@ import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/models/transcode_quality_preset.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/plex_api_cache.dart';
@@ -44,8 +43,11 @@ void main() {
);
}
List<SubtitleTrack> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
return client.buildTranscodeSidecarSubtitlesForTesting(mediaInfoWithSubtitles(subtitleTracks));
List<PlaybackSubtitleSidecar> buildTranscodeSubtitles(PlexClient client, List<MediaSubtitleTrack> subtitleTracks) {
return client.buildTranscodeSidecarSubtitlesForTesting(
mediaInfoWithSubtitles(subtitleTracks),
'https://plex.example.com/video.mkv?X-Plex-Token=token',
);
}
test('selectStreams sends audio stream selection with allParts', () async {
@@ -129,6 +131,97 @@ void main() {
expect(data.mediaInfo?.subtitleTracks.single.selected, isTrue);
});
test('transcode initialization wires a subtitle-free HLS request to the complete sidecar catalog', () async {
final requests = <http.Request>[];
final client = makeClient((request) async {
requests.add(request);
if (request.url.path == '/library/metadata/42') {
return http.Response(
jsonEncode({
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'movie',
'title': 'Movie',
'Media': [
{
'id': 7,
'container': 'mkv',
'Part': [
{
'id': 99,
'key': '/library/parts/99/file.mkv',
'Stream': [
{'streamType': 1, 'id': 300, 'codec': 'h264'},
{'streamType': 2, 'id': 301, 'index': 0, 'languageCode': 'jpn', 'selected': true},
{
'streamType': 3,
'id': 401,
'index': 1,
'codec': 'ass',
'languageCode': 'eng',
'selected': true,
},
{
'streamType': 3,
'id': 402,
'index': 2,
'codec': 'srt',
'languageCode': 'swe',
'key': '/library/streams/402',
'external': true,
},
],
},
],
},
],
},
],
},
}),
200,
headers: {'content-type': 'application/json'},
);
}
if (request.url.path == '/video/:/transcode/universal/decision') {
return http.Response(
jsonEncode({
'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001},
}),
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,
qualityPreset: TranscodeQualityPreset.p720_3mbps,
sessionIdentifier: 'session-id',
transcodeSessionId: 'transcode-id',
),
);
final decisionRequest = requests.singleWhere(
(request) => request.url.path == '/video/:/transcode/universal/decision',
);
expect(decisionRequest.url.queryParameters['subtitles'], 'none');
expect(decisionRequest.url.queryParameters.containsKey('subtitleStreamID'), isFalse);
expect(decisionRequest.url.queryParameters.containsKey('advancedSubtitles'), isFalse);
expect(result.isTranscoding, isTrue);
expect(result.videoUrl, contains('/video/:/transcode/universal/start.m3u8?'));
expect(result.subtitleSidecars.map((sidecar) => sidecar.sourceStreamId), [401, 402]);
expect(result.subtitleSidecars.every((sidecar) => sidecar.preload), isTrue);
expect(result.subtitleSidecars.first.track.isContainer, isTrue);
expect(result.subtitleSidecars.last.track.uri, contains('/library/streams/402.srt'));
});
test('playback uses metadata availability flags without probing part URLs', () async {
final requests = <http.Request>[];
final client = makeClient((request) async {
@@ -331,16 +424,17 @@ void main() {
expect(result.selectedMediaIndex, 1);
});
test('transcode subtitle sidecars only use real Plex stream keys', () {
test('transcode subtitle catalog includes embedded and keyed Plex streams', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
final subtitles = buildTranscodeSubtitles(client, [
MediaSubtitleTrack(id: 401, codec: 'srt', languageCode: 'eng', selected: false, forced: false),
MediaSubtitleTrack(id: 401, codec: 'ass', languageCode: 'eng', title: 'Embedded', selected: true, forced: false),
MediaSubtitleTrack(
id: 402,
codec: 'srt',
languageCode: 'eng',
languageCode: 'swe',
title: 'External',
selected: false,
forced: false,
key: '/library/streams/402',
@@ -348,63 +442,50 @@ void main() {
),
]);
expect(subtitles, hasLength(1));
expect(subtitles.single.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token');
expect(subtitles, hasLength(2));
expect(subtitles.map((sidecar) => sidecar.sourceStreamId), [401, 402]);
expect(subtitles.every((sidecar) => sidecar.preload), isTrue);
expect(subtitles.first.track.isContainer, isTrue);
expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv?X-Plex-Token=token');
expect(subtitles.last.track.isContainer, isFalse);
expect(
subtitles.last.track.uri,
'https://plex.example.com/library/streams/402.srt?encoding=utf-8&X-Plex-Token=token',
);
});
test('transcode negotiation honors an explicit source subtitle preference', () {
final client = makeClient((_) async => http.Response('not used', 500));
test('tokenless transcode keeps embedded and keyed subtitle sources', () {
final client = testPlexClient(
serverId: ServerId('server-id'),
token: null,
handler: (_) async => http.Response('not used', 500),
);
addTearDown(client.close);
final info = mediaInfoWithSubtitles([
MediaSubtitleTrack(id: 401, languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 402, languageCode: 'swe', selected: false, forced: false),
]);
final selected = client.resolveTranscodeSubtitleTrackForTesting(
info,
const SubtitleTrack(id: 'source:402', language: 'swe'),
final subtitles = client.buildTranscodeSidecarSubtitlesForTesting(
mediaInfoWithSubtitles([
MediaSubtitleTrack(id: 401, codec: 'ass', languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(
id: 402,
codec: 'srt',
languageCode: 'swe',
selected: false,
forced: false,
key: '/library/streams/402',
external: true,
),
]),
'https://plex.example.com/video.mkv',
);
expect(selected?.id, 402);
expect(client.resolveTranscodeSubtitleTrackForTesting(info, SubtitleTrack.off), isNull);
expect(subtitles, hasLength(2));
expect(subtitles.first.track.isContainer, isTrue);
expect(subtitles.first.track.uri, 'https://plex.example.com/video.mkv');
expect(subtitles.last.track.isContainer, isFalse);
expect(subtitles.last.track.uri, 'https://plex.example.com/library/streams/402.srt?encoding=utf-8');
});
test('transcode negotiation falls back to the new source default for a stale source id', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
final info = mediaInfoWithSubtitles([
MediaSubtitleTrack(id: 501, languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 502, languageCode: 'swe', selected: false, forced: false),
]);
final selected = client.resolveTranscodeSubtitleTrackForTesting(
info,
const SubtitleTrack(id: 'source:402', language: 'und'),
);
expect(selected?.id, 501);
});
test('selected internal text subtitles are not attached as external sidecars', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
final subtitles = buildTranscodeSubtitles(client, [
MediaSubtitleTrack(
id: 401,
codec: 'ass',
language: 'English',
languageCode: 'eng',
title: 'Signs/Songs',
selected: true,
forced: false,
),
]);
expect(subtitles, isEmpty);
});
test('selected internal text subtitles are segmented into the HLS transcode', () {
test('video transcode stays subtitle-free while preserving the HLS profile', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
@@ -414,19 +495,12 @@ void main() {
preset: TranscodeQualityPreset.p720_3mbps,
sessionIdentifier: 'session-id',
transcodeSessionId: 'transcode-id',
selectedSubtitleTrack: MediaSubtitleTrack(
id: 401,
codec: 'ass',
languageCode: 'eng',
selected: true,
forced: false,
),
);
expect(params['protocol'], 'hls');
expect(params['subtitles'], 'segmented');
expect(params['subtitleStreamID'], '401');
expect(params['advancedSubtitles'], 'text');
expect(params['subtitles'], 'none');
expect(params.containsKey('subtitleStreamID'), isFalse);
expect(params.containsKey('advancedSubtitles'), isFalse);
expect(params.containsKey('X-Plex-Chunked'), isFalse);
expect(params['X-Plex-Incomplete-Segments'], '1');
expect(params['X-Plex-Client-Profile-Name'], 'Generic');
@@ -444,8 +518,7 @@ void main() {
profile,
contains(
'add-transcode-target(type=videoProfile&context=streaming'
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)',
'&protocol=hls&container=mpegts',
),
);
expect(
@@ -495,43 +568,20 @@ void main() {
expect(params['partIndex'], '2');
});
test('selected image subtitles are burned into HLS without advancedSubtitles', () {
test('image-based embedded subtitles use the shared container sidecar', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
final params = client.buildTranscodeParamsForTesting(
ratingKey: '42',
mediaIndex: 0,
preset: TranscodeQualityPreset.p720_3mbps,
sessionIdentifier: 'session-id',
transcodeSessionId: 'transcode-id',
selectedSubtitleTrack: MediaSubtitleTrack(
id: 401,
codec: 'pgs',
languageCode: 'eng',
selected: true,
forced: false,
),
);
expect(params['subtitles'], 'burn');
expect(params['subtitleStreamID'], '401');
expect(params['protocol'], 'hls');
expect(params.containsKey('advancedSubtitles'), isFalse);
});
test('image-based embedded subtitles are rendered by HLS, not attached as sidecars', () {
final client = makeClient((_) async => http.Response('not used', 500));
addTearDown(client.close);
// Embedded bitmap streams have no Plex `key`; the HLS request burns the
// selected track into the video rendition.
final subtitles = buildTranscodeSubtitles(client, [
MediaSubtitleTrack(id: 401, codec: 'pgs', languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: true, forced: false),
MediaSubtitleTrack(id: 402, codec: 'dvd_subtitle', languageCode: 'eng', selected: false, forced: false),
]);
expect(subtitles, isEmpty);
expect(subtitles, hasLength(2));
expect(subtitles.every((sidecar) => sidecar.track.isContainer), isTrue);
expect(subtitles.map((sidecar) => sidecar.track.uri).toSet(), {
'https://plex.example.com/video.mkv?X-Plex-Token=token',
});
});
group('playback metadata failure contract', () {
+358
View File
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
@@ -7,6 +8,8 @@ import 'package:plezy/media/media_kind.dart';
import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/mpv/mpv.dart';
import 'package:plezy/mpv/player/player_stream_controllers.dart';
import 'package:plezy/screens/video_player_screen.dart';
import 'package:plezy/services/playback_initialization_types.dart';
import 'package:plezy/services/settings_service.dart';
import 'package:plezy/services/track_manager.dart';
@@ -369,6 +372,361 @@ void main() {
expect(player.selectedSubtitle, hasLength(1));
expect(player.selectedSubtitle.single.id, 'no');
});
test('waits through a partial catalog until the selected Plex subtitle arrives', () async {
await SettingsService.getInstance();
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
),
);
final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true));
addTearDown(mgr.dispose);
mgr.applyTrackSelectionWhenReady();
player.emitTracks(
const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
subtitle: [SubtitleTrack(id: '11', language: 'fre')],
),
);
await _drainAsync();
expect(player.selectedSubtitle, isEmpty);
player.emitTracks(
const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
subtitle: [
SubtitleTrack(id: '11', language: 'fre'),
SubtitleTrack(id: '10', language: 'eng'),
],
),
);
await _drainAsync();
expect(player.selectedSubtitle.map((track) => track.id), ['10']);
});
test('waits for a preferred subtitle even when the server-selected track arrives first', () async {
await SettingsService.getInstance();
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
),
);
final mgr = _make(
player: player,
mediaInfo: _mediaInfoWithSubtitles(selected: true),
preferredSubtitleTrack: const SubtitleTrack(id: 'previous', language: 'fre'),
);
addTearDown(mgr.dispose);
mgr.applyTrackSelectionWhenReady();
player.emitTracks(
const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
subtitle: [SubtitleTrack(id: '10', language: 'eng')],
),
);
await _drainAsync();
expect(player.selectedSubtitle, isEmpty);
player.emitTracks(
const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
subtitle: [
SubtitleTrack(id: '10', language: 'eng'),
SubtitleTrack(id: '11', language: 'fre'),
],
),
);
await _drainAsync();
expect(player.selectedSubtitle.map((track) => track.id), ['11']);
});
test('source identity waits for the intended identical container track', () async {
await SettingsService.getInstance();
final mediaInfo = MediaSourceInfo(
videoUrl: 'https://example.com/transcode.m3u8',
partId: 99,
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
subtitleTracks: [
MediaSubtitleTrack(
id: 30,
index: 0,
languageCode: 'eng',
title: 'English',
codec: 'ass',
selected: false,
forced: false,
),
MediaSubtitleTrack(
id: 31,
index: 1,
languageCode: 'eng',
title: 'English',
codec: 'ass',
selected: true,
forced: false,
),
],
chapters: const [],
);
const firstNativeTrack = SubtitleTrack(
id: 'native-0',
language: 'eng',
title: 'English',
codec: 'ass',
isExternal: true,
isContainer: true,
uri: 'https://example.com/video.mkv',
);
const secondNativeTrack = SubtitleTrack(
id: 'native-1',
language: 'eng',
title: 'English',
codec: 'ass',
isExternal: true,
isContainer: true,
uri: 'https://example.com/video.mkv',
);
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: 'audio', language: 'eng')],
subtitle: [firstNativeTrack],
),
);
final mgr = _make(
player: player,
mediaInfo: mediaInfo,
preferredSubtitleTrack: const SubtitleTrack(
id: 'source:31',
language: 'eng',
title: 'English',
codec: 'ass',
isExternal: true,
isContainer: true,
uri: 'https://example.com/video.mkv',
),
);
addTearDown(mgr.dispose);
mgr.applyTrackSelectionWhenReady();
await _drainAsync();
expect(player.selectedSubtitle, isEmpty);
player.emitTracks(
const Tracks(
audio: [AudioTrack(id: 'audio', language: 'eng')],
subtitle: [firstNativeTrack, secondNativeTrack],
),
);
await _drainAsync();
expect(player.selectedSubtitle.map((track) => track.id), ['native-1']);
});
test('deferred transcode source choice applies after native discovery without requesting a reload', () async {
await SettingsService.getInstance();
final sourceTrack = MediaSubtitleTrack(
id: 31,
index: 0,
languageCode: 'eng',
title: 'English',
codec: 'ass',
selected: false,
forced: false,
);
final mediaInfo = MediaSourceInfo(
videoUrl: 'https://example.com/transcode.m3u8',
partId: 99,
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
subtitleTracks: [sourceTrack],
chapters: const [],
);
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: 'audio', language: 'eng')],
),
);
final mgr = _make(player: player, mediaInfo: mediaInfo);
addTearDown(mgr.dispose);
SubtitleTrack? persistedTrack;
int? persistedSourceStreamId;
final handledLocally = await deferTranscodeSubtitleSelection(
trackManager: mgr,
sourceTrack: sourceTrack,
sourceSidecar: const PlaybackSubtitleSidecar(
sourceStreamId: 31,
preload: true,
track: SubtitleTrack(
id: 'container:31',
language: 'eng',
title: 'English',
codec: 'ass',
isExternal: true,
isContainer: true,
uri: 'https://example.com/video.mkv',
),
),
sourceStreamId: 31,
onSubtitleTrackChanged: (track, {sourceStreamId}) async {
persistedTrack = track;
persistedSourceStreamId = sourceStreamId;
},
shouldContinue: () => true,
);
expect(handledLocally, isTrue);
expect(mgr.preferredSubtitleTrack?.id, 'source:31');
expect(persistedTrack?.id, 'source:31');
expect(persistedSourceStreamId, 31);
expect(player.selectedSubtitle, isEmpty);
player.emitTracks(
const Tracks(
audio: [AudioTrack(id: 'audio', language: 'eng')],
subtitle: [
SubtitleTrack(
id: 'native-0',
language: 'eng',
title: 'English',
codec: 'ass',
isExternal: true,
isContainer: true,
uri: 'https://example.com/video.mkv',
),
],
),
);
await _drainAsync();
expect(player.selectedSubtitle.map((track) => track.id), ['native-0']);
});
test('five-second fallback keeps listening and applies a late advertised subtitle', () async {
await SettingsService.getInstance();
fakeAsync((async) {
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
),
);
final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true));
mgr.applyTrackSelectionWhenReady();
async.elapse(const Duration(seconds: 5));
async.flushMicrotasks();
expect(player.selectedAudio, hasLength(1));
expect(player.selectedSubtitle, isEmpty);
player.emitTracks(
const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
subtitle: [SubtitleTrack(id: '10', language: 'eng')],
),
);
async.flushMicrotasks();
expect(player.selectedSubtitle.map((track) => track.id), ['10']);
expect(async.nonPeriodicTimerCount, 0);
mgr.dispose();
});
});
test('five-second fallback keeps listening through a partial subtitle catalog', () async {
await SettingsService.getInstance();
fakeAsync((async) {
final mediaInfo = MediaSourceInfo(
videoUrl: 'https://example.com/transcode.m3u8',
audioTracks: [MediaAudioTrack(id: 1, languageCode: 'eng', selected: true)],
subtitleTracks: [
MediaSubtitleTrack(id: 10, languageCode: 'eng', selected: false, forced: false),
MediaSubtitleTrack(id: 11, languageCode: 'fre', selected: true, forced: false),
],
chapters: const [],
);
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
subtitle: [SubtitleTrack(id: '10', language: 'eng')],
),
);
final mgr = _make(
player: player,
mediaInfo: mediaInfo,
preferredSubtitleTrack: const SubtitleTrack(id: 'source:11', language: 'fre'),
);
mgr.applyTrackSelectionWhenReady();
async.elapse(const Duration(seconds: 5));
async.flushMicrotasks();
expect(player.selectedAudio, hasLength(1));
expect(player.selectedSubtitle, isEmpty);
player.emitTracks(
const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
subtitle: [
SubtitleTrack(id: '10', language: 'eng'),
SubtitleTrack(id: '11', language: 'fre'),
],
),
);
async.flushMicrotasks();
expect(player.selectedSubtitle.map((track) => track.id), ['11']);
expect(async.nonPeriodicTimerCount, 0);
mgr.dispose();
});
});
test('late subtitle arrival queues behind an in-flight fallback selection', () async {
await SettingsService.getInstance();
fakeAsync((async) {
final audioSelection = Completer<void>();
final player = _FakePlayer(
tracks: const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
),
);
var blockFirstAudioSelection = true;
player.onSelectAudioTrack = (_) {
if (!blockFirstAudioSelection) return Future<void>.value();
blockFirstAudioSelection = false;
return audioSelection.future;
};
final mgr = _make(player: player, mediaInfo: _mediaInfoWithSubtitles(selected: true));
mgr.applyTrackSelectionWhenReady();
async.elapse(const Duration(seconds: 5));
async.flushMicrotasks();
expect(player.selectedAudio, hasLength(1));
player.emitTracks(
const Tracks(
audio: [AudioTrack(id: '1', language: 'eng')],
subtitle: [SubtitleTrack(id: '10', language: 'eng')],
),
);
async.flushMicrotasks();
expect(player.selectedSubtitle, isEmpty);
audioSelection.complete();
async.flushMicrotasks();
expect(player.selectedSubtitle.map((track) => track.id), ['10']);
expect(async.nonPeriodicTimerCount, 0);
mgr.dispose();
});
});
});
group('applyTrackSelection ownership', () {
+156 -19
View File
@@ -99,7 +99,18 @@ SubtitleTrack _sub(
String? codec,
bool isDefault = false,
bool isForced = false,
}) => SubtitleTrack(id: id, language: lang, title: title, codec: codec, isDefault: isDefault, isForced: isForced);
bool isExternal = false,
bool isContainer = false,
}) => SubtitleTrack(
id: id,
language: lang,
title: title,
codec: codec,
isDefault: isDefault,
isForced: isForced,
isExternal: isExternal,
isContainer: isContainer,
);
MediaAudioTrack _plexAudio(
int id, {
@@ -132,6 +143,8 @@ MediaSubtitleTrack _plexSub(
bool selected = false,
bool forced = false,
String? codec,
bool external = false,
String? key,
}) {
return MediaSubtitleTrack(
id: id,
@@ -142,6 +155,8 @@ MediaSubtitleTrack _plexSub(
selected: selected,
forced: forced,
codec: codec,
external: external,
key: key,
);
}
@@ -438,14 +453,14 @@ void main() {
group('selectSubtitleTrack', () {
test('Priority 1: preferred id="no" forces subtitles off', () {
final tracks = [_sub('1', lang: 'eng', isDefault: true)];
final result = _svc().selectSubtitleTrack(tracks, const SubtitleTrack(id: 'no'), null);
final result = _svc().selectSubtitleTrack(tracks, const SubtitleTrack(id: 'no'), null)!;
expect(result.priority, TrackSelectionPriority.navigation);
expect(result.track.id, 'no');
});
test('Priority 1: preferred subtitle from navigation matches by language', () {
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
final result = _svc().selectSubtitleTrack(tracks, _sub('99', lang: 'fre'), null);
final result = _svc().selectSubtitleTrack(tracks, _sub('99', lang: 'fre'), null)!;
expect(result.priority, TrackSelectionPriority.navigation);
expect(result.track.id, '2');
});
@@ -458,11 +473,90 @@ void main() {
_plexSub(11, language: 'fre', languageCode: 'fre', selected: true),
],
);
final result = _svc(info: info).selectSubtitleTrack(tracks, null, null);
final result = _svc(info: info).selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.serverSelected);
expect(result.track.language, 'fre');
});
test('partial native catalog stays undetermined until the selected Plex track arrives', () {
final info = _info(
subs: [
_plexSub(10, language: 'eng', selected: true),
_plexSub(11, language: 'fre'),
],
);
final result = _svc(info: info).selectSubtitleTrack([_sub('2', lang: 'fre')], null, null);
expect(result, isNull);
});
test('preferred source waits for its ordinal in a partial identical container catalog', () {
final info = _info(
subs: [
_plexSub(30, index: 0, language: 'eng', title: 'English', codec: 'ass'),
_plexSub(31, index: 1, language: 'eng', title: 'English', codec: 'ass', selected: true),
],
);
const preferred = SubtitleTrack(
id: 'source:31',
language: 'eng',
title: 'English',
codec: 'ass',
isExternal: true,
isContainer: true,
uri: 'https://example.test/video.mkv',
);
const first = SubtitleTrack(
id: 'native-0',
language: 'eng',
title: 'English',
codec: 'ass',
isExternal: true,
isContainer: true,
uri: 'https://example.test/video.mkv',
);
const second = SubtitleTrack(
id: 'native-1',
language: 'eng',
title: 'English',
codec: 'ass',
isExternal: true,
isContainer: true,
uri: 'https://example.test/video.mkv',
);
final service = _svc(info: info);
expect(service.selectSubtitleTrack(const [first], preferred, null), isNull);
expect(service.selectSubtitleTrack(const [first, second], preferred, null)?.track.id, 'native-1');
});
test('preferred keyed source does not fuzzy-match an early same-language container track', () {
final info = _info(
subs: [
_plexSub(10, language: 'eng', codec: 'srt', external: true, key: '/library/streams/10'),
_plexSub(11, language: 'eng', codec: 'srt'),
],
);
const preferred = SubtitleTrack(
id: 'source:10',
language: 'eng',
codec: 'srt',
isExternal: true,
uri: 'https://example.test/library/streams/10.srt',
);
const earlyContainer = SubtitleTrack(
id: 'native-0',
language: 'eng',
codec: 'srt',
isExternal: true,
isContainer: true,
uri: 'https://example.test/video.mkv',
);
expect(_svc(info: info).selectSubtitleTrack(const [earlyContainer], preferred, null), isNull);
});
test('Jellyfin selected subtitle stream wins over DefaultSubtitleStreamIndex', () {
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
final info = _info(
@@ -475,7 +569,7 @@ void main() {
final result = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
info: info,
).selectSubtitleTrack(tracks, null, null);
).selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.serverSelected);
expect(result.track.language, 'fre');
});
@@ -489,7 +583,7 @@ void main() {
_plexSub(11, language: 'fre'),
],
);
final result = _svc(info: info).selectSubtitleTrack(tracks, null, null);
final result = _svc(info: info).selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.serverSelected);
expect(result.track.id, 'no');
});
@@ -505,7 +599,7 @@ void main() {
final result = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
info: info,
).selectSubtitleTrack(tracks, null, null);
).selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.defaultTrack);
expect(result.track.id, '2');
});
@@ -522,7 +616,7 @@ void main() {
final result = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
info: info,
).selectSubtitleTrack(tracks, null, null);
).selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.serverSelected);
expect(result.track.language, 'fre');
});
@@ -539,7 +633,7 @@ void main() {
final result = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
info: info,
).selectSubtitleTrack(tracks, null, null);
).selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.serverSelected);
expect(result.track.id, 'no');
});
@@ -549,7 +643,7 @@ void main() {
final result = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.none),
).selectSubtitleTrack(tracks, null, null);
).selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.profile);
expect(result.track.id, 'no');
});
@@ -563,7 +657,7 @@ void main() {
final result = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced),
).selectSubtitleTrack(tracks, null, null);
).selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.profile);
expect(result.track.id, '2');
});
@@ -573,7 +667,7 @@ void main() {
final result = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.onlyForced),
).selectSubtitleTrack(tracks, null, null);
).selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.profile);
expect(result.track.id, 'no');
});
@@ -583,7 +677,7 @@ void main() {
final result = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always),
).selectSubtitleTrack(tracks, null, null);
).selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.profile);
expect(result.track.id, '2');
});
@@ -593,7 +687,7 @@ void main() {
final result = _svc(
metadata: _meta(backend: MediaBackend.jellyfin),
profile: _jellyfinProfile(defaultSubtitleLanguage: 'eng', subtitleMode: SubtitlePlaybackMode.always),
).selectSubtitleTrack(tracks, null, null);
).selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.profile);
expect(result.track.id, '2');
});
@@ -607,7 +701,7 @@ void main() {
defaultSubtitleLanguage: 'eng',
subtitleMode: SubtitlePlaybackMode.smart,
),
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'eng'));
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'eng'))!;
expect(result.priority, TrackSelectionPriority.profile);
expect(result.track.id, '2');
});
@@ -621,30 +715,57 @@ void main() {
defaultSubtitleLanguage: 'eng',
subtitleMode: SubtitlePlaybackMode.smart,
),
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'jpn'));
).selectSubtitleTrack(tracks, null, _audio('A', lang: 'jpn'))!;
expect(result.priority, TrackSelectionPriority.profile);
expect(result.track.id, '2');
});
test('Priority 3: default-flagged track when no Plex info', () {
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre', isDefault: true)];
final result = _svc().selectSubtitleTrack(tracks, null, null);
final result = _svc().selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.defaultTrack);
expect(result.track.id, '2');
});
test('Priority 4: off when no default and no info', () {
final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')];
final result = _svc().selectSubtitleTrack(tracks, null, null);
final result = _svc().selectSubtitleTrack(tracks, null, null)!;
expect(result.priority, TrackSelectionPriority.off);
expect(result.track.id, 'no');
});
test('Priority 4: off when no available tracks at all', () {
final result = _svc().selectSubtitleTrack(const [], null, null);
final result = _svc().selectSubtitleTrack(const [], null, null)!;
expect(result.priority, TrackSelectionPriority.off);
expect(result.track.id, 'no');
});
test('empty native track list remains undetermined when Plex advertises subtitles', () {
final info = _info(subs: [_plexSub(10, language: 'eng', selected: true)]);
final result = _svc(info: info).selectSubtitleTrack(const [], null, null);
expect(result, isNull);
});
test('empty native track list is also undetermined when Plex selected no subtitle', () {
final info = _info(subs: [_plexSub(10, language: 'eng')]);
final result = _svc(info: info).selectSubtitleTrack(const [], null, null);
expect(result, isNull);
});
test('selected keyed sidecar remains pending when only a same-language container has arrived', () {
final info = _info(
subs: [_plexSub(10, language: 'eng', selected: true, external: true, key: '/library/streams/10')],
);
final earlyContainer = _sub('20', lang: 'eng', isExternal: true, isContainer: true);
final result = _svc(info: info).selectSubtitleTrack([earlyContainer], null, null);
expect(result, isNull);
});
});
// ============================================================
@@ -691,6 +812,22 @@ void main() {
});
});
group('container-sidecar ordinal fallback', () {
final plexTracks = [_plexSub(40, index: 0), _plexSub(41, index: 1)];
final nativeTracks = [
_sub('2_0', isExternal: true, isContainer: true),
_sub('2_1', isExternal: true, isContainer: true),
];
test('maps a metadata-free Plex stream to its container track', () {
expect(findMpvTrackForPlexSubtitle(plexTracks[1], nativeTracks, allPlexTracks: plexTracks), nativeTracks[1]);
});
test('maps a metadata-free container track back to its Plex stream', () {
expect(findPlexTrackForMpvSubtitle(nativeTracks[0], plexTracks, allMpvTracks: nativeTracks)?.id, 40);
});
});
group('findPlexTrackForMpvAudio - same-language disambiguation', () {
// Two French audio tracks differing only by channel count, titles null.
final plexTracks = [