From 79c06b82d303c704ec61b068097d7b0c3a68c6de Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 4 Jun 2026 10:35:14 +0200 Subject: [PATCH] fix(jellyfin): align default track selection --- lib/services/track_selection_service.dart | 61 ++++++++++++--- .../track_selection_service_test.dart | 76 +++++++++++++++++++ 2 files changed, 128 insertions(+), 9 deletions(-) diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index a82eed0b..ba4b040e 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -330,6 +330,8 @@ bool _titlesMatch(String? mpvTitle, String? plexTitle, String? plexDisplayTitle) return _titleScore(mpvTitle, plexTitle, plexDisplayTitle) > 0; } +int _mediaTrackStreamIndex(int id, int? index) => index ?? id; + /// Priority levels for track selection enum TrackSelectionPriority { navigation, // Priority 1: User's manual selection from previous episode @@ -624,7 +626,7 @@ class TrackSelectionService { /// Select the best audio track based on priority: /// Priority 1: Preferred track from navigation - /// Priority 2: Plex-selected track from media info + /// Priority 2: Server-selected track from media info /// Priority 3: Per-media language preference /// Priority 4: User profile preferences /// Priority 5: Default or first track @@ -644,20 +646,40 @@ class TrackSelectionService { } } - // Priority 2: Check Plex-selected track from media info - if (plexMediaInfo != null && availableTracks.isNotEmpty) { - final plexSelectedTrack = plexMediaInfo!.audioTracks.where((t) => t.selected).firstOrNull; + // Priority 2: Check server-selected track from media info + final info = plexMediaInfo; + if (info != null && availableTracks.isNotEmpty) { + final serverSelectedTrack = info.audioTracks.where((t) => t.selected).firstOrNull; - if (plexSelectedTrack != null) { + if (serverSelectedTrack != null) { final matchedMpvTrack = findMpvTrackForPlexAudio( - plexSelectedTrack, + serverSelectedTrack, availableTracks, - allPlexTracks: plexMediaInfo!.audioTracks, + allPlexTracks: info.audioTracks, ); if (matchedMpvTrack != null) { return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected); } + } else if (metadata.backend == MediaBackend.jellyfin) { + final defaultStreamIndex = info.defaultAudioStreamIndex; + final defaultTrack = defaultStreamIndex != null + ? info.audioTracks + .where((track) => _mediaTrackStreamIndex(track.id, track.index) == defaultStreamIndex) + .firstOrNull + : null; + + if (defaultTrack != null) { + final matchedMpvTrack = findMpvTrackForPlexAudio( + defaultTrack, + availableTracks, + allPlexTracks: info.audioTracks, + ); + + if (matchedMpvTrack != null) { + return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected); + } + } } } @@ -726,8 +748,29 @@ class TrackSelectionService { if (matchedMpvTrack != null) { return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected); } - } else if (metadata.backend == MediaBackend.jellyfin && info.defaultSubtitleStreamIndex == -1) { - return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.serverSelected); + } else if (metadata.backend == MediaBackend.jellyfin) { + final defaultStreamIndex = info.defaultSubtitleStreamIndex; + if (defaultStreamIndex == -1) { + return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.serverSelected); + } + + final defaultTrack = defaultStreamIndex != null && availableTracks.isNotEmpty + ? info.subtitleTracks + .where((track) => _mediaTrackStreamIndex(track.id, track.index) == defaultStreamIndex) + .firstOrNull + : null; + + if (defaultTrack != null) { + final matchedMpvTrack = findMpvTrackForPlexSubtitle( + defaultTrack, + availableTracks, + allPlexTracks: info.subtitleTracks, + ); + + if (matchedMpvTrack != null) { + return TrackSelectionResult(matchedMpvTrack, TrackSelectionPriority.serverSelected); + } + } } else if (metadata.backend == MediaBackend.plex && info.subtitleTracks.isNotEmpty) { // Server has subtitle tracks but none selected — trust that decision return TrackSelectionResult(SubtitleTrack.off, TrackSelectionPriority.serverSelected); diff --git a/test/services/track_selection_service_test.dart b/test/services/track_selection_service_test.dart index ab644030..e9577e18 100644 --- a/test/services/track_selection_service_test.dart +++ b/test/services/track_selection_service_test.dart @@ -102,6 +102,7 @@ SubtitleTrack _sub( MediaAudioTrack _plexAudio( int id, { + int? index, String? language, String? languageCode, String? title, @@ -111,6 +112,7 @@ MediaAudioTrack _plexAudio( }) { return MediaAudioTrack( id: id, + index: index, language: language, languageCode: languageCode ?? language, title: title, @@ -122,6 +124,7 @@ MediaAudioTrack _plexAudio( MediaSubtitleTrack _plexSub( int id, { + int? index, String? language, String? languageCode, String? title, @@ -131,6 +134,7 @@ MediaSubtitleTrack _plexSub( }) { return MediaSubtitleTrack( id: id, + index: index, language: language, languageCode: languageCode ?? language, title: title, @@ -143,12 +147,14 @@ MediaSubtitleTrack _plexSub( MediaSourceInfo _info({ List? audio, List? subs, + int? defaultAudioStreamIndex, int? defaultSubtitleStreamIndex, }) => MediaSourceInfo( videoUrl: '', audioTracks: audio ?? const [], subtitleTracks: subs ?? const [], chapters: const [], + defaultAudioStreamIndex: defaultAudioStreamIndex, defaultSubtitleStreamIndex: defaultSubtitleStreamIndex, ); @@ -345,6 +351,42 @@ void main() { expect(result.track.language, 'fre'); }); + test('Jellyfin selected audio stream wins over DefaultAudioStreamIndex', () { + final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')]; + final info = _info( + defaultAudioStreamIndex: 1, + audio: [ + _plexAudio(1, index: 1, language: 'eng', languageCode: 'eng'), + _plexAudio(2, index: 2, language: 'fre', languageCode: 'fre', selected: true), + ], + ); + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + info: info, + ).selectAudioTrack(tracks, null); + expect(result, isNotNull); + expect(result!.priority, TrackSelectionPriority.serverSelected); + expect(result.track.language, 'fre'); + }); + + test('Jellyfin DefaultAudioStreamIndex selects audio when selected flag is missing', () { + final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')]; + final info = _info( + defaultAudioStreamIndex: 2, + audio: [ + _plexAudio(1, index: 1, language: 'eng', languageCode: 'eng'), + _plexAudio(2, index: 2, language: 'fre', languageCode: 'fre'), + ], + ); + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + info: info, + ).selectAudioTrack(tracks, null); + expect(result, isNotNull); + expect(result!.priority, TrackSelectionPriority.serverSelected); + expect(result.track.language, 'fre'); + }); + test('Priority 3: per-media audioLanguage from metadata', () { final tracks = [_audio('A', lang: 'eng'), _audio('B', lang: 'fre')]; final result = _svc(metadata: _meta(audioLanguage: 'fre')).selectAudioTrack(tracks, null); @@ -420,6 +462,23 @@ void main() { expect(result.track.language, 'fre'); }); + test('Jellyfin selected subtitle stream wins over DefaultSubtitleStreamIndex', () { + final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')]; + final info = _info( + defaultSubtitleStreamIndex: 10, + subs: [ + _plexSub(10, index: 10, language: 'eng', languageCode: 'eng'), + _plexSub(11, index: 11, language: 'fre', languageCode: 'fre', selected: true), + ], + ); + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + info: info, + ).selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.serverSelected); + expect(result.track.language, 'fre'); + }); + test('Priority 2: Plex media info has subs but none selected → off', () { // Server's explicit decision: there ARE subs but the user opted out. final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')]; @@ -450,6 +509,23 @@ void main() { expect(result.track.id, '2'); }); + test('Jellyfin DefaultSubtitleStreamIndex selects subtitle when selected flag is missing', () { + final tracks = [_sub('1', lang: 'eng'), _sub('2', lang: 'fre')]; + final info = _info( + defaultSubtitleStreamIndex: 11, + subs: [ + _plexSub(10, index: 10, language: 'eng', languageCode: 'eng'), + _plexSub(11, index: 11, language: 'fre', languageCode: 'fre'), + ], + ); + final result = _svc( + metadata: _meta(backend: MediaBackend.jellyfin), + info: info, + ).selectSubtitleTrack(tracks, null, null); + expect(result.priority, TrackSelectionPriority.serverSelected); + expect(result.track.language, 'fre'); + }); + test('Jellyfin explicit DefaultSubtitleStreamIndex=-1 forces subtitles off', () { final tracks = [_sub('1', lang: 'eng', isDefault: true), _sub('2', lang: 'fre')]; final info = _info(