feat(player): keep the session's explicit track choices across episodes

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
This commit is contained in:
edde746
2026-08-04 16:10:02 +02:00
parent 61ae314c94
commit 4872adcde3
13 changed files with 721 additions and 112 deletions
@@ -60,6 +60,7 @@ void main() {
hasCommittedSelection: true,
committedTrack: committed,
nativeTrack: SubtitleTrack.off,
sessionPreference: null,
);
expect(result, isA<SubtitleIntentPreference>());
@@ -71,6 +72,45 @@ void main() {
expect(intent.isExternal, isTrue);
});
test('session subtitle intent wins over the committed outcome at an item boundary', () {
const sessionPreference = SubtitlePreference.intent(
SubtitleIntent(language: 'swe', forced: false, title: 'Swedish', codec: 'srt'),
);
final result = subtitlePreferenceForItemChange(
hasCommittedSelection: true,
committedTrack: const SubtitleTrack(id: 'source:4', language: 'eng', title: 'English', codec: 'srt'),
nativeTrack: const SubtitleTrack(id: '2', language: 'eng', title: 'English', codec: 'srt'),
sessionPreference: sessionPreference,
);
expect(result, sessionPreference);
});
test('session subtitle off stays off at an item boundary', () {
expect(
subtitlePreferenceForItemChange(
hasCommittedSelection: true,
committedTrack: const SubtitleTrack(id: 'source:4', language: 'eng'),
nativeTrack: const SubtitleTrack(id: '2', language: 'eng'),
sessionPreference: const SubtitlePreference.off(),
),
const SubtitlePreference.off(),
);
});
test('semantics-free session subtitle preference falls back to the committed flow', () {
final result = subtitlePreferenceForItemChange(
hasCommittedSelection: true,
committedTrack: const SubtitleTrack(id: 'source:4', language: 'eng', title: 'English', codec: 'srt'),
nativeTrack: SubtitleTrack.off,
sessionPreference: const SubtitlePreference.track(SubtitleTrack(id: 'source:9')),
);
expect(result, isA<SubtitleIntentPreference>());
expect((result! as SubtitleIntentPreference).intent.language, 'eng');
});
test('item-change subtitle preference derives forced-ness from a forced title (#1716)', () {
const committed = SubtitleTrack(id: 'source:4', title: 'FR Forced [ASS]', language: 'fra', codec: 'ass');
@@ -233,6 +273,55 @@ void main() {
expect(selection.secondarySourceStreamId, isNull);
});
test('a reload-path source subtitle pick becomes the session preference (#1785)', () {
// Picks that cannot switch locally go through a full reload and never
// reach the native remember chain; the authoritative source row still
// has to become the session preference — including its discriminating
// title — or a later fallback episode erases the choice.
final rows = [
MediaSubtitleTrack(
id: 3,
languageCode: 'eng',
title: 'Full Subtitles',
displayTitle: 'English',
codec: 'ass',
selected: true,
forced: false,
),
MediaSubtitleTrack(
id: 4,
languageCode: 'eng',
title: 'Signs & Songs',
displayTitle: 'English',
codec: 'ass',
selected: false,
forced: false,
),
];
final captured = sessionPreferenceForSourceSubtitleChoice(const PlaybackSourceSubtitleChoice.source(4), rows);
expect(captured, isA<SubtitleTrackPreference>());
expect((captured! as SubtitleTrackPreference).track.title, 'Signs & Songs');
// The captured preference crosses the next episode boundary as its
// intent, keeping the signs/dialogue distinction.
final carried = SubtitlePreference.demoteToIntent(captured);
expect(carried, isA<SubtitleIntentPreference>());
expect((carried! as SubtitleIntentPreference).intent.title, 'Signs & Songs');
expect((carried as SubtitleIntentPreference).intent.language, 'eng');
});
test('a reload-path off choice and a stale row id capture correctly', () {
expect(
sessionPreferenceForSourceSubtitleChoice(const PlaybackSourceSubtitleChoice.off(), const []),
const SubtitlePreference.off(),
);
// A row the catalog no longer carries must not overwrite the session
// preference with a fabricated pick.
final rows = [MediaSubtitleTrack(id: 3, languageCode: 'eng', codec: 'ass', selected: false, forced: false)];
expect(sessionPreferenceForSourceSubtitleChoice(const PlaybackSourceSubtitleChoice.source(99), rows), isNull);
});
test('a secondary-only change keeps the primary declined carry alive (#1785)', () {
const declined = SubtitlePreference.intent(
SubtitleIntent(language: 'swe', forced: false, title: 'Swedish', codec: 'srt'),
@@ -64,6 +64,56 @@ JellyfinClient _clientWithPlaybackInfo(
);
}
Future<({PlaybackInitializationResult result, Uri playbackInfoUri, Map<String, dynamic> playbackInfoBody})>
_initializeJellyfinAudioCarry({int? selectedAudioStreamId, AudioTrack? preferredAudioTrack}) async {
late Uri playbackInfoUri;
late String playbackInfoBody;
final client = _clientWithPlaybackInfo(
(request) async {
playbackInfoUri = request.url;
playbackInfoBody = request.body;
return jsonResponse({
'MediaSources': [
{'Id': 'src-1'},
],
});
},
itemSources: [
{
'Id': 'src-1',
'Container': 'mkv',
'MediaStreams': [
{'Index': 0, 'Type': 'Video'},
{'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true},
{'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'Title': 'Main'},
],
},
],
);
try {
final result = await client.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: testMediaItem(
id: 'item-1',
backend: MediaBackend.jellyfin,
kind: MediaKind.episode,
serverId: 'srv-1',
),
selectedMediaIndex: 0,
selectedAudioStreamId: selectedAudioStreamId,
preferredAudioTrack: preferredAudioTrack,
),
);
return (
result: result,
playbackInfoUri: playbackInfoUri,
playbackInfoBody: jsonDecode(playbackInfoBody) as Map<String, dynamic>,
);
} finally {
client.close();
}
}
/// Serves [routes] as JSON keyed by request path and records the last URL seen
/// for each path; every other path answers 404.
({JellyfinClient client, Map<String, Uri> requests}) _routedClient(Map<String, Object> routes) {
@@ -1342,6 +1392,38 @@ void main() {
expect(uri.queryParameters['Container'], 'mkv');
});
test('semantic carried audio resolves against the selected source before PlaybackInfo negotiation', () async {
final initialized = await _initializeJellyfinAudioCarry(
preferredAudioTrack: const AudioTrack(id: 'source:99', language: 'jpn', title: 'Main', codec: 'flac'),
);
expect(initialized.playbackInfoUri.queryParameters['AudioStreamIndex'], '4');
expect(initialized.playbackInfoBody['AudioStreamIndex'], 4);
expect(initialized.result.activeAudioStreamId, 4);
expect(initialized.result.mediaInfo!.audioTracks.singleWhere((track) => track.id == 4).selected, isTrue);
});
test('explicit Jellyfin audio stream wins over a conflicting semantic carry', () async {
final initialized = await _initializeJellyfinAudioCarry(
selectedAudioStreamId: 1,
preferredAudioTrack: const AudioTrack(id: 'source:99', language: 'jpn', title: 'Main', codec: 'flac'),
);
expect(initialized.playbackInfoUri.queryParameters['AudioStreamIndex'], '1');
expect(initialized.playbackInfoBody['AudioStreamIndex'], 1);
expect(initialized.result.activeAudioStreamId, 1);
});
test('unresolvable semantic audio carry lets Jellyfin choose the stream', () async {
final initialized = await _initializeJellyfinAudioCarry(
preferredAudioTrack: const AudioTrack(id: 'source:99', language: 'swe'),
);
expect(initialized.playbackInfoUri.queryParameters.containsKey('AudioStreamIndex'), isFalse);
expect(initialized.playbackInfoBody.containsKey('AudioStreamIndex'), isFalse);
expect(initialized.result.activeAudioStreamId, isNull);
});
test('stale selected audio stream is not sent for a source without that stream', () async {
Uri? playbackInfoUri;
String? playbackInfoBody;
@@ -692,6 +692,25 @@ void main() {
});
});
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,
@@ -10,6 +10,7 @@ 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';
@@ -34,6 +35,91 @@ void main() {
PlexClient makeClient(Future<http.Response> Function(http.Request request) handler) =>
testPlexClient(serverId: ServerId('server-id'), handler: handler);
Future<({PlaybackInitializationResult result, Uri decisionUri})> initializeTranscodeAudio({
int? selectedAudioStreamId,
AudioTrack? preferredAudioTrack,
}) async {
late Uri decisionUri;
final client = makeClient((request) async {
if (request.url.path == '/library/metadata/42') {
return http.Response(
jsonEncode({
'MediaContainer': {
'Metadata': [
{
'ratingKey': '42',
'type': 'episode',
'title': 'Episode',
'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,
'codec': 'aac',
'languageCode': 'eng',
'title': 'Original',
'selected': true,
},
{
'streamType': 2,
'id': 305,
'index': 1,
'codec': 'flac',
'languageCode': 'jpn',
'title': 'Main',
},
],
},
],
},
],
},
],
},
}),
200,
headers: {'content-type': 'application/json'},
);
}
if (request.url.path == '/video/:/transcode/universal/decision') {
decisionUri = request.url;
return http.Response(
jsonEncode({
'MediaContainer': {'generalDecisionCode': 1001, 'transcodeDecisionCode': 1001},
}),
200,
headers: {'content-type': 'application/json'},
);
}
return http.Response('unexpected request', 500);
});
try {
final result = await client.getPlaybackInitialization(
PlaybackInitializationOptions(
metadata: testMediaItem(id: '42', backend: MediaBackend.plex, kind: MediaKind.episode, serverId: 'server-id'),
selectedMediaIndex: 0,
selectedAudioStreamId: selectedAudioStreamId,
preferredAudioTrack: preferredAudioTrack,
qualityPreset: TranscodeQualityPreset.p720_3mbps,
sessionIdentifier: 'session-id',
transcodeSessionId: 'transcode-id',
),
);
return (result: result, decisionUri: decisionUri);
} finally {
client.close();
}
}
MediaSourceInfo mediaInfoWithSubtitles(List<MediaSubtitleTrack> subtitleTracks) {
return MediaSourceInfo(
videoUrl: 'https://plex.example.com/video.mkv',
@@ -68,6 +154,34 @@ void main() {
expect(requests.single.url.queryParameters['allParts'], '1');
});
test('semantic carried audio is sent to the Plex transcode decision', () async {
final initialized = await initializeTranscodeAudio(
preferredAudioTrack: const AudioTrack(id: 'source:999', language: 'jpn', title: 'Main', codec: 'flac'),
);
expect(initialized.decisionUri.queryParameters['audioStreamID'], '305');
expect(initialized.result.activeAudioStreamId, 305);
});
test('explicit Plex audio stream wins over a conflicting semantic carry', () async {
final initialized = await initializeTranscodeAudio(
selectedAudioStreamId: 301,
preferredAudioTrack: const AudioTrack(id: 'source:999', language: 'jpn', title: 'Main', codec: 'flac'),
);
expect(initialized.decisionUri.queryParameters['audioStreamID'], '301');
expect(initialized.result.activeAudioStreamId, 301);
});
test('unresolvable semantic audio carry lets the Plex transcoder choose the stream', () async {
final initialized = await initializeTranscodeAudio(
preferredAudioTrack: const AudioTrack(id: 'source:999', language: 'swe'),
);
expect(initialized.decisionUri.queryParameters.containsKey('audioStreamID'), isFalse);
expect(initialized.result.activeAudioStreamId, isNull);
});
test('playback metadata request includes streams for transcode sidecar subtitles', () async {
final requests = <Uri>[];
final client = makeClient((request) async {
+152 -44
View File
@@ -16,7 +16,7 @@ import '../test_helpers/media_items.dart';
// integration point (`selectAndApplyTracks`). We cover:
//
// - `languageMatches` — direct, base-code, and ISO 639 variation matching.
// - `findBestTrackMatch` / `findBestAudioMatch` / `findBestSubtitleMatch` —
// - `findBestTrackMatch` / `findBestSubtitleMatch` —
// id+title+language exact, title+language, language-only, and the
// "auto"/"no" filtering rule.
// - `findAudioTrackByProfile` — picks the first preferred-language match,
@@ -232,49 +232,6 @@ void main() {
// findBestTrackMatch (via the audio/subtitle wrappers)
// ============================================================
group('findBestAudioMatch', () {
final svc = _svc();
test('exact id + title + language match wins', () {
final tracks = [_audio('1', lang: 'eng', title: 'Stereo'), _audio('2', lang: 'eng', title: 'Surround')];
final preferred = _audio('2', lang: 'eng', title: 'Surround');
expect(svc.findBestAudioMatch(tracks, preferred), tracks[1]);
});
test('falls back to title + language when id differs', () {
final tracks = [_audio('1', lang: 'eng', title: 'Stereo'), _audio('2', lang: 'eng', title: 'Surround')];
// Different id but matching title+language → tracks[1].
final preferred = _audio('999', lang: 'eng', title: 'Surround');
expect(svc.findBestAudioMatch(tracks, preferred), tracks[1]);
});
test('falls back to language-only match', () {
final tracks = [_audio('1', lang: 'eng', title: 'Stereo')];
final preferred = _audio('999', lang: 'eng', title: 'Different');
expect(svc.findBestAudioMatch(tracks, preferred), tracks[0]);
});
test('returns null when no language match exists', () {
final tracks = [_audio('1', lang: 'fre')];
final preferred = _audio('1', lang: 'eng');
expect(svc.findBestAudioMatch(tracks, preferred), isNull);
});
test('filters out auto and no tracks before matching', () {
final tracks = [AudioTrack.auto, AudioTrack.off, _audio('3', lang: 'eng')];
final preferred = _audio('3', lang: 'eng');
expect(svc.findBestAudioMatch(tracks, preferred), tracks[2]);
});
test('returns null on an empty list', () {
expect(svc.findBestAudioMatch(const [], _audio('1', lang: 'eng')), isNull);
});
test('returns null when only auto/no tracks remain after filtering', () {
expect(svc.findBestAudioMatch([AudioTrack.auto, AudioTrack.off], _audio('1', lang: 'eng')), isNull);
});
});
group('findBestSubtitleMatch', () {
final svc = _svc();
@@ -352,6 +309,64 @@ void main() {
expect(result.track, tracks[1]);
});
test('Priority 1: a cross-item semantic carry matches through the evidence bands', () {
// The carried track's id belongs to the previous episode; language and
// title must still find the equivalent native track here.
final tracks = [_audio('1', lang: 'eng', title: 'Main'), _audio('2', lang: 'eng', title: 'Commentary')];
final carried = _audio('source:99', lang: 'eng', title: 'Commentary');
final result = _svc().selectAudioTrack(tracks, carried);
expect(result!.priority, TrackSelectionPriority.navigation);
expect(result.track, tracks[1]);
});
test('Priority 1: a declined audio carry falls to the server-selected track', () {
final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')];
final info = _info(
audio: [
_plexAudio(1, language: 'eng', languageCode: 'eng', selected: true),
_plexAudio(2, language: 'fre', languageCode: 'fre'),
],
);
// Swedish is gone on this episode: the carry declines instead of
// latching onto an arbitrary row, and the server's pick plays.
final result = _svc(info: info).selectAudioTrack(tracks, _audio('source:9', lang: 'swe'));
expect(result!.priority, TrackSelectionPriority.serverSelected);
expect(result.track.language, 'eng');
});
test('a demoted cross-item carry cannot latch a reused native id', () {
// Native ids are per-item ordinals: the previous episode's id '2' names
// a DIFFERENT track here. The boundary demotes the carry to semantics
// only; an indistinguishable same-language pair then declines to the
// server's choice instead of silently keeping the old ordinal.
final tracks = [
_audio('1', lang: 'eng', codec: 'aac', channels: 2),
_audio('2', lang: 'eng', codec: 'aac', channels: 2),
];
final info = _info(
audio: [
_plexAudio(1, language: 'eng', languageCode: 'eng', selected: true),
_plexAudio(2, language: 'eng', languageCode: 'eng'),
],
);
final carriedRaw = _audio('2', lang: 'eng', codec: 'aac', channels: 2);
final carried = itemAgnosticAudioCarry(carriedRaw);
// Demotion swaps only the identity; the semantics stay intact.
expect(carried.id, carriedAudioTrackId);
expect(carried.language, 'eng');
expect(carried.channels, 2);
final result = _svc(info: info).selectAudioTrack(tracks, carried);
expect(result!.priority, TrackSelectionPriority.serverSelected);
// Control: the raw (un-demoted) carry would have identity-latched the
// reused id — the exact bypass the boundary demotion exists to prevent.
final latched = _svc(info: info).selectAudioTrack(tracks, carriedRaw);
expect(latched!.priority, TrackSelectionPriority.navigation);
expect(latched.track.id, '2');
});
test('Priority 2: Plex-selected track from media info', () {
final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')];
final info = _info(
@@ -1205,6 +1220,30 @@ void main() {
];
expect(findSourceTrackForIntent(intent, rows)?.id, 2);
});
test('the semantic title outranks a retained codec across a codec flip', () {
// The signs track was re-encoded srt on this episode while the full
// dialogue track kept the old codec: the row NAMED by the carried
// title must win — technical parity only breaks ties the semantic
// tiers left.
const intent = SubtitleIntent(language: 'eng', forced: false, title: 'Signs/OP/ED', codec: 'ass');
final rows = [
_plexSub(1, languageCode: 'eng', title: 'Full Subtitles', codec: 'ass'),
_plexSub(2, languageCode: 'eng', title: 'Signs/OP/ED', codec: 'subrip'),
];
expect(findSourceTrackForIntent(intent, rows)?.id, 2);
});
test('an indistinguishable same-language pair declines rather than latching by order', () {
// A titleless carry cannot tell two equal same-language rows apart:
// guessing the first row would recreate the #1717 order latch.
const intent = SubtitleIntent(language: 'eng', forced: false, codec: 'subrip');
final rows = [
_plexSub(1, languageCode: 'eng', codec: 'subrip'),
_plexSub(2, languageCode: 'eng', codec: 'subrip'),
];
expect(findSourceTrackForIntent(intent, rows), isNull);
});
});
group('findNativeTrackForIntent', () {
@@ -1248,6 +1287,75 @@ void main() {
});
});
group('audio carry evidence bands', () {
test('bridges two- and three-letter language codes across episodes', () {
// The old carry compared languages with raw equality, so a 'sv' pick
// never found a 'swe'-tagged row on the next episode.
final rows = [_plexAudio(1, languageCode: 'eng'), _plexAudio(2, languageCode: 'swe')];
expect(findSourceAudioTrackForIntent(_audio('x', lang: 'sv'), rows)?.id, 2);
});
test('keeps the commentary/main distinction between same-language tracks', () {
// The old language-only tier returned the FIRST same-language row,
// flipping a commentary pick back to the main mix every episode.
final rows = [
_plexAudio(1, languageCode: 'eng', title: 'Main'),
_plexAudio(2, languageCode: 'eng', title: 'Commentary'),
];
expect(findSourceAudioTrackForIntent(_audio('x', lang: 'eng', title: 'Commentary'), rows)?.id, 2);
});
test('a unique title vouches for untagged tracks', () {
final rows = [_plexAudio(1, title: 'Main'), _plexAudio(2, title: 'Commentary')];
expect(findSourceAudioTrackForIntent(_audio('x', title: 'Commentary'), rows)?.id, 2);
});
test('codec parity alone never vouches for an untagged track', () {
final rows = [_plexAudio(1, codec: 'ac3')];
expect(findSourceAudioTrackForIntent(_audio('x', title: 'Commentary', codec: 'ac3'), rows), isNull);
});
test('an ambiguous same-title untagged pair declines rather than guesses', () {
final rows = [_plexAudio(1, title: 'Stereo'), _plexAudio(2, title: 'Stereo')];
expect(findSourceAudioTrackForIntent(_audio('x', title: 'Stereo'), rows), isNull);
});
test('a declared language contradiction is never rescued by a title', () {
final rows = [_plexAudio(1, languageCode: 'eng', title: 'Commentary')];
expect(findSourceAudioTrackForIntent(_audio('x', lang: 'swe', title: 'Commentary'), rows), isNull);
});
test('channel count breaks ties between otherwise equal rows', () {
final rows = [
_plexAudio(1, languageCode: 'eng', channels: 2, codec: 'aac'),
_plexAudio(2, languageCode: 'eng', channels: 6, codec: 'aac'),
];
expect(findSourceAudioTrackForIntent(_audio('x', lang: 'eng', channels: 6, codec: 'aac'), rows)?.id, 2);
});
test('the native twin skips the auto and off sentinels', () {
final tracks = [AudioTrack.auto, AudioTrack.off, _audio('3', lang: 'eng')];
expect(findNativeAudioTrackForIntent(_audio('x', lang: 'eng'), tracks)?.id, '3');
});
test('a commentary title outranks a retained codec across a codec flip', () {
final rows = [
_plexAudio(1, languageCode: 'eng', title: 'Main', codec: 'ac3'),
_plexAudio(2, languageCode: 'eng', title: 'Commentary', codec: 'aac'),
];
final carried = _audio('x', lang: 'eng', title: 'Commentary', codec: 'ac3');
expect(findSourceAudioTrackForIntent(carried, rows)?.id, 2);
});
test('an indistinguishable same-language pair declines rather than latching by order', () {
final rows = [
_plexAudio(1, languageCode: 'eng', codec: 'aac', channels: 2),
_plexAudio(2, languageCode: 'eng', codec: 'aac', channels: 2),
];
expect(findSourceAudioTrackForIntent(_audio('x', lang: 'eng', codec: 'aac'), rows), isNull);
});
});
group('selectSubtitleTrack - intent preferences (#1716/#1717)', () {
const forcedIntent = SubtitlePreference.intent(
SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced [ASS]', codec: 'ass'),