From e7aa1e47825c95d59eb76bff0015960d2811cc7f Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 3 Aug 2026 17:07:05 +0200 Subject: [PATCH] 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 --- lib/services/jellyfin_media_info_mapper.dart | 15 +++-- test/services/jellyfin_media_info_test.dart | 29 +++++++++- .../playback_subtitle_resolver_test.dart | 55 +++++++++++++++++++ 3 files changed, 93 insertions(+), 6 deletions(-) diff --git a/lib/services/jellyfin_media_info_mapper.dart b/lib/services/jellyfin_media_info_mapper.dart index 6cc981cc..803744f5 100644 --- a/lib/services/jellyfin_media_info_mapper.dart +++ b/lib/services/jellyfin_media_info_mapper.dart @@ -70,12 +70,19 @@ List _withDefaultAudioSelection(List tracks, i 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 _withDefaultSubtitleSelection(List tracks, int? defaultStreamIndex) { return [ - for (final track in tracks) - track.withSelected( - defaultStreamIndex != null ? track.index == defaultStreamIndex : track.selected || track.forced, - ), + for (final track in tracks) track.withSelected(defaultStreamIndex != null && track.index == defaultStreamIndex), ]; } diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart index 2e93b5c3..050e04d1 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -75,17 +75,42 @@ void main() { expect(jpn.languageCode, 'jpn'); 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; expect(sub.id, 3); expect(sub.codec, 'srt'); expect(sub.languageCode, 'eng'); expect(sub.forced, isTrue); - expect(sub.selected, isTrue); + expect(sub.selected, isFalse); expect(sub.isExternal, isTrue); 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', () { final info = jellyfinMediaSourceToMediaSourceInfo({ 'Id': 'src-1', diff --git a/test/services/playback_subtitle_resolver_test.dart b/test/services/playback_subtitle_resolver_test.dart index 450f2c38..ff0244a1 100644 --- a/test/services/playback_subtitle_resolver_test.dart +++ b/test/services/playback_subtitle_resolver_test.dart @@ -2,7 +2,9 @@ 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'; @@ -238,6 +240,59 @@ void main() { 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,