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 };