mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-05-31 19:18:26 +01:00
Carry the live subtitle/audio selection to the next episode on all TV navigation paths (next/prev buttons, autoplay) and feed TV subtitle modal selections back into player state via onSubtitleIndexChange so the chosen track is what gets carried. Rank subtitles by language plus forced/hearing-impaired mode, with a no-language fallback (mode + codec + position), and use an explicit match flag so a deliberate "off" selection is retained too.
246 lines
6.4 KiB
TypeScript
246 lines
6.4 KiB
TypeScript
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;
|
|
|
|
/**
|
|
* Score how well a candidate stream matches the previously selected stream.
|
|
* Overridable so subtitle ranking can add mode (forced / hearing-impaired)
|
|
* awareness without changing audio behavior.
|
|
*/
|
|
protected computeScore(
|
|
prevStream: MediaStream,
|
|
stream: MediaStream,
|
|
prevRelIndex: number,
|
|
newRelIndex: number,
|
|
): number {
|
|
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;
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
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;
|
|
// A deliberate "off" selection is a valid match to retain — flag it so
|
|
// callers don't fall back to language preferences / subtitle mode.
|
|
trackOptions.matched = true;
|
|
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;
|
|
}
|
|
|
|
const score = this.computeScore(
|
|
prevStream,
|
|
stream,
|
|
prevRelIndex,
|
|
newRelIndex,
|
|
);
|
|
|
|
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;
|
|
trackOptions.matched = true;
|
|
} else {
|
|
console.debug(
|
|
`AutoSet ${this.streamType} - Threshold not met. Using default.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
class SubtitleStreamRanker extends StreamRankerStrategy {
|
|
streamType = "Subtitle";
|
|
|
|
/**
|
|
* Subtitle scoring that retains both language and mode across episodes.
|
|
*
|
|
* - When the previous track has a language: a language match is weighted high
|
|
* (+3) so it clears the threshold even when codec / title / position differ,
|
|
* and mode (forced / hearing-impaired) acts as a tiebreaker among
|
|
* same-language tracks. Different-language candidates get no language or mode
|
|
* points, so they can never be selected on mode alone (no cross-language
|
|
* hijack).
|
|
* - When the previous track has NO usable language (common for SRT/SUBRIP):
|
|
* language can't help, so mode (forced / hearing-impaired) + codec + relative
|
|
* position become the identity signal. Without this, unlabeled subtitles
|
|
* score only codec+relIndex (≤2) and the selection is silently lost.
|
|
*/
|
|
protected computeScore(
|
|
prevStream: MediaStream,
|
|
stream: MediaStream,
|
|
prevRelIndex: number,
|
|
newRelIndex: number,
|
|
): number {
|
|
let score = 0;
|
|
|
|
if (prevStream.Codec === stream.Codec) {
|
|
score += 1;
|
|
}
|
|
if (prevRelIndex === newRelIndex) {
|
|
score += 1;
|
|
}
|
|
if (
|
|
prevStream.DisplayTitle &&
|
|
prevStream.DisplayTitle === stream.DisplayTitle
|
|
) {
|
|
score += 2;
|
|
}
|
|
|
|
const prevHasLanguage =
|
|
!!prevStream.Language && prevStream.Language !== "und";
|
|
const languageMatches =
|
|
prevHasLanguage && prevStream.Language === stream.Language;
|
|
|
|
if (languageMatches) {
|
|
score += 3;
|
|
} else if (prevHasLanguage) {
|
|
// Previous track had a language but this candidate's differs — do not award
|
|
// mode points, so a different language is never matched on mode alone.
|
|
return score;
|
|
}
|
|
|
|
// Either the language matched, or the previous track had no language (so mode
|
|
// is the primary identity). Normalize the flags to booleans since
|
|
// IsForced / IsHearingImpaired may be undefined.
|
|
if (!!prevStream.IsForced === !!stream.IsForced) {
|
|
score += 2;
|
|
}
|
|
if (!!prevStream.IsHearingImpaired === !!stream.IsHearingImpaired) {
|
|
score += 1;
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
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 { AudioStreamRanker, StreamRanker, SubtitleStreamRanker };
|