Files
SubMiner/src/main/runtime/jellyfin-subtitle-preload.ts
T

471 lines
15 KiB
TypeScript

type JellyfinSession = {
serverUrl: string;
accessToken: string;
userId: string;
username: string;
};
type JellyfinClientInfo = {
clientName: string;
clientVersion: string;
deviceId: string;
};
type JellyfinSubtitleTrack = {
index: number;
language?: string;
title?: string;
codec?: string;
isDefault?: boolean;
isForced?: boolean;
isExternal?: boolean;
deliveryMethod?: string;
deliveryUrl?: string | null;
};
type CachedSubtitleTrack = {
path: string;
cleanupDir: string;
};
type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
source: JellyfinSubtitleTrack;
};
type MpvSubtitleTrack = {
id: number;
lang: string;
title: string;
external: boolean;
externalFilename: string;
};
type MpvClientLike = {
connected?: boolean;
requestProperty: (name: string) => Promise<unknown>;
};
const TRACK_SELECTION_INITIAL_WAIT_MS = 250;
const TRACK_SELECTION_RETRY_MS = 150;
const TRACK_SELECTION_MAX_ATTEMPTS = 10;
export type PreloadJellyfinExternalSubtitlesHandler = ((params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
itemId: string;
}) => Promise<void>) & {
cleanupCachedSubtitles: () => void;
};
function normalizeLang(value: unknown): string {
return String(value || '')
.trim()
.toLowerCase()
.replace(/_/g, '-');
}
function isJapanese(value: string): boolean {
const v = normalizeLang(value);
return (
v === 'ja' ||
v === 'jp' ||
v === 'jpn' ||
v === 'japanese' ||
v.startsWith('ja-') ||
v.startsWith('jp-')
);
}
function isEnglish(value: string): boolean {
const v = normalizeLang(value);
return (
v === 'en' ||
v === 'eng' ||
v === 'english' ||
v === 'enus' ||
v === 'en-us' ||
v.startsWith('en-')
);
}
function isLikelyHearingImpaired(title: string): boolean {
return /\b(hearing impaired|sdh|closed captions?|cc)\b/i.test(title);
}
function pickBestTrackId(
tracks: MpvSubtitleTrack[],
languageMatcher: (value: string) => boolean,
excludeId: number | null = null,
): number | null {
const ranked = tracks
.filter((track) => languageMatcher(track.lang) || languageMatcher(track.title))
.filter((track) => track.id !== excludeId)
.map((track) => ({
track,
score:
(track.external ? 100 : 0) +
(isLikelyHearingImpaired(track.title) ? -10 : 10) +
(/\bdefault\b/i.test(track.title) ? 3 : 0),
}))
.sort((a, b) => b.score - a.score);
return ranked[0]?.track.id ?? null;
}
function pickBestCachedTrackId(
tracks: MpvSubtitleTrack[],
cachedTracks: CachedExternalSubtitleTrack[],
sourceMatcher: (value: string) => boolean,
excludeId: number | null = null,
): number | null {
const cachedByPath = new Map(cachedTracks.map((track) => [track.path, track]));
const ranked = tracks
.map((track) => ({
track,
cached: cachedByPath.get(track.externalFilename),
}))
.filter(({ cached }) =>
cached
? sourceMatcher(cached.source.language || '') || sourceMatcher(cached.source.title || '')
: false,
)
.filter(({ track }) => track.id !== excludeId)
.flatMap(({ track, cached }) =>
cached
? [
{
track,
score:
(track.external ? 100 : 0) +
scoreJellyfinSource(cached.source, cached.source.title || track.title),
},
]
: [],
)
.sort((a, b) => b.score - a.score);
return ranked[0]?.track.id ?? null;
}
// Ranks Jellyfin subtitle sources by metadata alone, so the preferred track is known before download.
function scoreJellyfinSource(source: JellyfinSubtitleTrack, title: string): number {
return (
(source.isDefault ? 35 : 0) +
(source.isExternal === false ? 25 : 0) +
(source.isExternal === true ? -10 : 0) +
(source.isForced ? -25 : 0) +
(isLikelyHearingImpaired(title) ? -10 : 10) +
(/\bdefault\b/i.test(title) ? 3 : 0)
);
}
function pickPreferredJapaneseSource(
sources: JellyfinSubtitleTrack[],
): JellyfinSubtitleTrack | null {
const ranked = sources
.filter((source) => isJapanese(source.language || '') || isJapanese(source.title || ''))
.map((source) => ({ source, score: scoreJellyfinSource(source, source.title || '') }))
.sort((a, b) => b.score - a.score);
return ranked[0]?.source ?? null;
}
function findMpvTrackIdByPath(tracks: MpvSubtitleTrack[], filePath: string): number | null {
return tracks.find((track) => track.externalFilename === filePath)?.id ?? null;
}
function findCachedTrackForMpvTrackId(
tracks: MpvSubtitleTrack[],
cachedTracks: CachedExternalSubtitleTrack[],
trackId: number | null,
): CachedExternalSubtitleTrack | null {
if (trackId === null) return null;
const mpvTrack = tracks.find((track) => track.id === trackId);
if (!mpvTrack?.externalFilename) return null;
return cachedTracks.find((track) => track.path === mpvTrack.externalFilename) ?? null;
}
function isJapaneseTrack(track: MpvSubtitleTrack): boolean {
return isJapanese(track.lang) || isJapanese(track.title);
}
function hasExternalJapaneseTrack(tracks: MpvSubtitleTrack[]): boolean {
return tracks.some((track) => track.external && isJapaneseTrack(track));
}
function parseMpvSubtitleTracks(trackListRaw: unknown): MpvSubtitleTrack[] {
return Array.isArray(trackListRaw)
? trackListRaw
.filter(
(track): track is Record<string, unknown> =>
Boolean(track) && typeof track === 'object' && track.type === 'sub',
)
.map((track) => ({
id: parseTrackId(track.id),
lang: String(track.lang || ''),
title: String(track.title || ''),
external: track.external === true,
externalFilename: String(track['external-filename'] || ''),
}))
.filter((track): track is MpvSubtitleTrack => track.id !== null)
: [];
}
function hasExpectedExternalSubtitleTracks(
tracks: MpvSubtitleTrack[],
expectedExternalFilenames: string[],
): boolean {
if (expectedExternalFilenames.length === 0) {
return true;
}
const loadedExternalFilenames = new Set(
tracks.filter((track) => track.externalFilename).map((track) => track.externalFilename),
);
return expectedExternalFilenames.every((filePath) => loadedExternalFilenames.has(filePath));
}
function parseTrackId(value: unknown): number | null {
if (typeof value === 'string' && value.trim() === '') {
return null;
}
const numeric =
typeof value === 'number' ? value : typeof value === 'string' ? Number(value) : NaN;
return Number.isFinite(numeric) ? numeric : null;
}
async function readMpvSubtitleTracks(deps: {
getMpvClient: () => MpvClientLike | null;
}): Promise<MpvSubtitleTrack[] | null> {
const client = deps.getMpvClient();
if (!client || client.connected === false) {
return null;
}
let trackListRaw: unknown;
try {
trackListRaw = await client.requestProperty('track-list');
} catch {
return null;
}
return parseMpvSubtitleTracks(trackListRaw);
}
async function waitForPreferredSubtitleTracks(
deps: {
getMpvClient: () => MpvClientLike | null;
wait: (ms: number) => Promise<void>;
},
shouldWaitForExternalJapanese: boolean,
expectedExternalFilenames: string[],
): Promise<MpvSubtitleTrack[] | null> {
let subtitleTracks: MpvSubtitleTrack[] = [];
for (let attempt = 1; attempt <= TRACK_SELECTION_MAX_ATTEMPTS; attempt += 1) {
const nextTracks = await readMpvSubtitleTracks(deps);
if (nextTracks !== null) {
subtitleTracks = nextTracks;
if (
(!shouldWaitForExternalJapanese || hasExternalJapaneseTrack(subtitleTracks)) &&
hasExpectedExternalSubtitleTracks(subtitleTracks, expectedExternalFilenames)
) {
return subtitleTracks;
}
}
if (attempt < TRACK_SELECTION_MAX_ATTEMPTS) {
await deps.wait(TRACK_SELECTION_RETRY_MS);
}
}
return subtitleTracks;
}
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
listJellyfinSubtitleTracks: (
session: JellyfinSession,
clientInfo: JellyfinClientInfo,
itemId: string,
) => Promise<JellyfinSubtitleTrack[]>;
getMpvClient: () => MpvClientLike | null;
sendMpvCommand: (command: Array<string | number>) => void;
wait: (ms: number) => Promise<void>;
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
cleanupCachedSubtitles: (dirs: string[]) => void;
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
logDebug: (message: string, error: unknown) => void;
}): PreloadJellyfinExternalSubtitlesHandler {
const activeCacheDirs = new Set<string>();
let preloadQueue: Promise<void> = Promise.resolve();
function resetManagedSubtitleDelay(): void {
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
}
// mpv's sid property-change is the only thing that normally starts prefetching, so a
// coalesced or missed event leaves the whole episode uncached. The downloaded path is
// known here, so seed the pipeline directly instead of waiting on the observer.
function startSubtitlePrefetchForCachedTrack(sourcePath: string): void {
if (!deps.initSubtitlePrefetch) return;
void Promise.resolve()
.then(() => deps.initSubtitlePrefetch!(sourcePath))
.catch((error) => {
deps.logDebug('Failed to start subtitle prefetch for Jellyfin subtitle', error);
});
}
function selectJapanesePrimary(
subtitleTracks: MpvSubtitleTrack[],
cachedTracks: CachedExternalSubtitleTrack[],
trackId: number | null,
): void {
if (trackId === null) {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
return;
}
deps.sendMpvCommand(['set_property', 'sid', trackId]);
const selectedCachedTrack = findCachedTrackForMpvTrackId(subtitleTracks, cachedTracks, trackId);
if (selectedCachedTrack) {
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
}
}
function cleanupActiveCache(): void {
const dirs = [...activeCacheDirs];
if (dirs.length === 0) return;
deps.cleanupCachedSubtitles(dirs);
for (const dir of dirs) {
activeCacheDirs.delete(dir);
}
}
const runPreload = async (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
itemId: string;
}): Promise<void> => {
try {
resetManagedSubtitleDelay();
try {
cleanupActiveCache();
} catch (error) {
deps.logDebug('Failed to cleanup Jellyfin cached subtitles', error);
}
const tracks = await deps.listJellyfinSubtitleTracks(
params.session,
params.clientInfo,
params.itemId,
);
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
if (externalTracks.length === 0) {
return;
}
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sid', 'no']);
deps.sendMpvCommand(['set_property', 'sub-visibility', 'no']);
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
await deps.wait(300);
const seenUrls = new Set<string>();
const uniqueTracks = externalTracks.filter((track) => {
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) return false;
seenUrls.add(track.deliveryUrl);
return true;
});
// Download every track at once and add each to mpv as soon as it lands. Jellyfin has
// to extract embedded tracks from the container, so one slow track must not hold up
// the Japanese primary that annotations depend on. A failed track is skipped rather
// than failing the whole preload, so the remaining tracks still get selected.
const cachedTracks: CachedExternalSubtitleTrack[] = [];
const downloads = new Map(
uniqueTracks.map((track) => [
track,
(async (): Promise<CachedExternalSubtitleTrack | null> => {
const labelBase = (track.title || track.language || '').trim();
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
let cached: CachedExternalSubtitleTrack;
try {
cached = { ...(await deps.cacheSubtitleTrack(track)), source: track };
} catch (error) {
deps.logDebug(`Failed to download Jellyfin subtitle track ${track.index}`, error);
return null;
}
activeCacheDirs.add(cached.cleanupDir);
cachedTracks.push(cached);
deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']);
return cached;
})(),
]),
);
const allDownloads = Promise.all(downloads.values());
try {
let subtitleTracks: MpvSubtitleTrack[] = [];
let japanesePrimaryId: number | null | undefined;
const preferredJapaneseSource = pickPreferredJapaneseSource(uniqueTracks);
const preferredJapanese = preferredJapaneseSource
? await downloads.get(preferredJapaneseSource)
: null;
if (preferredJapanese) {
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
subtitleTracks =
(await waitForPreferredSubtitleTracks(deps, true, [preferredJapanese.path])) ?? [];
if (!hasExternalJapaneseTrack(subtitleTracks)) {
deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', {
itemId: params.itemId,
});
return;
}
// Only commit early to the preferred track. If mpv has not listed it yet, leave the
// choice to the full ranking below instead of locking in a lower-ranked fallback.
const preferredJapaneseTrackId = findMpvTrackIdByPath(
subtitleTracks,
preferredJapanese.path,
);
if (preferredJapaneseTrackId !== null) {
japanesePrimaryId = preferredJapaneseTrackId;
selectJapanesePrimary(subtitleTracks, cachedTracks, japanesePrimaryId);
}
}
await allDownloads;
const cachedPaths = cachedTracks.map((track) => track.path);
if (!hasExpectedExternalSubtitleTracks(subtitleTracks, cachedPaths)) {
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
subtitleTracks = (await waitForPreferredSubtitleTracks(deps, false, cachedPaths)) ?? [];
}
if (japanesePrimaryId === undefined) {
japanesePrimaryId =
pickBestCachedTrackId(subtitleTracks, cachedTracks, isJapanese) ??
pickBestTrackId(subtitleTracks, isJapanese);
selectJapanesePrimary(subtitleTracks, cachedTracks, japanesePrimaryId);
}
const englishSecondaryId =
pickBestCachedTrackId(subtitleTracks, cachedTracks, isEnglish, japanesePrimaryId) ??
pickBestTrackId(subtitleTracks, isEnglish, japanesePrimaryId);
if (englishSecondaryId !== null) {
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]);
}
} finally {
// Keep this run in the queue until every download has registered its cache dir, so
// the next run's cleanup sees them all.
await allDownloads;
}
} catch (error) {
deps.logDebug('Failed to preload Jellyfin external subtitles', error);
}
};
const preload = (params: {
session: JellyfinSession;
clientInfo: JellyfinClientInfo;
itemId: string;
}): Promise<void> => {
preloadQueue = preloadQueue.then(
() => runPreload(params),
() => runPreload(params),
);
return preloadQueue;
};
return Object.assign(preload, {
cleanupCachedSubtitles: cleanupActiveCache,
});
}