Files
silo-server/web/src/player/utils/subtitleSort.ts
T

246 lines
7.3 KiB
TypeScript

import type { PlayerSubtitleInfo, PlayerSubtitleTrackSignature, SubtitleMode } from "../types";
const ORIGINAL_LANGUAGE_SENTINEL = "original";
const SOURCE_PRIORITY: Record<string, number> = {
external: 0,
downloaded: 1,
embedded: 2,
};
const ISO639_TO_3: Record<string, string> = {
en: "eng",
es: "spa",
fr: "fre",
de: "ger",
it: "ita",
pt: "por",
nl: "dut",
pl: "pol",
sv: "swe",
no: "nor",
da: "dan",
fi: "fin",
ru: "rus",
uk: "ukr",
cs: "cze",
sk: "slo",
hu: "hun",
ro: "rum",
bg: "bul",
hr: "hrv",
sl: "slv",
sr: "srp",
tr: "tur",
el: "gre",
he: "heb",
ar: "ara",
zh: "chi",
ja: "jpn",
ko: "kor",
vi: "vie",
th: "tha",
id: "ind",
ms: "may",
hi: "hin",
};
function normalize(value: string | undefined | null): string {
return (value ?? "").trim().toLowerCase();
}
function normalizeConcreteLanguage(value: string | undefined | null): string | null {
const normalized = normalize(value);
if (!normalized || normalized === ORIGINAL_LANGUAGE_SENTINEL) {
return null;
}
return normalized;
}
function sameLanguageCode(a: string | undefined | null, b: string | undefined | null): boolean {
const left = normalizeConcreteLanguage(a);
const right = normalizeConcreteLanguage(b);
if (!left || !right) return false;
if (left === right) return true;
return (ISO639_TO_3[left] ?? left) === (ISO639_TO_3[right] ?? right);
}
function sameLanguage(track: PlayerSubtitleInfo, language: string): boolean {
return sameLanguageCode(track.language, language);
}
function subtitleTrackMatchesSignature(
track: PlayerSubtitleInfo,
signature: PlayerSubtitleTrackSignature | null,
): boolean {
if (!signature) return false;
return (
normalize(track.source) === normalize(signature.source) &&
normalize(track.language) === normalize(signature.language) &&
normalize(track.codec) === normalize(signature.codec) &&
(normalize(signature.label) === "" || normalize(track.label) === normalize(signature.label)) &&
Boolean(track.forced) === Boolean(signature.forced) &&
Boolean(track.hearing_impaired) === Boolean(signature.hearing_impaired)
);
}
function findExactSubtitleSignatureMatch(
tracks: PlayerSubtitleInfo[],
signature: PlayerSubtitleTrackSignature | null,
): number | null {
if (!signature) return null;
const match = tracks.find((track) => subtitleTrackMatchesSignature(track, signature));
return match?.index ?? null;
}
function scoreSignatureFallback(
track: PlayerSubtitleInfo,
signature: PlayerSubtitleTrackSignature | null,
): number {
if (!signature) return 0;
let score = 0;
if (normalize(track.source) === normalize(signature.source)) score += 4;
if (Boolean(track.forced) === Boolean(signature.forced)) score += 2;
if (Boolean(track.hearing_impaired) === Boolean(signature.hearing_impaired)) score += 2;
if (normalize(track.codec) === normalize(signature.codec)) score += 1;
if (normalize(track.label) === normalize(signature.label)) score += 1;
return score;
}
/** Sort subtitle tracks: external first, then downloaded, then embedded. */
export function sortSubtitlesBySource(tracks: PlayerSubtitleInfo[]): PlayerSubtitleInfo[] {
return [...tracks].sort((a, b) => {
const pa = SOURCE_PRIORITY[a.source ?? "embedded"] ?? 2;
const pb = SOURCE_PRIORITY[b.source ?? "embedded"] ?? 2;
return pa - pb;
});
}
/**
* Find the best subtitle track index for a given language,
* preferring external > downloaded > embedded.
* Returns the track's backend index (track.index) or -1 if no match.
*/
export function findPreferredSubtitleIndex(tracks: PlayerSubtitleInfo[], language: string): number {
let bestIdx = -1;
let bestPriority = Infinity;
for (const track of tracks) {
if (!track || !sameLanguage(track, language)) continue;
const priority = SOURCE_PRIORITY[track.source ?? "embedded"] ?? 2;
if (priority < bestPriority) {
bestPriority = priority;
bestIdx = track.index;
}
}
return bestIdx;
}
function findPreferredSubtitleIndexWithSignature(
tracks: PlayerSubtitleInfo[],
language: string,
signature: PlayerSubtitleTrackSignature | null,
): number {
let bestTrack: PlayerSubtitleInfo | null = null;
let bestScore = -1;
let bestPriority = Infinity;
for (const track of tracks) {
if (!track || !sameLanguage(track, language)) continue;
const priority = SOURCE_PRIORITY[track.source ?? "embedded"] ?? 2;
const score = scoreSignatureFallback(track, signature);
if (
bestTrack === null ||
score > bestScore ||
(score === bestScore && priority < bestPriority)
) {
bestTrack = track;
bestScore = score;
bestPriority = priority;
}
}
return bestTrack?.index ?? -1;
}
export interface SubtitleAutoSelectOptions {
mode: SubtitleMode;
tracks: PlayerSubtitleInfo[];
preferredLanguage: string | null;
preferredTrackSignature?: PlayerSubtitleTrackSignature | null;
audioLanguage: string | null;
profileLanguage: string | null;
showForcedSubtitles: boolean;
}
function findForcedSubtitleIndex(
tracks: PlayerSubtitleInfo[],
language: string | null | undefined,
): number | null {
if (!language) return null;
const match = findPreferredSubtitleIndex(
tracks.filter((track) => track.forced),
language,
);
return match >= 0 ? match : null;
}
/**
* Determines which subtitle track to auto-select on playback start.
* Returns the track's backend index, or null if no track should be selected.
*/
export function resolveSubtitleAutoSelect(options: SubtitleAutoSelectOptions): number | null {
const {
mode,
tracks,
preferredLanguage,
preferredTrackSignature,
audioLanguage,
profileLanguage,
showForcedSubtitles,
} = options;
const signature = preferredTrackSignature ?? null;
if (tracks.length === 0) return null;
const preferredSubtitleLang = normalizeConcreteLanguage(preferredLanguage);
const normalizedProfileLanguage = normalize(profileLanguage);
const effectiveProfileLang =
normalizeConcreteLanguage(profileLanguage) ??
(normalizedProfileLanguage === ORIGINAL_LANGUAGE_SENTINEL
? preferredSubtitleLang
: normalizedProfileLanguage === ""
? "en"
: null);
const effectiveAudioLang = normalizeConcreteLanguage(audioLanguage) ?? effectiveProfileLang;
switch (mode) {
case "off":
return showForcedSubtitles ? findForcedSubtitleIndex(tracks, effectiveAudioLang) : null;
case "always": {
const exactMatch = findExactSubtitleSignatureMatch(tracks, signature);
if (exactMatch !== null) return exactMatch;
if (!preferredLanguage) return null;
const match = findPreferredSubtitleIndexWithSignature(tracks, preferredLanguage, signature);
return match >= 0 ? match : null;
}
case "auto": {
if (preferredLanguage === "") return null;
if (effectiveProfileLang && sameLanguageCode(effectiveAudioLang, effectiveProfileLang)) {
return showForcedSubtitles ? findForcedSubtitleIndex(tracks, effectiveAudioLang) : null;
}
const lang = preferredSubtitleLang ?? effectiveProfileLang;
if (!lang) {
return showForcedSubtitles ? findForcedSubtitleIndex(tracks, effectiveAudioLang) : null;
}
const match = findPreferredSubtitleIndexWithSignature(tracks, lang, signature);
return match >= 0 ? match : null;
}
default:
return null;
}
}