From da3654ec4ac3afce7bf2a721ba7611a8bb60b793 Mon Sep 17 00:00:00 2001 From: Uruk Date: Fri, 22 May 2026 01:43:40 +0200 Subject: [PATCH] fix: restore StreamRanker for cross-episode track matching Removing StreamRanker reduced track matching to direct index reuse. When a next episode has a different audio/subtitle track layout (common with anime multi-sub releases or mixed sources), the stored index points at the wrong track. StreamRanker scores by codec/language/title/relative index and only applies a match above threshold, otherwise falls back to media defaults. Restores utils/streamRanker.ts and its use in getDefaultPlaySettings so the cleanup PR stays behavior-preserving for playback. --- utils/jellyfin/getDefaultPlaySettings.ts | 30 ++++- utils/streamRanker.ts | 159 +++++++++++++++++++++++ 2 files changed, 184 insertions(+), 5 deletions(-) create mode 100644 utils/streamRanker.ts diff --git a/utils/jellyfin/getDefaultPlaySettings.ts b/utils/jellyfin/getDefaultPlaySettings.ts index 3cafd0d0..d5ac5032 100644 --- a/utils/jellyfin/getDefaultPlaySettings.ts +++ b/utils/jellyfin/getDefaultPlaySettings.ts @@ -15,6 +15,11 @@ import type { } from "@jellyfin/sdk/lib/generated-client"; import { BITRATES } from "@/components/BitrateSelector"; import { type Settings } from "../atoms/settings"; +import { + AudioStreamRanker, + StreamRanker, + SubtitleStreamRanker, +} from "../streamRanker"; export interface PlaySettings { item: BaseItemDto; @@ -49,27 +54,42 @@ export function getDefaultPlaySettings( } const mediaSource = item.MediaSources?.[0]; - const _streams = mediaSource?.MediaStreams ?? []; + const streams = mediaSource?.MediaStreams ?? []; // Start with media source defaults let audioIndex = mediaSource?.DefaultAudioStreamIndex; let subtitleIndex = mediaSource?.DefaultSubtitleStreamIndex ?? -1; // Try to match previous selections (sequential play) - // Simplified: just use previous indexes if available - if (previous?.indexes && settings) { + if (previous?.indexes && previous?.source && settings) { if ( settings.rememberSubtitleSelections && previous.indexes.subtitleIndex !== undefined ) { - subtitleIndex = previous.indexes.subtitleIndex; + const ranker = new StreamRanker(new SubtitleStreamRanker()); + const result = { DefaultSubtitleStreamIndex: subtitleIndex }; + ranker.rankStream( + previous.indexes.subtitleIndex, + previous.source, + streams, + result, + ); + subtitleIndex = result.DefaultSubtitleStreamIndex; } if ( settings.rememberAudioSelections && previous.indexes.audioIndex !== undefined ) { - audioIndex = previous.indexes.audioIndex; + const ranker = new StreamRanker(new AudioStreamRanker()); + const result = { DefaultAudioStreamIndex: audioIndex }; + ranker.rankStream( + previous.indexes.audioIndex, + previous.source, + streams, + result, + ); + audioIndex = result.DefaultAudioStreamIndex; } } diff --git a/utils/streamRanker.ts b/utils/streamRanker.ts new file mode 100644 index 00000000..8121adea --- /dev/null +++ b/utils/streamRanker.ts @@ -0,0 +1,159 @@ +import type { + MediaSourceInfo, + MediaStream, +} from "@jellyfin/sdk/lib/generated-client"; + +abstract class StreamRankerStrategy { + abstract streamType: string; + + abstract rankStream( + prevIndex: number, + prevSource: MediaSourceInfo, + mediaStreams: MediaStream[], + trackOptions: any, + ): void; + + protected rank( + prevIndex: number, + prevSource: MediaSourceInfo, + mediaStreams: MediaStream[], + trackOptions: any, + ): void { + if (prevIndex === -1) { + console.debug("AutoSet Subtitle - No Stream Set"); + trackOptions[`Default${this.streamType}StreamIndex`] = -1; + return; + } + + if (!prevSource.MediaStreams || !mediaStreams) { + console.debug(`AutoSet ${this.streamType} - No MediaStreams`); + return; + } + + let bestStreamIndex = null; + let bestStreamScore = 0; + + const prevStream = prevSource.MediaStreams[prevIndex]; + + if (!prevStream) { + console.debug(`AutoSet ${this.streamType} - No prevStream`); + return; + } + + console.debug( + `AutoSet ${this.streamType} - Previous was ${prevStream.Index} - ${prevStream.DisplayTitle}`, + ); + + let prevRelIndex = 0; + for (const stream of prevSource.MediaStreams) { + if (stream.Type !== this.streamType) { + continue; + } + + if (stream.Index === prevIndex) { + break; + } + + prevRelIndex += 1; + } + + let newRelIndex = 0; + for (const stream of mediaStreams) { + if (stream.Type !== this.streamType) { + continue; + } + + let score = 0; + + if (prevStream.Codec === stream.Codec) { + score += 1; + } + if (prevRelIndex === newRelIndex) { + score += 1; + } + if ( + prevStream.DisplayTitle && + prevStream.DisplayTitle === stream.DisplayTitle + ) { + score += 2; + } + if ( + prevStream.Language && + prevStream.Language !== "und" && + prevStream.Language === stream.Language + ) { + score += 2; + } + + console.debug( + `AutoSet ${this.streamType} - Score ${score} for ${stream.Index} - ${stream.DisplayTitle}`, + ); + if (score > bestStreamScore && score >= 3) { + bestStreamScore = score; + bestStreamIndex = stream.Index; + } + + newRelIndex += 1; + } + + if (bestStreamIndex != null) { + console.debug( + `AutoSet ${this.streamType} - Using ${bestStreamIndex} score ${bestStreamScore}.`, + ); + trackOptions[`Default${this.streamType}StreamIndex`] = bestStreamIndex; + } else { + console.debug( + `AutoSet ${this.streamType} - Threshold not met. Using default.`, + ); + } + } +} + +class SubtitleStreamRanker extends StreamRankerStrategy { + streamType = "Subtitle"; + + rankStream( + prevIndex: number, + prevSource: MediaSourceInfo, + mediaStreams: MediaStream[], + trackOptions: any, + ): void { + super.rank(prevIndex, prevSource, mediaStreams, trackOptions); + } +} + +class AudioStreamRanker extends StreamRankerStrategy { + streamType = "Audio"; + + rankStream( + prevIndex: number, + prevSource: MediaSourceInfo, + mediaStreams: MediaStream[], + trackOptions: any, + ): void { + super.rank(prevIndex, prevSource, mediaStreams, trackOptions); + } +} + +class StreamRanker { + private strategy: StreamRankerStrategy; + + constructor(strategy: StreamRankerStrategy) { + this.strategy = strategy; + } + + setStrategy(strategy: StreamRankerStrategy) { + this.strategy = strategy; + } + + rankStream( + prevIndex: number, + prevSource: MediaSourceInfo, + mediaStreams: MediaStream[], + trackOptions: any, + ) { + this.strategy.rankStream(prevIndex, prevSource, mediaStreams, trackOptions); + } +} + +export { StreamRanker, SubtitleStreamRanker, AudioStreamRanker };