diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index 923dd35a..2845292c 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -160,6 +160,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { committedTrack: committedSubtitleSelection?.primaryTrack, nativeTrack: currentPlayer.state.track.subtitle, declinedPreference: committedSubtitleSelection?.declinedPreference, + sessionPreference: _sessionSubtitlePreference, ); final secondarySubtitlePreference = followServerSelections ? null @@ -167,6 +168,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { hasCommittedSelection: committedSubtitleSelection != null, committedTrack: committedSubtitleSelection?.secondaryTrack, nativeTrack: currentPlayer.state.track.secondarySubtitle, + sessionPreference: _sessionSecondarySubtitlePreference, ); await _reloadMediaInPlace( metadata: episodeMetadata, @@ -178,6 +180,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { // meaningless on the new item, so let preferences pick the track. useCurrentAudioStreamSelection: false, preserveCurrentTrackSelection: !followServerSelections, + preservedAudioTrack: _sessionAudioPreference, preservedSubtitleTrack: primarySubtitlePreference, preservedSecondarySubtitleTrack: secondarySubtitlePreference, reason: 'episode navigation', @@ -222,6 +225,28 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { player == currentPlayer && _ownsPlaybackTransition(transitionLease, expected: _PlaybackTransition.switchingSource); bool sourceSwitchWasSuperseded() => !mounted || player != currentPlayer || transitionLease.wasSuperseded; + void rememberSourceAudioPreference() { + final streamId = newAudioStreamId; + final mediaInfo = _currentMediaInfo; + if (streamId == null || mediaInfo == null) return; + for (final row in mediaInfo.audioTracks) { + if (row.id == streamId) { + _sessionAudioPreference = PlaybackSubtitleResolver.audioTrackForSource(row); + return; + } + } + } + + void rememberSourceSubtitlePreference() { + final choice = newSubtitleChoice; + final mediaInfo = _currentMediaInfo; + if (choice == null || mediaInfo == null) return; + // The local-switch path records through the screen's remember chain; + // this covers picks that had to reload, whose resolved outcome never + // reaches _rememberNativeSubtitleSelectionForSlot. + final preference = sessionPreferenceForSourceSubtitleChoice(choice, mediaInfo.subtitleTracks); + if (preference != null) _sessionSubtitlePreference = preference; + } // Snapshot the backend client before subtitle selection can cross an // async boundary or the profile-scoped context can disappear. @@ -276,6 +301,8 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final isAudioChange = effectiveAudioStreamId != _selectedAudioStreamId; final isSubtitleChange = newSubtitleChoice != null && newSubtitleChoice != currentSubtitleChoice; if (!isVersionChange && !isPresetChange && !isAudioChange && !isSubtitleChange) { + rememberSourceAudioPreference(); + rememberSourceSubtitlePreference(); return PlaybackSourceChangeOutcome.unchanged; } @@ -323,6 +350,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { transitionLease: transitionLease, reason: 'source switch', ); + if (outcome == _MediaReloadOutcome.opened) { + rememberSourceAudioPreference(); + rememberSourceSubtitlePreference(); + } return switch (outcome) { _MediaReloadOutcome.opened => PlaybackSourceChangeOutcome.applied, _MediaReloadOutcome.rejected => PlaybackSourceChangeOutcome.busy, @@ -562,6 +593,13 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final initializationSubtitleTrack = preservesRequestedSubtitleSource ? currentSubtitleTrack : SubtitlePreference.demoteToIntent(currentSubtitleTrack); + // Same boundary rule for audio: native and Jellyfin stream ids are + // reused per item, so a cross-source carry keeps only its semantics — + // an identity match against a reused id would latch by ordinal and + // bypass the evidence bands' ambiguity decline. + final initializationAudioTrack = preservesRequestedSubtitleSource || currentAudioTrack == null + ? currentAudioTrack + : itemAgnosticAudioCarry(currentAudioTrack); try { // Eager identity-only: the loading UI shows the new title immediately, // while the selection/source state flips with the session commit at @@ -598,6 +636,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { preferredVersionSignature: preferredVersionSignature, qualityPreset: targetQualityPreset, selectedAudioStreamId: targetAudioStreamId, + preferredAudioTrack: initializationAudioTrack, preferredSubtitleTrack: initializationSubtitleTrack, sessionIdentifier: _playbackSessionIdentifier, transcodeSessionId: _playbackTranscodeSessionId, @@ -617,7 +656,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { var subtitleSelection = await _resolveSubtitleSelectionForOpen( metadata: metadata, result: result, - preferredAudioTrack: currentAudioTrack, + preferredAudioTrack: initializationAudioTrack, preferredSubtitleTrack: currentSubtitleTrack, preferredSecondarySubtitleTrack: currentSecondarySubtitleTrack, preserveSubtitleSourceIdentity: @@ -771,7 +810,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { metadata: metadata, plexClient: plexClient, getProfileSettings: () => userProfileProvider.profileSettings, - preferredAudioTrack: currentAudioTrack, + preferredAudioTrack: initializationAudioTrack, // A declined carry stays alive for the native passes: freezing the // resolver's off verdict here would turn a metadata mismatch into a // navigation-priority off that no late track can undo (#1785). diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index f48562af..f710ce5a 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -123,6 +123,7 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { selectedMediaSourceId: _requestedMediaSourceId, qualityPreset: _selectedQualityPreset, selectedAudioStreamId: _selectedAudioStreamId, + preferredAudioTrack: _preferredAudioTrack, preferredSubtitleTrack: _preferredSubtitleTrack, sessionIdentifier: _playbackSessionIdentifier, transcodeSessionId: _playbackTranscodeSessionId, diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index e8777154..adb3605b 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -153,6 +153,10 @@ bool playerChromeStartsVisible({required bool isTv}) => !isTv; /// boundary; native state is a fallback for sessions created before /// source-backed selection was recorded. /// +/// [sessionPreference] is the screen's last explicit viewer choice and wins +/// outright: automatic outcomes never overwrite it, so a catalog-gap episode +/// cannot reset the choice for the rest of the session. +/// /// [declinedPreference] is the committed selection's unserved carry: its off /// is fallout from a metadata mismatch, not a viewer choice, so it must not /// harden into an explicit off on the next item (#1785). @@ -161,7 +165,10 @@ SubtitlePreference? subtitlePreferenceForItemChange({ required SubtitleTrack? committedTrack, required SubtitleTrack? nativeTrack, SubtitlePreference? declinedPreference, + SubtitlePreference? sessionPreference, }) { + final sessionIntent = SubtitlePreference.demoteToIntent(sessionPreference); + if (sessionIntent != null) return sessionIntent; SubtitlePreference? normalize(SubtitleTrack? track, {required bool preserveOff}) { if (track == null) return null; if (track.id == SubtitleTrack.off.id) return preserveOff ? const SubtitlePreference.off() : null; @@ -223,6 +230,26 @@ PlaybackSubtitleSelection subtitleSelectionForUserPick({ ); } +/// Session-preference form of a source-catalog subtitle choice that had to +/// go through a reload instead of a local track switch. +/// +/// The authoritative row — not the reload's outcome — becomes the session +/// preference: a failed resolution must not turn the viewer's pick into a +/// carried off. Returns null when the row is absent from [rows] so a stale +/// id never overwrites the existing session preference. +SubtitlePreference? sessionPreferenceForSourceSubtitleChoice( + PlaybackSourceSubtitleChoice choice, + List rows, +) { + if (choice.isOff) return const SubtitlePreference.off(); + for (final row in rows) { + if (row.id == choice.sourceStreamId) { + return SubtitlePreference.track(PlaybackSubtitleResolver.subtitleTrackForSource(row)); + } + } + return null; +} + /// The in-place media-source transitions a [VideoPlayerScreenState] can run. /// They are mutually exclusive by construction — entry points bail while a /// transition is in flight. @@ -425,6 +452,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin AudioTrack? _preferredAudioTrack; SubtitlePreference? _preferredSubtitleTrack; SubtitlePreference? _preferredSecondarySubtitleTrack; + + /// Last explicit track choices made on this screen. They survive in-place + /// reloads but not route replacement. Automatic selections never overwrite + /// them, so an episode with a catalog gap cannot reset the viewer's choice + /// for the rest of the session (#1785). + AudioTrack? _sessionAudioPreference; + SubtitlePreference? _sessionSubtitlePreference; + SubtitlePreference? _sessionSecondarySubtitlePreference; bool _serverSupportsTranscoding = false; // Kicked off early in the player initialization attempt for online non-live playback so // the metadata fetch (and transcode-decision HTTP, if non-original preset) @@ -1148,6 +1183,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin preferredVersionSignature: widget.preferredVersionSignature, qualityPreset: _selectedQualityPreset, selectedAudioStreamId: _selectedAudioStreamId, + preferredAudioTrack: _preferredAudioTrack, preferredSubtitleTrack: _preferredSubtitleTrack, sessionIdentifier: _playbackSessionIdentifier, transcodeSessionId: _playbackTranscodeSessionId, @@ -1894,7 +1930,12 @@ class VideoPlayerScreenState extends State with WidgetsBindin } } - Future _onAudioTrackChanged(AudioTrack track) async => _trackManager?.onAudioTrackSelectedByUser(track); + Future _onAudioTrackChanged(AudioTrack track) async { + if (track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id) { + _sessionAudioPreference = track; + } + await _trackManager?.onAudioTrackSelectedByUser(track); + } Future _onSubtitleTrackChanged(SubtitleTrack track, {int? sourceStreamId}) async { _rememberNativeSubtitleSelection(track, sourceStreamId: sourceStreamId); @@ -1919,10 +1960,16 @@ class VideoPlayerScreenState extends State with WidgetsBindin required _SubtitleSelectionSlot slot, int? sourceStreamId, }) { - final session = _playbackSession; - if (session == null) return; - final currentSelection = session.subtitleSelection; if (track.id == SubtitleTrack.off.id) { + switch (slot) { + case _SubtitleSelectionSlot.primary: + _sessionSubtitlePreference = const SubtitlePreference.off(); + case _SubtitleSelectionSlot.secondary: + _sessionSecondarySubtitlePreference = const SubtitlePreference.off(); + } + final session = _playbackSession; + if (session == null) return; + final currentSelection = session.subtitleSelection; _updatePlaybackSessionSubtitleSelection(session, switch (slot) { _SubtitleSelectionSlot.primary => const PlaybackSubtitleSelection.off(), // Dropping only the secondary must not relabel the primary's @@ -1938,6 +1985,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin return; } + final session = _playbackSession; + if (session == null) return; + final currentSelection = session.subtitleSelection; + final info = _currentMediaInfo; final currentPlayer = player; @@ -1989,6 +2040,18 @@ class VideoPlayerScreenState extends State with WidgetsBindin sourceTrack: sourceTrack, sidecar: sidecar, ); + final resolvedTrack = switch (slot) { + _SubtitleSelectionSlot.primary => selection.primaryTrack, + _SubtitleSelectionSlot.secondary => selection.secondaryTrack, + }; + if (resolvedTrack != null) { + switch (slot) { + case _SubtitleSelectionSlot.primary: + _sessionSubtitlePreference = SubtitlePreference.track(resolvedTrack); + case _SubtitleSelectionSlot.secondary: + _sessionSecondarySubtitlePreference = SubtitlePreference.track(resolvedTrack); + } + } _updatePlaybackSessionSubtitleSelection(session, selection); if (mounted) _setPlayerState(() {}); } diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 57332649..b045cfc6 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -176,7 +176,11 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { final preset = options.qualityPreset; final audioPreset = options.audioQualityPreset ?? AudioQualityPreset.original; final wantsOriginal = isTrack ? audioPreset.isOriginal : preset.isOriginal; - final requestedAudioStreamId = _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo); + final requestedAudioStreamId = options.selectedAudioStreamId == null + ? options.preferredAudioTrack == null + ? null + : findSourceAudioTrackForIntent(options.preferredAudioTrack!, mediaInfo.audioTracks)?.id + : _validJellyfinAudioStreamId(options.selectedAudioStreamId, mediaInfo); final requestedSubtitleStreamId = _validJellyfinSubtitleStreamId(options.preferredSubtitleTrack, mediaInfo); final int? maxStreamingBitrate = wantsOriginal ? null diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index 2e0cad00..f2151193 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -42,6 +42,13 @@ class PlaybackInitializationOptions { /// server pick". final int? selectedAudioStreamId; + /// Semantic audio carry for negotiation when [selectedAudioStreamId] is + /// null: the id belongs to another item, but language, title, codec, and + /// channels let a backend resolve the equivalent stream on this one + /// (transcodes bake the audio choice in, so post-open switching cannot + /// recover it). Resolution failure falls back to the server's pick. + final AudioTrack? preferredAudioTrack; + /// Preferred subtitle carried across navigation/reloads. Backends that put /// embedded subtitles in the rendition can use this during negotiation; /// sidecar-capable backends keep subtitle delivery independent. @@ -62,6 +69,7 @@ class PlaybackInitializationOptions { this.qualityPreset = TranscodeQualityPreset.original, this.audioQualityPreset, this.selectedAudioStreamId, + this.preferredAudioTrack, this.preferredSubtitleTrack, this.sessionIdentifier, this.transcodeSessionId, diff --git a/lib/services/playback_subtitle_resolver.dart b/lib/services/playback_subtitle_resolver.dart index 29ec7ce6..5edc58e4 100644 --- a/lib/services/playback_subtitle_resolver.dart +++ b/lib/services/playback_subtitle_resolver.dart @@ -329,18 +329,23 @@ class PlaybackSubtitleResolver { return choices[(normalizedCurrentIndex + advances) % choices.length]; } + /// Stable semantic descriptor for a source audio row — the audio twin of + /// [subtitleTrackForSource]. The row's own title comes first: server + /// display titles collapse to the bare language and cannot tell a + /// commentary or alternate mix from the main track on another item. + static AudioTrack audioTrackForSource(MediaAudioTrack track) { + return AudioTrack( + id: 'source:${track.id}', + title: track.title ?? track.displayTitle ?? track.language, + language: track.languageCode ?? track.language, + codec: track.codec, + channels: track.channels, + isDefault: track.selected, + ); + } + static List _audioTracksForSource(MediaSourceInfo? mediaInfo) { - return [ - for (final track in mediaInfo?.audioTracks ?? const []) - AudioTrack( - id: 'source:${track.id}', - title: track.displayTitle ?? track.title ?? track.language, - language: track.languageCode ?? track.language, - codec: track.codec, - channels: track.channels, - isDefault: track.selected, - ), - ]; + return [for (final track in mediaInfo?.audioTracks ?? const []) audioTrackForSource(track)]; } } diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index ec92176b..9491e252 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -73,6 +73,7 @@ import 'plex_lyrics_parser.dart'; import 'plex_mappers.dart'; import 'plex_playback_mapper.dart'; import 'playback_initialization_types.dart'; +import 'track_selection_service.dart'; part 'plex_client/parts/live_tv.dart'; part 'plex_client/parts/playlists.dart'; @@ -3252,6 +3253,10 @@ class PlexClient if (!data.hasValidVideoUrl) { throw PlaybackException(t.messages.fileInfoNotAvailable, reason: PlaybackFailureReason.noPlayableSource); } + final carriedAudioTrack = options.selectedAudioStreamId == null ? options.preferredAudioTrack : null; + final carriedAudioStreamId = carriedAudioTrack == null || data.mediaInfo == null + ? null + : findSourceAudioTrackForIntent(carriedAudioTrack, data.mediaInfo!.audioTracks)?.id; // Tracks consult the music preset — [qualityPreset] is video-shaped // (resolution/videoQuality) and is ignored for audio. @@ -3286,7 +3291,9 @@ class PlexClient return _transcodeFallbackResult(data, result.outcome, options); } - final resolvedAudioId = _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo); + final resolvedAudioId = carriedAudioTrack == null + ? _resolveAudioStreamId(options.selectedAudioStreamId, data.mediaInfo) + : carriedAudioStreamId; final result = await buildTranscodeStartPath( ratingKey: options.metadata.id, mediaIndex: data.selectedMediaIndex, @@ -3314,7 +3321,7 @@ class PlexClient ); } - return _transcodeFallbackResult(data, result.outcome, options); + return _transcodeFallbackResult(data, result.outcome, options, activeAudioStreamId: carriedAudioStreamId); } return PlaybackInitializationResult( @@ -3323,6 +3330,7 @@ class PlexClient mediaInfo: data.mediaInfo, subtitleSidecars: _buildExternalSubtitles(data.mediaInfo), isOffline: false, + activeAudioStreamId: carriedAudioStreamId, playMethod: 'DirectPlay', playSessionId: options.sessionIdentifier, selectedMediaIndex: data.selectedMediaIndex, @@ -3340,8 +3348,9 @@ class PlexClient PlaybackInitializationResult _transcodeFallbackResult( PlexVideoPlaybackData data, TranscodeDecisionOutcome outcome, - PlaybackInitializationOptions options, - ) { + PlaybackInitializationOptions options, { + int? activeAudioStreamId, + }) { final fallbackReason = outcome == TranscodeDecisionOutcome.directPlayOnly ? TranscodeFallbackReason.directPlayOnly : TranscodeFallbackReason.decisionFailed; @@ -3354,6 +3363,7 @@ class PlexClient isOffline: false, isTranscoding: false, fallbackReason: fallbackReason, + activeAudioStreamId: activeAudioStreamId, playMethod: 'DirectPlay', playSessionId: options.sessionIdentifier, selectedMediaIndex: data.selectedMediaIndex, diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index d56fd00a..056b80e8 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -357,93 +357,147 @@ MediaSubtitleTrack? findPlexTrackForMpvSubtitle( /// signal is a title like "Swedish" are common, and declining them turned the /// viewer's subtitles off on every episode advance. Codec/external parity is /// never sufficient evidence on its own (an arbitrary untagged row would -/// reintroduce the #1716 wrong-track class); it only breaks ties WITHIN the -/// title-matched set, and a residual tie declines rather than guesses. +/// reintroduce the #1716 wrong-track class); technical parity only breaks +/// ties the stronger tiers left, and any tie still standing at the top +/// declines rather than guesses — in every band, so two indistinguishable +/// same-language rows never latch by catalog order. MediaSubtitleTrack? findSourceTrackForIntent(SubtitleIntent intent, List sourceTracks) { - return _findTrackForIntent( - intent, + return _findTrackByEvidenceBands( sourceTracks, + intentLanguage: intent.language, isSelectable: (_) => true, + classMatches: (row) => row.effectiveForced == intent.forced, language: (row) => row.languageCode ?? row.language, - effectiveForced: (row) => row.effectiveForced, titleScore: (row) => _titleScore(intent.title, row.title, row.displayTitle), - codec: (row) => row.codec, - isExternal: (row) => row.isExternal, + codecMatches: (row) => _subtitleCodecsMatch(intent.codec, row.codec), + extraScore: (row) => intent.isExternal == row.isExternal ? 1 : 0, ); } /// Native-track twin of [findSourceTrackForIntent], for catalogs the source /// side cannot describe (legacy offline sidecars) and late-arriving tracks. SubtitleTrack? findNativeTrackForIntent(SubtitleIntent intent, List tracks) { - return _findTrackForIntent( - intent, + return _findTrackByEvidenceBands( tracks, + intentLanguage: intent.language, isSelectable: (track) => track.id != SubtitleTrack.auto.id && track.id != SubtitleTrack.off.id, + classMatches: (track) => track.effectiveForced == intent.forced, language: (track) => track.language, - effectiveForced: (track) => track.effectiveForced, titleScore: (track) => _titleScore(intent.title, track.title, null), - codec: (track) => track.codec, - isExternal: (track) => track.isExternal, + codecMatches: (track) => _subtitleCodecsMatch(intent.codec, track.codec), + extraScore: (track) => intent.isExternal == track.isExternal ? 1 : 0, ); } -/// Shared scorer behind [findSourceTrackForIntent]/[findNativeTrackForIntent]. +/// Audio twin of [findSourceTrackForIntent]: the source-catalog row that +/// serves a cross-item audio carry. [carried] is a semantic vehicle — its id +/// belongs to another item; language, title, codec, and channels are the +/// signal. Audio has no forced-class gate; channel-count parity replaces the +/// external-parity tiebreaker. +MediaAudioTrack? findSourceAudioTrackForIntent(AudioTrack carried, List rows) { + return _findTrackByEvidenceBands( + rows, + intentLanguage: carried.language, + isSelectable: (_) => true, + classMatches: (_) => true, + language: (row) => row.languageCode ?? row.language, + titleScore: (row) => _titleScore(carried.title, row.title, row.displayTitle), + codecMatches: (row) => _audioCodecsMatch(carried.codec, row.codec), + extraScore: (row) => carried.channels != null && carried.channels == row.channels ? 1 : 0, + ); +} + +/// Native twin of [findSourceAudioTrackForIntent]. +AudioTrack? findNativeAudioTrackForIntent(AudioTrack carried, List tracks) { + return _findTrackByEvidenceBands( + tracks, + intentLanguage: carried.language, + isSelectable: (track) => track.id != AudioTrack.auto.id && track.id != AudioTrack.off.id, + classMatches: (_) => true, + language: (track) => track.language, + titleScore: (track) => _titleScore(carried.title, track.title, null), + codecMatches: (track) => _audioCodecsMatch(carried.codec, track.codec), + extraScore: (track) => carried.channels != null && carried.channels == track.channels ? 1 : 0, + ); +} + +/// Sentinel id for a cross-item audio carry. Native player ids ('1', '2', …) +/// and Jellyfin `source:` ids are reused per item, so a carried track +/// with its original id can identity-match a DIFFERENT track that happens to +/// sit at the same position on the next episode — bypassing the evidence +/// bands and their ambiguity decline. +const String carriedAudioTrackId = 'carried'; + +/// Strips item-bound identity from an audio carry so only its semantics may +/// speak — the audio twin of [SubtitlePreference.demoteToIntent]. Same-item +/// reloads keep the original track and its identity fast path. +AudioTrack itemAgnosticAudioCarry(AudioTrack track) => track.copyWith(id: carriedAudioTrackId); + +/// Shared evidence matcher behind the cross-item intent matchers. /// -/// Score bands keep the evidence classes strictly ordered: a language-parity -/// match starts at 10 while a title-evidence match tops out at 9 -/// (codec 5 + title 3 + external 1), so the two can never tie and language -/// always outranks a title coincidence. -T? _findTrackForIntent( - SubtitleIntent intent, +/// Candidates are compared lexicographically, strongest evidence first: +/// declared-language parity, then the semantic title/role match, then +/// technical parity (codec, then channels/external). Each tier only breaks +/// ties left by the tiers above it — a codec that changed between episodes +/// can never outvote the title that names the viewer's track, and language +/// parity always outranks a title coincidence. A tie left standing at the +/// top means the catalog cannot say which row the viewer meant: the match +/// declines and the ladder falls to the server's own per-item choice. +T? _findTrackByEvidenceBands( List candidates, { + required String? intentLanguage, required bool Function(T) isSelectable, + required bool Function(T) classMatches, required String? Function(T) language, - required bool Function(T) effectiveForced, required int Function(T) titleScore, - required String? Function(T) codec, - required bool Function(T) isExternal, + required bool Function(T) codecMatches, + required int Function(T) extraScore, }) { T? bestMatch; - var bestScore = -1; - var bestIsTitleEvidence = false; + List? bestKey; var bestIsAmbiguous = false; for (final candidate in candidates) { if (!isSelectable(candidate)) continue; - if (effectiveForced(candidate) != intent.forced) continue; + if (!classMatches(candidate)) continue; final candidateLanguage = language(candidate); final candidateTitleScore = titleScore(candidate); - final hasLanguageParity = intent.language != null && candidateLanguage != null; - var score = 0; + final hasLanguageParity = intentLanguage != null && candidateLanguage != null; if (hasLanguageParity) { // A declared language on both sides stays authoritative: a // contradiction declines no matter what the title says. - if (!_languagesMatch(intent.language, candidateLanguage)) continue; - score += 10; + if (!_languagesMatch(intentLanguage, candidateLanguage)) continue; } else if (candidateTitleScore < 3) { // Language evidence is missing on at least one side; only a real // title match may serve the intent then. continue; } - if (_subtitleCodecsMatch(intent.codec, codec(candidate))) score += 5; - score += candidateTitleScore; - if (intent.isExternal == isExternal(candidate)) score += 1; - if (score > bestScore) { - bestScore = score; + final key = [ + hasLanguageParity ? 1 : 0, + candidateTitleScore, + codecMatches(candidate) ? 1 : 0, + extraScore(candidate), + ]; + final comparison = bestKey == null ? 1 : _compareEvidenceKeys(key, bestKey); + if (comparison > 0) { + bestKey = key; bestMatch = candidate; - bestIsTitleEvidence = !hasLanguageParity; bestIsAmbiguous = false; - } else if (score == bestScore && bestIsTitleEvidence) { - // Only title-evidence matches can tie (their band never reaches a - // language-parity score). Two rows the tiebreakers cannot separate - // mean the catalog cannot say which one the viewer meant. + } else if (comparison == 0) { bestIsAmbiguous = true; } } - if (bestIsTitleEvidence && bestIsAmbiguous) return null; + if (bestIsAmbiguous) return null; return bestMatch; } +int _compareEvidenceKeys(List a, List b) { + for (var i = 0; i < a.length; i++) { + if (a[i] != b[i]) return a[i].compareTo(b[i]); + } + return 0; +} + /// Find the MPV audio track that matches a Plex audio track AudioTrack? findMpvTrackForPlexAudio( MediaAudioTrack plexTrack, @@ -716,10 +770,6 @@ class TrackSelectionService { return null; } - AudioTrack? findBestAudioMatch(List availableTracks, AudioTrack preferred) { - return findBestTrackMatch(availableTracks, preferred, (t) => t.id, (t) => t.title, (t) => t.language); - } - AudioTrack? findAudioTrackByProfile(List availableTracks, MediaServerUserProfile profile) { if (availableTracks.isEmpty || !profile.autoSelectAudio) return null; @@ -947,12 +997,29 @@ class TrackSelectionService { AudioTrack? trackToSelect; - // Priority 1: Try to match preferred track from navigation + // Priority 1: the carried preference. Full identity first (a same-item + // reload passing back the identical track), then the cross-item evidence + // bands — bridged language parity or a unique title match with + // codec/channel tiebreaks. This replaces the old first-match language + // tier, which latched onto an arbitrary same-language (or same-untagged) + // row and lost the viewer's commentary/dub distinction across episodes. if (preferredAudioTrack != null) { - trackToSelect = findBestAudioMatch(availableTracks, preferredAudioTrack); + trackToSelect = + availableTracks + .where( + (track) => + track.id != AudioTrack.auto.id && + track.id != AudioTrack.off.id && + track.id == preferredAudioTrack.id && + track.title == preferredAudioTrack.title && + track.language == preferredAudioTrack.language, + ) + .firstOrNull ?? + findNativeAudioTrackForIntent(preferredAudioTrack, availableTracks); if (trackToSelect != null) { return TrackSelectionResult(trackToSelect, TrackSelectionPriority.navigation); } + appLogger.d('Audio carry declined: ${preferredAudioTrack.language}/${preferredAudioTrack.title}'); } // Priority 2: Check server-selected track from media info diff --git a/test/screens/video_player/player_initialization_lifecycle_test.dart b/test/screens/video_player/player_initialization_lifecycle_test.dart index ca42d95b..6377154f 100644 --- a/test/screens/video_player/player_initialization_lifecycle_test.dart +++ b/test/screens/video_player/player_initialization_lifecycle_test.dart @@ -60,6 +60,7 @@ void main() { hasCommittedSelection: true, committedTrack: committed, nativeTrack: SubtitleTrack.off, + sessionPreference: null, ); expect(result, isA()); @@ -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()); + 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()); + 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()); + 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'), diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 8cdc250d..b62427df 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -64,6 +64,56 @@ JellyfinClient _clientWithPlaybackInfo( ); } +Future<({PlaybackInitializationResult result, Uri playbackInfoUri, Map 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, + ); + } 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 requests}) _routedClient(Map 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; diff --git a/test/services/playback_subtitle_resolver_test.dart b/test/services/playback_subtitle_resolver_test.dart index 4d013f40..48e79dc8 100644 --- a/test/services/playback_subtitle_resolver_test.dart +++ b/test/services/playback_subtitle_resolver_test.dart @@ -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, diff --git a/test/services/plex_playback_data_request_test.dart b/test/services/plex_playback_data_request_test.dart index b588af83..84058090 100644 --- a/test/services/plex_playback_data_request_test.dart +++ b/test/services/plex_playback_data_request_test.dart @@ -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 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 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 = []; final client = makeClient((request) async { diff --git a/test/services/track_selection_service_test.dart b/test/services/track_selection_service_test.dart index 95408fe8..33fc7621 100644 --- a/test/services/track_selection_service_test.dart +++ b/test/services/track_selection_service_test.dart @@ -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'),