fix(player): preserve the forced-subtitle class across episode boundaries

Plex treats a subtitle stream as forced when its title says "Forced" even
with the API flag unset. Every forced comparison now uses that effective
forced-ness on both sides: the match scorer, the low-metadata hard gate,
the Jellyfin OnlyForced/Smart profile modes, and stream-index negotiation.

Carrying a track choice into the next episode no longer reuses the
same-item identity matchers. A sealed SubtitlePreference (off / track
reference / semantic intent) replaces the id-'navigation' pseudo-track
through the whole preference channel, and cross-item intents hard-require
language and forced-class parity. When the next episode has no track of
the same class, the intent declines and selection falls through to the
server's own per-episode choice instead of latching onto a full track by
position and persisting that mistake back to the server.

Intents wait for pending native tracks under the same catalog-completeness
rule as source ids, so an early decline cannot retire the selection
listener before the real track arrives.

Ref #1716
This commit is contained in:
edde746
2026-07-30 02:46:10 +02:00
parent f13f5af6e2
commit daab4f1e24
22 changed files with 921 additions and 231 deletions
@@ -5,6 +5,7 @@ import 'package:plezy/media/media_source_info.dart';
import 'package:plezy/mpv/mpv.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';
@@ -17,13 +18,14 @@ MediaSubtitleTrack _sourceSubtitle(
bool external = false,
bool usesExternalDelivery = false,
String? key,
String? title,
}) {
return MediaSubtitleTrack(
id: id,
language: language,
languageCode: language,
codec: codec,
title: 'Subtitle $id',
title: title ?? 'Subtitle $id',
selected: selected,
forced: forced,
// Mirrors JellyfinFileInfoStreamReader: the server ships a delivery URL
@@ -229,7 +231,7 @@ void main() {
metadata: metadata,
mediaInfo: _mediaInfo([_sourceSubtitle(2, selected: true, usesExternalDelivery: true)]),
sidecars: [_sidecar(2)],
preferredSubtitleTrack: SubtitleTrack.off,
preferredSubtitleTrack: const SubtitlePreference.off(),
);
expect(result.isOff, isTrue);
@@ -244,7 +246,7 @@ void main() {
_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,
preferredSubtitleTrack: const SubtitlePreference.off(),
);
expect(result.isOff, isTrue);
@@ -266,7 +268,9 @@ void main() {
_sidecar(2),
_sidecar(3, language: 'swe'),
],
preferredSubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
preferredSubtitleTrack: SubtitlePreference.trackOrNull(
PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
),
);
expect(result.primarySourceStreamId, 3);
@@ -301,7 +305,9 @@ void main() {
_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),
preferredSubtitleTrack: SubtitlePreference.trackOrNull(
PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
),
);
expect(result.primarySourceStreamId, 3);
@@ -318,13 +324,15 @@ void main() {
_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',
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,
);
@@ -337,11 +345,8 @@ void main() {
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',
preferredSubtitleTrack: const SubtitlePreference.track(
SubtitleTrack(id: 'source:3', title: 'French from the previous source', language: 'fra', codec: 'srt'),
),
preserveSourceIdentity: false,
);
@@ -360,12 +365,14 @@ void main() {
_sidecar(7),
_sidecar(9, language: 'fra'),
],
preferredSubtitleTrack: const SubtitleTrack(
id: 'navigation',
title: 'English from the previous episode',
language: 'eng',
codec: 'srt',
isExternal: true,
preferredSubtitleTrack: const SubtitlePreference.intent(
SubtitleIntent(
language: 'eng',
forced: false,
title: 'English from the previous episode',
codec: 'srt',
isExternal: true,
),
),
);
@@ -382,13 +389,14 @@ void main() {
_sourceSubtitle(8, language: 'eng', forced: true, usesExternalDelivery: true),
]),
sidecars: [_sidecar(7), _sidecar(8)],
preferredSubtitleTrack: const SubtitleTrack(
id: 'navigation',
title: 'English forced from the previous episode',
language: 'eng',
codec: 'srt',
isForced: true,
isExternal: true,
preferredSubtitleTrack: const SubtitlePreference.intent(
SubtitleIntent(
language: 'eng',
forced: true,
title: 'English forced from the previous episode',
codec: 'srt',
isExternal: true,
),
),
);
@@ -397,6 +405,82 @@ void main() {
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);
});
});
test('selected embedded subtitle keeps sidecars out of the open', () {
final result = PlaybackSubtitleResolver.resolve(
metadata: metadata,
@@ -433,7 +517,9 @@ void main() {
_sidecar(2),
_sidecar(3, language: 'swe'),
],
preferredSecondarySubtitleTrack: PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
preferredSecondarySubtitleTrack: SubtitlePreference.trackOrNull(
PlaybackSubtitleResolver.preferredTrackForSource(mediaInfo, 3),
),
);
expect(result.primarySourceStreamId, 2);