fix(jellyfin): stop overriding a server subtitle mode of None
Jellyfin answers PlaybackInfo with a null DefaultSubtitleStreamIndex when the user's SubtitleMode is None: the index is the server's whole answer, and null means it picked no subtitle. The mapper read null as "the server did not say" and promoted the container's default/forced flags to a server selection instead, which outranks the profile subtitle mode in the selection ladder. A viewer who had turned subtitles off for their Jellyfin user got them switched back on by every item that carried a default or forced row. Only the row the server names is selected now. A stream the viewer picks, and an explicit off, still survive per item because Plezy reports the index through playback progress and the server hands it back as that index or -1. close #1779
This commit is contained in:
@@ -70,12 +70,19 @@ List<MediaAudioTrack> _withDefaultAudioSelection(List<MediaAudioTrack> tracks, i
|
|||||||
return [for (final track in tracks) track.withSelected(track.index == defaultStreamIndex)];
|
return [for (final track in tracks) track.withSelected(track.index == defaultStreamIndex)];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Marks the row Jellyfin selected for this user, and only that row.
|
||||||
|
///
|
||||||
|
/// `DefaultSubtitleStreamIndex` is the server's whole answer: it folds in the
|
||||||
|
/// user's `SubtitleMode`, their language preference, and any per-item choice
|
||||||
|
/// Plezy persisted through playback progress reports (including `-1` for a
|
||||||
|
/// deliberate off). A null index is part of that answer — "play no subtitle" —
|
||||||
|
/// not a missing field. Synthesising a selection from the container's
|
||||||
|
/// default/forced flags overrode `SubtitleMode: None` at the server-selected
|
||||||
|
/// priority, so a user who turned subtitles off on the server had them
|
||||||
|
/// switched back on by every fresh item (#1779).
|
||||||
List<MediaSubtitleTrack> _withDefaultSubtitleSelection(List<MediaSubtitleTrack> tracks, int? defaultStreamIndex) {
|
List<MediaSubtitleTrack> _withDefaultSubtitleSelection(List<MediaSubtitleTrack> tracks, int? defaultStreamIndex) {
|
||||||
return [
|
return [
|
||||||
for (final track in tracks)
|
for (final track in tracks) track.withSelected(defaultStreamIndex != null && track.index == defaultStreamIndex),
|
||||||
track.withSelected(
|
|
||||||
defaultStreamIndex != null ? track.index == defaultStreamIndex : track.selected || track.forced,
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -75,17 +75,42 @@ void main() {
|
|||||||
expect(jpn.languageCode, 'jpn');
|
expect(jpn.languageCode, 'jpn');
|
||||||
expect(jpn.selected, isFalse);
|
expect(jpn.selected, isFalse);
|
||||||
|
|
||||||
// Subtitle, external + forced
|
// Subtitle, external + forced. Forced is metadata about the row, not a
|
||||||
|
// selection: this source carries no DefaultSubtitleStreamIndex, so
|
||||||
|
// nothing may claim the server picked it.
|
||||||
final sub = info.subtitleTracks.single;
|
final sub = info.subtitleTracks.single;
|
||||||
expect(sub.id, 3);
|
expect(sub.id, 3);
|
||||||
expect(sub.codec, 'srt');
|
expect(sub.codec, 'srt');
|
||||||
expect(sub.languageCode, 'eng');
|
expect(sub.languageCode, 'eng');
|
||||||
expect(sub.forced, isTrue);
|
expect(sub.forced, isTrue);
|
||||||
expect(sub.selected, isTrue);
|
expect(sub.selected, isFalse);
|
||||||
expect(sub.isExternal, isTrue);
|
expect(sub.isExternal, isTrue);
|
||||||
expect(sub.key, '/Videos/src-1/Subtitles/3/Stream.srt');
|
expect(sub.key, '/Videos/src-1/Subtitles/3/Stream.srt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('no DefaultSubtitleStreamIndex means no server-selected subtitle (#1779)', () {
|
||||||
|
// What Jellyfin answers for a user whose SubtitleMode is None: the
|
||||||
|
// container still flags a default/forced row, the server still declines
|
||||||
|
// to select one. Promoting those flags to a selection outranked the
|
||||||
|
// user's own "no subtitles" setting in the selection ladder.
|
||||||
|
final info = jellyfinMediaSourceToMediaSourceInfo({
|
||||||
|
'Id': 'src-1',
|
||||||
|
'DefaultSubtitleStreamIndex': null,
|
||||||
|
'MediaStreams': [
|
||||||
|
{'Index': 1, 'Type': 'Audio', 'Language': 'eng', 'IsDefault': true},
|
||||||
|
{'Index': 3, 'Type': 'Subtitle', 'Language': 'eng', 'IsDefault': true, 'IsForced': true},
|
||||||
|
{'Index': 4, 'Type': 'Subtitle', 'Language': 'eng'},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(info.defaultSubtitleStreamIndex, isNull);
|
||||||
|
expect(info.subtitleTracks.map((track) => track.selected), [false, false]);
|
||||||
|
// The row metadata itself is untouched; only the selection claim is.
|
||||||
|
expect(info.subtitleTracks.first.forced, isTrue);
|
||||||
|
// Audio keeps the container default: something always has to play.
|
||||||
|
expect(info.audioTracks.single.selected, isTrue);
|
||||||
|
});
|
||||||
|
|
||||||
test('maps display criteria from Jellyfin video stream metadata', () {
|
test('maps display criteria from Jellyfin video stream metadata', () {
|
||||||
final info = jellyfinMediaSourceToMediaSourceInfo({
|
final info = jellyfinMediaSourceToMediaSourceInfo({
|
||||||
'Id': 'src-1',
|
'Id': 'src-1',
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
import 'package:plezy/media/media_backend.dart';
|
import 'package:plezy/media/media_backend.dart';
|
||||||
import 'package:plezy/media/media_kind.dart';
|
import 'package:plezy/media/media_kind.dart';
|
||||||
import 'package:plezy/media/media_source_info.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/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_initialization_types.dart';
|
||||||
import 'package:plezy/services/playback_subtitle_resolver.dart';
|
import 'package:plezy/services/playback_subtitle_resolver.dart';
|
||||||
import 'package:plezy/services/subtitle_preference.dart';
|
import 'package:plezy/services/subtitle_preference.dart';
|
||||||
@@ -238,6 +240,59 @@ void main() {
|
|||||||
expect(result.sidecarsAtOpen, isEmpty);
|
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', () {
|
test('retains each source metadata row for a shared preloaded container', () {
|
||||||
final result = PlaybackSubtitleResolver.resolve(
|
final result = PlaybackSubtitleResolver.resolve(
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
|||||||
Reference in New Issue
Block a user