Merge remote-tracking branch 'origin/main' into add-hachidori-backend

This commit is contained in:
2026-09-24 20:43:02 -07:00
3 changed files with 247 additions and 66 deletions
@@ -0,0 +1,4 @@
type: fixed
area: jellyfin
- Jellyfin playback now selects the Japanese subtitle track, and starts subtitle annotations, as soon as that track downloads instead of waiting for every other subtitle track. Tracks now download in parallel, so a slow embedded track Jellyfin has to extract no longer delays the primary subtitles.
@@ -363,6 +363,114 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
]); ]);
}); });
test('preload jellyfin subtitles selects japanese before slower tracks finish downloading', async () => {
const commands: Array<Array<string | number>> = [];
let releaseSlowTrack!: () => void;
const slowTrackBlocked = new Promise<void>((resolve) => {
releaseSlowTrack = resolve;
});
const mpvTracks: Array<Record<string, unknown>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.ass' },
{ index: 1, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
],
getMpvClient: () => ({ requestProperty: async () => mpvTracks }),
cacheSubtitleTrack: async (track) => {
if (track.index === 0) {
await slowTrackBlocked;
}
return {
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
};
},
sendMpvCommand: (command) => {
commands.push(command);
if (command[0] === 'sub-add') {
mpvTracks.push({
type: 'sub',
id: mpvTracks.length + 1,
lang: command[4],
title: command[3],
external: true,
'external-filename': command[1],
});
}
},
}),
);
const done = preload({ session, clientInfo, itemId: 'item-1' });
const hasJapanesePrimary = () =>
commands.some(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 1,
);
for (let tick = 0; tick < 1000 && !hasJapanesePrimary(); tick += 1) {
await new Promise((resolve) => setImmediate(resolve));
}
const selectedBeforeSlowTrack = setPropertyCommandsExceptTrackAutoSelection(commands);
// Release before asserting so a regression fails here instead of leaving the preload hanging.
releaseSlowTrack();
await done;
assert.deepEqual(selectedBeforeSlowTrack, [['set_property', 'sid', 1]]);
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sid', 1],
['set_property', 'secondary-sid', 2],
]);
});
test('preload jellyfin subtitles does not lock in a fallback japanese track during early selection', async () => {
const commands: Array<Array<string | number>> = [];
let requestCount = 0;
const fallbackJapanese = {
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese SDH',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
};
const preferredJapanese = {
type: 'sub',
id: 6,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
};
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese SDH', deliveryUrl: 'https://sub/sdh.srt' },
{
index: 1,
language: 'jpn',
title: 'Japanese',
isDefault: true,
deliveryUrl: 'https://sub/jpn.srt',
},
],
getMpvClient: () => ({
requestProperty: async () => {
requestCount += 1;
// mpv lists the preferred track only after the early selection poll gives up.
return requestCount <= 10 ? [fallbackJapanese] : [fallbackJapanese, preferredJapanese];
},
}),
sendMpvCommand: (command) => commands.push(command),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sid', 6],
]);
});
test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => { test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => {
const commands: Array<Array<string | number>> = []; const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler( const preload = createPreloadJellyfinExternalSubtitlesHandler(
+135 -66
View File
@@ -129,24 +129,48 @@ function pickBestCachedTrackId(
: false, : false,
) )
.filter(({ track }) => track.id !== excludeId) .filter(({ track }) => track.id !== excludeId)
.map(({ track, cached }) => { .flatMap(({ track, cached }) =>
const title = cached?.source.title || track.title; cached
return { ? [
track, {
score: track,
(track.external ? 100 : 0) + score:
(cached?.source.isDefault ? 35 : 0) + (track.external ? 100 : 0) +
(cached?.source.isExternal === false ? 25 : 0) + scoreJellyfinSource(cached.source, cached.source.title || track.title),
(cached?.source.isExternal === true ? -10 : 0) + },
(cached?.source.isForced ? -25 : 0) + ]
(isLikelyHearingImpaired(title) ? -10 : 10) + : [],
(/\bdefault\b/i.test(title) ? 3 : 0), )
};
})
.sort((a, b) => b.score - a.score); .sort((a, b) => b.score - a.score);
return ranked[0]?.track.id ?? null; 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( function findCachedTrackForMpvTrackId(
tracks: MpvSubtitleTrack[], tracks: MpvSubtitleTrack[],
cachedTracks: CachedExternalSubtitleTrack[], cachedTracks: CachedExternalSubtitleTrack[],
@@ -282,6 +306,22 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
}); });
} }
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 { function cleanupActiveCache(): void {
const dirs = [...activeCacheDirs]; const dirs = [...activeCacheDirs];
if (dirs.length === 0) return; if (dirs.length === 0) return;
@@ -319,64 +359,93 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']); deps.sendMpvCommand(['set_property', 'secondary-sub-visibility', 'no']);
await deps.wait(300); await deps.wait(300);
const seenUrls = new Set<string>(); const seenUrls = new Set<string>();
const cachedTracks: CachedExternalSubtitleTrack[] = []; const uniqueTracks = externalTracks.filter((track) => {
for (const track of externalTracks) { if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) return false;
if (!track.deliveryUrl || seenUrls.has(track.deliveryUrl)) {
continue;
}
seenUrls.add(track.deliveryUrl); seenUrls.add(track.deliveryUrl);
const labelBase = (track.title || track.language || '').trim(); return true;
const label = labelBase || `Jellyfin Subtitle ${track.index}`; });
const cached = await deps.cacheSubtitleTrack(track);
activeCacheDirs.add(cached.cleanupDir);
cachedTracks.push({ ...cached, source: track });
deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']);
}
await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS); // Download every track at once and add each to mpv as soon as it lands. Jellyfin has
const shouldWaitForExternalJapanese = externalTracks.some( // to extract embedded tracks from the container, so one slow track must not hold up
(track) => isJapanese(track.language || '') || isJapanese(track.title || ''), // the Japanese primary that annotations depend on.
const cachedTracks: CachedExternalSubtitleTrack[] = [];
const downloads = new Map(
uniqueTracks.map((track) => [
track,
(async (): Promise<CachedExternalSubtitleTrack> => {
const labelBase = (track.title || track.language || '').trim();
const label = labelBase || `Jellyfin Subtitle ${track.index}`;
const cached = { ...(await deps.cacheSubtitleTrack(track)), source: track };
activeCacheDirs.add(cached.cleanupDir);
cachedTracks.push(cached);
deps.sendMpvCommand(['sub-add', cached.path, 'auto', label, track.language || '']);
return cached;
})(),
]),
); );
const subtitleTracks = await waitForPreferredSubtitleTracks( // Settled up front so a failed download is not flagged as unhandled while the
deps, // Japanese track is still being selected.
shouldWaitForExternalJapanese, const allDownloads = Promise.allSettled(downloads.values());
cachedTracks.map((track) => track.path),
);
if (
shouldWaitForExternalJapanese &&
(!subtitleTracks || !hasExternalJapaneseTrack(subtitleTracks))
) {
deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', {
itemId: params.itemId,
});
return;
}
const resolvedSubtitleTracks = subtitleTracks ?? []; try {
const japanesePrimaryId = let subtitleTracks: MpvSubtitleTrack[] = [];
pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isJapanese) ?? let japanesePrimaryId: number | null | undefined;
pickBestTrackId(resolvedSubtitleTracks, isJapanese);
const englishSecondaryId = const preferredJapaneseSource = pickPreferredJapaneseSource(uniqueTracks);
pickBestCachedTrackId(resolvedSubtitleTracks, cachedTracks, isEnglish, japanesePrimaryId) ?? const preferredJapaneseDownload = preferredJapaneseSource
pickBestTrackId(resolvedSubtitleTracks, isEnglish, japanesePrimaryId); ? downloads.get(preferredJapaneseSource)
if (japanesePrimaryId !== null) { : undefined;
const selectedCachedTrack = findCachedTrackForMpvTrackId( if (preferredJapaneseDownload) {
resolvedSubtitleTracks, const preferredJapanese = await preferredJapaneseDownload;
cachedTracks, await deps.wait(TRACK_SELECTION_INITIAL_WAIT_MS);
japanesePrimaryId, subtitleTracks =
); (await waitForPreferredSubtitleTracks(deps, true, [preferredJapanese.path])) ?? [];
if (selectedCachedTrack) { if (!hasExternalJapaneseTrack(subtitleTracks)) {
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); deps.logDebug('Timed out waiting for Jellyfin Japanese subtitle track', {
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path); itemId: params.itemId,
} else { });
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]); 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);
}
} }
} else {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
}
if (englishSecondaryId !== null) { const results = await allDownloads;
deps.sendMpvCommand(['set_property', 'secondary-sid', englishSecondaryId]); const failedDownload = results.find((result) => result.status === 'rejected');
if (failedDownload) {
throw failedDownload.reason;
}
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) { } catch (error) {
deps.logDebug('Failed to preload Jellyfin external subtitles', error); deps.logDebug('Failed to preload Jellyfin external subtitles', error);