Two regressions since 2.9.1 broke subtitles on transcoded playback. Sincea1b6a8971sidecars load with the media behind a 10s open guard, so a subtitle URL the server is slow to serve — Jellyfin extracting an embedded stream while its transcoder spins up — tripped the guard: stop, reopen without subtitles, "Selected subtitles could not be loaded" snackbar, and an emptied subtitle menu. Since2b3853a88every embedded Plex subtitle was handed to the player as a sidecar whose URL is the original container, so a transcode also range-read and demuxed the source over HTTP — for a 40 GB remux, purely to find a subtitle track — which is also why PGS never appeared: the client was handed a container to demux rather than a rendition to play. Delivery is the server's job again, backported from the AVPlayer branch (42ba01440, the subtitle subset of 6852ac274, and a3da81e83) and adapted to main's mpv backend: Plex burns every embedded track (subtitles=burn); only a real external file with a /library/streams key stays a client-fetched sidecar. A burn is a re-encode, so directPlay is withdrawn — a real PMS answers HTTP 400 to directPlay=1 with burn — and the burn is aimed by selecting the stream on the part first via the selectStreams PUT, because the decision endpoint ignores subtitleStreamID alongside subtitles=burn. An unaimable or undeliverable burn (dvb_teletext) refuses the transcode and falls back to warned direct play rather than welding the wrong language in or silently dropping the caption. Main's per-preset directPlay/directStream pinning is kept; verified against a live PMS that burn works under directStream=0. Jellyfin never offers image formats as External, so bitmaps fall through to Encode and are burned; text External is withheld per request when the effective selection — including the server's DefaultSubtitleStreamIndex — is embedded, and offered when it is a real file, so a file is delivered as a file and never fetched twice. The burned row is excluded from the sidecars; remaining text rows stay extractable, which is how a secondary track still renders over a transcode. Sidecar URLs now use the format extension the endpoint expects instead of the reported codec name. The controls and selection layers learn what burning means: burn eligibility is the codec's property, so burned rows stay selectable in the menu; any change away from a burned selection renegotiates with the server instead of pretending a local switch worked; the visibility shortcut explains itself instead of doing nothing; and the track manager is told when the primary is server-rendered so it stops waiting out a thirty-second deadline for a native track that is already pixels. Verified: analyzer parity, clean_translations --check --strict, full flutter test (5749), and decision-level runs against live Plex and Jellyfin servers — text and PGS burn decisions, the directPlay=1+burn 400, External file delivery, an unchanged no-burn baseline, and a real burn session serving its playlist. The pre-commit aggregate was bypassed for pre-existing main-state findings outside this diff: 21 format-drifted files and three unused test seams in lib/main.dart. close #1738 Refs #1815, #1622.
857 lines
31 KiB
Dart
857 lines
31 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('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
|
|
// deadline and then read as active while nothing was on screen.
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true), _sourceSubtitle(3, language: 'swe')]),
|
|
sidecars: const [],
|
|
preferredSecondarySubtitleTrack: SubtitlePreference.track(const SubtitleTrack(id: 'source:3', language: 'swe')),
|
|
isTranscoding: true,
|
|
);
|
|
|
|
expect(result.secondaryTrack, isNull, reason: 'nothing can carry it, so it must not stay selected');
|
|
expect(result.secondarySourceStreamId, isNull);
|
|
});
|
|
|
|
test('a direct play keeps a carried secondary the player can select natively', () {
|
|
final result = PlaybackSubtitleResolver.resolve(
|
|
metadata: metadata,
|
|
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true), _sourceSubtitle(3, language: 'swe')]),
|
|
sidecars: const [],
|
|
preferredSecondarySubtitleTrack: SubtitlePreference.track(const SubtitleTrack(id: 'source:3', language: 'swe')),
|
|
);
|
|
|
|
expect(result.secondarySourceStreamId, 3, reason: 'the container still carries it');
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
group('burned-in subtitles force a renegotiation', () {
|
|
bool needsServer({
|
|
bool isTranscoding = true,
|
|
int? currentSourceStreamId,
|
|
bool currentSelectionHasSidecar = false,
|
|
bool targetIsOff = false,
|
|
bool targetIsExternalFile = false,
|
|
}) => PlaybackSubtitleResolver.burnRequiresRenegotiation(
|
|
isTranscoding: isTranscoding,
|
|
currentSourceStreamId: currentSourceStreamId,
|
|
currentSelectionHasSidecar: currentSelectionHasSidecar,
|
|
targetIsOff: targetIsOff,
|
|
targetIsExternalFile: targetIsExternalFile,
|
|
);
|
|
|
|
// Burned pixels are not a track: off leaves them on screen, anything else is drawn over them.
|
|
// So from a burned selection the target is irrelevant - every change goes back to the server.
|
|
test('every change from a burned selection goes back to the server', () {
|
|
expect(needsServer(currentSourceStreamId: 3, targetIsOff: true), isTrue);
|
|
expect(needsServer(currentSourceStreamId: 3, targetIsExternalFile: true), isTrue);
|
|
expect(needsServer(currentSourceStreamId: 3), isTrue, reason: 'another embedded track');
|
|
});
|
|
|
|
// The other direction: nothing is burned yet, but the target can only arrive burned.
|
|
test('an embedded target has to be negotiated even with nothing burned', () {
|
|
expect(
|
|
needsServer(currentSourceStreamId: null),
|
|
isTrue,
|
|
reason: 'applying it locally attaches nothing and reports success over an unchanged picture',
|
|
);
|
|
expect(
|
|
needsServer(currentSourceStreamId: 3, currentSelectionHasSidecar: true),
|
|
isTrue,
|
|
reason: 'a sidecar-backed current selection does not make an embedded target local',
|
|
);
|
|
});
|
|
|
|
test('what the client already holds stays local', () {
|
|
expect(needsServer(currentSourceStreamId: null, targetIsOff: true), isFalse);
|
|
expect(needsServer(currentSourceStreamId: null, targetIsExternalFile: true), isFalse);
|
|
expect(
|
|
needsServer(currentSourceStreamId: 3, currentSelectionHasSidecar: true, targetIsOff: true),
|
|
isFalse,
|
|
reason: 'a sidecar was delivered as a file, so turning it off is a real local change',
|
|
);
|
|
});
|
|
|
|
test('a direct play never burns, whatever is selected', () {
|
|
expect(needsServer(isTranscoding: false, currentSourceStreamId: 3), isFalse);
|
|
expect(needsServer(isTranscoding: false), isFalse);
|
|
});
|
|
});
|
|
|
|
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');
|
|
});
|
|
}
|