Episode advance carried live player state, so the viewer's choice only survived while every episode could serve it: one episode without the picked audio or subtitle fell back, and the fallback became the carry for the rest of the session. The screen now keeps the last explicit audio, subtitle, and secondary-subtitle choices for its lifetime; automatic outcomes never overwrite them, so the choice retries on every following episode and reattaches as soon as a catalog can serve it again. Audio catches up with the subtitle carry from #1785. The old matcher required raw language equality (a 'sv' pick never found a 'swe' row) and otherwise took the first same-language track, flipping a commentary or alternate-mix pick back to the main mix on every episode. Audio now uses the same evidence bands as subtitles: bridged language parity is authoritative, a unique title match vouches for untagged tracks, codec and channel-count parity only break ties, and an ambiguous catalog declines to the server's own choice instead of guessing. The synthesized source descriptor also prefers the row's own title over the display title that collapses to the bare language. Episode advance previously sent no audio hint to negotiation at all, so a transcode baked in the server's default audio no matter what was playing. Both backends now resolve the carried semantics against the new episode's streams: Jellyfin sends the resolved AudioStreamIndex, Plex feeds the transcode decision, an explicit per-part stream id always wins, and a failed match falls back to the server's pick. close #1785
777 lines
27 KiB
Dart
777 lines
27 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
|
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/models/jellyfin/jellyfin_user_profile.dart';
|
|
import 'package:plezy/mpv/mpv.dart';
|
|
import 'package:plezy/services/jellyfin_media_info_mapper.dart';
|
|
import 'package:plezy/services/playback_initialization_types.dart';
|
|
import 'package:plezy/services/playback_subtitle_resolver.dart';
|
|
import 'package:plezy/services/subtitle_preference.dart';
|
|
|
|
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,
|
|
bool usesExternalDelivery = false,
|
|
String? key,
|
|
String? title,
|
|
}) {
|
|
return MediaSubtitleTrack(
|
|
id: id,
|
|
language: language,
|
|
languageCode: language,
|
|
codec: codec,
|
|
title: 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,
|
|
);
|
|
}
|
|
|
|
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,
|
|
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),
|
|
);
|
|
}
|
|
|
|
MediaSourceInfo _mediaInfo(List<MediaSubtitleTrack> subtitles) {
|
|
return MediaSourceInfo(videoUrl: '', audioTracks: const [], subtitleTracks: subtitles, chapters: const []);
|
|
}
|
|
|
|
void main() {
|
|
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.nativeTrackForSource(
|
|
sourceTrack: source,
|
|
nativeTracks: const [native],
|
|
allSourceTracks: [source],
|
|
isResolvedSidecar: false,
|
|
isContainerSidecar: false,
|
|
),
|
|
native,
|
|
);
|
|
});
|
|
|
|
test('does not fuzzy-match a different loaded external sidecar', () {
|
|
final source = MediaSubtitleTrack(
|
|
id: 2,
|
|
codec: 'srt',
|
|
languageCode: 'eng',
|
|
key: '/library/streams/2',
|
|
external: true,
|
|
selected: false,
|
|
forced: false,
|
|
);
|
|
const other = SubtitleTrack(
|
|
id: '9',
|
|
language: 'eng',
|
|
codec: 'srt',
|
|
isExternal: true,
|
|
uri: 'https://server/library/streams/9.srt',
|
|
);
|
|
|
|
expect(
|
|
PlaybackSubtitleResolver.nativeTrackForSource(
|
|
sourceTrack: source,
|
|
nativeTracks: const [other],
|
|
allSourceTracks: [source],
|
|
isResolvedSidecar: true,
|
|
isContainerSidecar: false,
|
|
),
|
|
isNull,
|
|
);
|
|
});
|
|
|
|
test('cycles through typed off and every authoritative source id, including zero', () {
|
|
final tracks = [_sourceSubtitle(0), _sourceSubtitle(2)];
|
|
|
|
expect(
|
|
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off(), 1),
|
|
const PlaybackSourceSubtitleChoice.source(0),
|
|
);
|
|
expect(
|
|
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0), 1),
|
|
const PlaybackSourceSubtitleChoice.source(2),
|
|
);
|
|
expect(
|
|
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2), 1),
|
|
const PlaybackSourceSubtitleChoice.off(),
|
|
);
|
|
expect(
|
|
PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off(), 4),
|
|
const PlaybackSourceSubtitleChoice.source(0),
|
|
);
|
|
});
|
|
|
|
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(
|
|
PlaybackSubtitleResolver.nativeTrackForSource(
|
|
sourceTrack: source,
|
|
nativeTracks: const [native],
|
|
allSourceTracks: [source],
|
|
isResolvedSidecar: false,
|
|
isContainerSidecar: false,
|
|
),
|
|
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 = [
|
|
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);
|
|
|
|
test('attaches only the server-selected sidecar from the full catalog', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([
|
|
_sourceSubtitle(2, selected: true, usesExternalDelivery: true),
|
|
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
|
|
]),
|
|
sidecars: [
|
|
_sidecar(2),
|
|
_sidecar(3, language: 'swe'),
|
|
],
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 2);
|
|
expect(result.sidecarsAtOpen, hasLength(1));
|
|
expect(result.sidecarsAtOpen.single.uri, 'https://example.test/subtitles/2.srt');
|
|
});
|
|
|
|
test('explicit off produces an open with zero sidecars', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true, usesExternalDelivery: true)]),
|
|
sidecars: [_sidecar(2)],
|
|
preferredSubtitleTrack: const SubtitlePreference.off(),
|
|
);
|
|
|
|
expect(result.isOff, isTrue);
|
|
expect(result.sidecarsAtOpen, isEmpty);
|
|
});
|
|
|
|
group('Jellyfin server subtitle answer', () {
|
|
// Verbatim shape of a direct-played episode from a real Jellyfin server:
|
|
// the container flags one row default+forced, and the response carries the
|
|
// index the server picked for this user (null when it picked none).
|
|
MediaSourceInfo jellyfinInfo({Object? defaultSubtitleStreamIndex}) => jellyfinMediaSourceToMediaSourceInfo({
|
|
'Id': 'src-1',
|
|
'DefaultSubtitleStreamIndex': defaultSubtitleStreamIndex,
|
|
'MediaStreams': [
|
|
{'Index': 1, 'Type': 'Audio', 'Language': 'eng', 'IsDefault': true},
|
|
{'Index': 3, 'Type': 'Subtitle', 'Language': 'eng', 'Codec': 'ass', 'IsDefault': true, 'IsForced': true},
|
|
{'Index': 4, 'Type': 'Subtitle', 'Language': 'eng', 'Codec': 'ass'},
|
|
],
|
|
});
|
|
|
|
JellyfinUserProfile profileWithMode(String mode) => JellyfinUserProfile.fromUserDto({
|
|
'Configuration': {'SubtitleMode': mode, 'PlayDefaultAudioTrack': true},
|
|
});
|
|
|
|
test('a user who turned subtitles off on the server gets none (#1779)', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: jellyfinInfo(),
|
|
sidecars: const [],
|
|
profileSettings: profileWithMode('None'),
|
|
);
|
|
|
|
expect(result.isOff, isTrue);
|
|
});
|
|
|
|
test('the stream the server did pick still plays', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: jellyfinInfo(defaultSubtitleStreamIndex: 3),
|
|
sidecars: const [],
|
|
profileSettings: profileWithMode('Default'),
|
|
);
|
|
|
|
expect(result.isOff, isFalse);
|
|
expect(result.primarySourceStreamId, 3);
|
|
});
|
|
|
|
test('an off persisted through progress reports plays nothing', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: jellyfinInfo(defaultSubtitleStreamIndex: -1),
|
|
sidecars: const [],
|
|
profileSettings: profileWithMode('Default'),
|
|
);
|
|
|
|
expect(result.isOff, isTrue);
|
|
});
|
|
});
|
|
|
|
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: const SubtitlePreference.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),
|
|
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
|
|
]);
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: mediaInfo,
|
|
sidecars: [
|
|
_sidecar(2),
|
|
_sidecar(3, language: 'swe'),
|
|
],
|
|
preferredSubtitleTrack: SubtitlePreference.trackOrNull(
|
|
PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
|
),
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 3);
|
|
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: SubtitlePreference.trackOrNull(
|
|
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 SubtitlePreference.track(
|
|
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 SubtitlePreference.track(
|
|
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,
|
|
mediaInfo: _mediaInfo([
|
|
_sourceSubtitle(7, language: 'eng', usesExternalDelivery: true),
|
|
_sourceSubtitle(9, language: 'fra', usesExternalDelivery: true),
|
|
]),
|
|
sidecars: [
|
|
_sidecar(7),
|
|
_sidecar(9, language: 'fra'),
|
|
],
|
|
preferredSubtitleTrack: const SubtitlePreference.intent(
|
|
SubtitleIntent(
|
|
language: 'eng',
|
|
forced: false,
|
|
title: 'English from the previous episode',
|
|
codec: 'srt',
|
|
isExternal: true,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 7);
|
|
expect(result.primarySidecar?.track.uri, 'https://example.test/subtitles/7.srt');
|
|
expect(result.sidecarsAtOpen, hasLength(1));
|
|
});
|
|
|
|
test('item-change semantic preference distinguishes forced and full subtitles in one language', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([
|
|
_sourceSubtitle(7, language: 'eng', usesExternalDelivery: true),
|
|
_sourceSubtitle(8, language: 'eng', forced: true, usesExternalDelivery: true),
|
|
]),
|
|
sidecars: [_sidecar(7), _sidecar(8)],
|
|
preferredSubtitleTrack: const SubtitlePreference.intent(
|
|
SubtitleIntent(
|
|
language: 'eng',
|
|
forced: true,
|
|
title: 'English forced from the previous episode',
|
|
codec: 'srt',
|
|
isExternal: true,
|
|
),
|
|
),
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 8);
|
|
expect(result.primaryTrack.isForced, isTrue);
|
|
expect(result.primarySidecar?.track.uri, 'https://example.test/subtitles/8.srt');
|
|
});
|
|
|
|
group('issue #1716/#1717 forced-class carry-over', () {
|
|
const forcedIntent = SubtitlePreference.intent(
|
|
SubtitleIntent(language: 'fra', forced: true, title: 'FR Forced [ASS]', codec: 'srt', isExternal: true),
|
|
);
|
|
|
|
test("picks the next episode's title-only forced row", () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([
|
|
_sourceSubtitle(7, language: 'fra', usesExternalDelivery: true),
|
|
_sourceSubtitle(8, language: 'fra', title: 'FR Forced', usesExternalDelivery: true),
|
|
]),
|
|
sidecars: [
|
|
_sidecar(7, language: 'fra'),
|
|
_sidecar(8, language: 'fra'),
|
|
],
|
|
preferredSubtitleTrack: forcedIntent,
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 8);
|
|
expect(result.primaryTrack.isForced, isTrue);
|
|
});
|
|
|
|
test('picks a flag-forced row for a title-forced intent (cross-form)', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([
|
|
_sourceSubtitle(7, language: 'fra', usesExternalDelivery: true),
|
|
_sourceSubtitle(8, language: 'fra', forced: true, usesExternalDelivery: true),
|
|
]),
|
|
sidecars: [
|
|
_sidecar(7, language: 'fra'),
|
|
_sidecar(8, language: 'fra'),
|
|
],
|
|
preferredSubtitleTrack: forcedIntent,
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 8);
|
|
});
|
|
|
|
test('declines to the server-selected full row when no forced row exists', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([
|
|
_sourceSubtitle(7, language: 'fra', selected: true, usesExternalDelivery: true),
|
|
_sourceSubtitle(9, language: 'eng', usesExternalDelivery: true),
|
|
]),
|
|
sidecars: [
|
|
_sidecar(7, language: 'fra'),
|
|
_sidecar(9),
|
|
],
|
|
preferredSubtitleTrack: forcedIntent,
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 7);
|
|
});
|
|
|
|
test('subtitles stay off when no forced row exists and nothing is selected', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([_sourceSubtitle(7, language: 'fra', usesExternalDelivery: true)]),
|
|
sidecars: [_sidecar(7, language: 'fra')],
|
|
preferredSubtitleTrack: forcedIntent,
|
|
);
|
|
|
|
expect(result.isOff, isTrue);
|
|
});
|
|
|
|
test('a full intent does not inherit a forced-only catalog (symmetric)', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([
|
|
_sourceSubtitle(8, language: 'fra', title: 'FR Forced', usesExternalDelivery: true),
|
|
_sourceSubtitle(9, language: 'eng', selected: true, usesExternalDelivery: true),
|
|
]),
|
|
sidecars: [
|
|
_sidecar(8, language: 'fra'),
|
|
_sidecar(9),
|
|
],
|
|
preferredSubtitleTrack: const SubtitlePreference.intent(
|
|
SubtitleIntent(language: 'fra', forced: false, title: 'French', codec: 'srt', isExternal: true),
|
|
),
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 9);
|
|
});
|
|
});
|
|
|
|
group('issue #1785 declined-carry surfacing', () {
|
|
const swedishIntent = SubtitlePreference.intent(
|
|
SubtitleIntent(language: 'swe', forced: false, title: 'Swedish', codec: 'srt'),
|
|
);
|
|
|
|
MediaSubtitleTrack untaggedRow(int id, {String? title, String codec = 'srt'}) =>
|
|
MediaSubtitleTrack(id: id, title: title, codec: codec, selected: false, forced: false);
|
|
|
|
test('an untagged catalog row with the matching title still serves the carry', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([untaggedRow(3, title: 'English'), untaggedRow(4, title: 'Swedish')]),
|
|
sidecars: const [],
|
|
preferredSubtitleTrack: swedishIntent,
|
|
preserveSourceIdentity: false,
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 4);
|
|
expect(result.declinedPreference, isNull);
|
|
});
|
|
|
|
test('a carry no row can serve is kept as declined, not converted to off', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([_sourceSubtitle(3, language: 'eng')]),
|
|
sidecars: const [],
|
|
preferredSubtitleTrack: swedishIntent,
|
|
preserveSourceIdentity: false,
|
|
);
|
|
|
|
expect(result.isOff, isTrue);
|
|
expect(result.declinedPreference, swedishIntent);
|
|
});
|
|
|
|
test('an explicit off carry is a decision, never a decline', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([_sourceSubtitle(3, language: 'eng')]),
|
|
sidecars: const [],
|
|
preferredSubtitleTrack: const SubtitlePreference.off(),
|
|
preserveSourceIdentity: false,
|
|
);
|
|
|
|
expect(result.isOff, isTrue);
|
|
expect(result.declinedPreference, isNull);
|
|
});
|
|
|
|
test('a ladder off with no carry at all is a decision, never a decline', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([_sourceSubtitle(3, language: 'eng')]),
|
|
sidecars: const [],
|
|
);
|
|
|
|
expect(result.isOff, isTrue);
|
|
expect(result.declinedPreference, isNull);
|
|
});
|
|
|
|
test('same-language rows are told apart by their titles across episodes (#1785)', () {
|
|
// Real-world shape (Madoka Magica on Plex): both rows English ASS with
|
|
// the bare displayTitle "English"; only Title separates the full
|
|
// dialogue track from the signs/songs track.
|
|
MediaSubtitleTrack row(int id, String title, {bool selected = false}) => MediaSubtitleTrack(
|
|
id: id,
|
|
language: 'English',
|
|
languageCode: 'eng',
|
|
title: title,
|
|
displayTitle: 'English',
|
|
codec: 'ass',
|
|
selected: selected,
|
|
forced: false,
|
|
);
|
|
|
|
// E1: the viewer picks the signs track and the session commits it. The
|
|
// committed track must keep the discriminating row title, not the
|
|
// language-only display title.
|
|
final committed = PlaybackSubtitleResolver.subtitleTrackForSource(row(53164, 'Signs/OP/ED'));
|
|
expect(committed.title, 'Signs/OP/ED');
|
|
|
|
// Episode boundary demotes the committed reference to its intent.
|
|
final carried = SubtitlePreference.demoteToIntent(SubtitlePreference.track(committed));
|
|
|
|
// E2: same pair; the dialogue row sorts first AND is server-selected —
|
|
// the carried signs choice must still win over both.
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([row(53193, 'Styled Subtitles', selected: true), row(53194, 'Signs/OP/ED')]),
|
|
sidecars: const [],
|
|
preferredSubtitleTrack: carried,
|
|
preserveSourceIdentity: false,
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 53194);
|
|
expect(result.declinedPreference, isNull);
|
|
});
|
|
|
|
test('a Jellyfin signs pick beats the server per-episode default on the next episode (#1785)', () {
|
|
// Verbatim shape from a live Jellyfin server (Attack on Titan): both
|
|
// rows English ASS, discriminated by Title, and the server names the
|
|
// full-dialogue row (DefaultSubtitleStreamIndex 3) on every episode.
|
|
MediaSourceInfo episodeInfo() => jellyfinMediaSourceToMediaSourceInfo({
|
|
'Id': 'src-1',
|
|
'DefaultSubtitleStreamIndex': 3,
|
|
'MediaStreams': [
|
|
{'Index': 1, 'Type': 'Audio', 'Language': 'jpn', 'IsDefault': true},
|
|
{
|
|
'Index': 3,
|
|
'Type': 'Subtitle',
|
|
'Language': 'eng',
|
|
'Codec': 'ass',
|
|
'Title': 'Full Subtitles',
|
|
'DisplayTitle': 'Full Subtitles - English - Default - ASS',
|
|
'IsDefault': true,
|
|
},
|
|
{
|
|
'Index': 4,
|
|
'Type': 'Subtitle',
|
|
'Language': 'eng',
|
|
'Codec': 'ass',
|
|
'Title': 'Signs & Songs',
|
|
'DisplayTitle': 'Signs & Songs - English - ASS',
|
|
},
|
|
],
|
|
});
|
|
|
|
final e1 = episodeInfo();
|
|
final picked = e1.subtitleTracks.firstWhere((row) => row.id == 4);
|
|
final committed = PlaybackSubtitleResolver.subtitleTrackForSource(picked);
|
|
expect(committed.title, 'Signs & Songs');
|
|
|
|
final carried = SubtitlePreference.demoteToIntent(SubtitlePreference.track(committed));
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: episodeInfo(),
|
|
sidecars: const [],
|
|
preferredSubtitleTrack: carried,
|
|
preserveSourceIdentity: false,
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 4);
|
|
expect(result.declinedPreference, isNull);
|
|
});
|
|
});
|
|
|
|
test('audio source descriptor keeps the discriminating row title', () {
|
|
// Server display titles collapse to the bare language; a commentary or
|
|
// alternate mix is only identifiable by the row's own title.
|
|
final row = MediaAudioTrack(
|
|
id: 7,
|
|
languageCode: 'eng',
|
|
title: 'Commentary',
|
|
displayTitle: 'English',
|
|
codec: 'ac3',
|
|
channels: 6,
|
|
selected: false,
|
|
);
|
|
final track = PlaybackSubtitleResolver.audioTrackForSource(row);
|
|
expect(track.id, 'source:7');
|
|
expect(track.title, 'Commentary');
|
|
expect(track.language, 'eng');
|
|
expect(track.channels, 6);
|
|
});
|
|
|
|
test('selected embedded subtitle keeps sidecars out of the open', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true)]),
|
|
sidecars: const [],
|
|
);
|
|
|
|
expect(result.isOff, isFalse);
|
|
expect(result.primarySourceStreamId, 2);
|
|
expect(result.primarySidecar, isNull);
|
|
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),
|
|
_sourceSubtitle(3, language: 'swe', usesExternalDelivery: true),
|
|
]);
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: mediaInfo,
|
|
sidecars: [
|
|
_sidecar(2),
|
|
_sidecar(3, language: 'swe'),
|
|
],
|
|
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(
|
|
PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
|
|
),
|
|
);
|
|
|
|
expect(result.primarySourceStreamId, 2);
|
|
expect(result.secondarySourceStreamId, 3);
|
|
expect(result.sidecarsAtOpen, hasLength(2));
|
|
});
|
|
|
|
test('legacy sidecar without source metadata still follows default selection', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: null,
|
|
sidecars: [
|
|
PlaybackSubtitleSidecar(
|
|
sourceStreamId: null,
|
|
track: SubtitleTrack.uri('file:///tmp/subtitle.srt', title: 'Local', isDefault: true),
|
|
),
|
|
],
|
|
);
|
|
|
|
expect(result.isOff, isFalse);
|
|
expect(result.primarySourceStreamId, isNull);
|
|
expect(result.sidecarsAtOpen.single.uri, 'file:///tmp/subtitle.srt');
|
|
});
|
|
}
|