fix(jellyfin): stop inferring subtitle delays

This commit is contained in:
2026-08-30 21:03:25 -07:00
parent 2ad491e95c
commit bb611588ed
12 changed files with 9 additions and 697 deletions
-6
View File
@@ -131,12 +131,6 @@ export {
resolvePlaybackPlan as resolveJellyfinPlaybackPlanRuntime,
ticksToSeconds as jellyfinTicksToSecondsRuntime,
} from './jellyfin';
export { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
export {
estimateSubtitleTimingOffset,
type SubtitleTimingOffsetOptions,
type SubtitleTimingOffsetResult,
} from './subtitle-timing-offset';
export { buildJellyfinTimelinePayload, JellyfinRemoteSessionService } from './jellyfin-remote';
export {
broadcastRuntimeOptionsChangedRuntime,
@@ -1,54 +0,0 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import test from 'node:test';
import { loadJellyfinSubtitleDelay, saveJellyfinSubtitleDelay } from './jellyfin-subtitle-delay';
function statePath(name: string): string {
return path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-jellyfin-delay-')), name);
}
test('jellyfin subtitle delay store saves and loads delay by item and stream', () => {
const filePath = statePath('delays.json');
assert.equal(
saveJellyfinSubtitleDelay({
filePath,
itemId: 'episode-1',
streamIndex: 3,
delaySeconds: 1.25,
}),
true,
);
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 1.25);
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), null);
});
test('jellyfin subtitle delay store preserves other stream delays when updating one stream', () => {
const filePath = statePath('delays.json');
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 1.25 });
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4, delaySeconds: -0.5 });
saveJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3, delaySeconds: 2 });
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), 2);
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 4 }), -0.5);
});
test('jellyfin subtitle delay store ignores invalid files and values', () => {
const filePath = statePath('delays.json');
fs.writeFileSync(filePath, '{');
assert.equal(loadJellyfinSubtitleDelay({ filePath, itemId: 'episode-1', streamIndex: 3 }), null);
assert.equal(
saveJellyfinSubtitleDelay({
filePath,
itemId: 'episode-1',
streamIndex: 3,
delaySeconds: Number.NaN,
}),
false,
);
});
@@ -1,66 +0,0 @@
import * as fs from 'fs';
import * as path from 'path';
type JellyfinSubtitleDelayStore = {
version?: unknown;
delays?: unknown;
};
type JellyfinSubtitleDelayParams = {
filePath: string;
itemId: string;
streamIndex: number;
};
type SaveJellyfinSubtitleDelayParams = JellyfinSubtitleDelayParams & {
delaySeconds: number;
};
function storeKey(itemId: string, streamIndex: number): string {
return JSON.stringify([itemId, streamIndex]);
}
function readDelayMap(filePath: string): Record<string, number> {
try {
if (!fs.existsSync(filePath)) return {};
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as JellyfinSubtitleDelayStore;
if (
!parsed ||
typeof parsed !== 'object' ||
!parsed.delays ||
typeof parsed.delays !== 'object'
) {
return {};
}
const delays: Record<string, number> = {};
for (const [key, value] of Object.entries(parsed.delays as Record<string, unknown>)) {
if (typeof value === 'number' && Number.isFinite(value)) {
delays[key] = value;
}
}
return delays;
} catch {
return {};
}
}
export function loadJellyfinSubtitleDelay(params: JellyfinSubtitleDelayParams): number | null {
const delay = readDelayMap(params.filePath)[storeKey(params.itemId, params.streamIndex)];
return typeof delay === 'number' && Number.isFinite(delay) ? delay : null;
}
export function saveJellyfinSubtitleDelay(params: SaveJellyfinSubtitleDelayParams): boolean {
if (!Number.isFinite(params.delaySeconds)) return false;
try {
const delays = readDelayMap(params.filePath);
delays[storeKey(params.itemId, params.streamIndex)] = params.delaySeconds;
const dir = path.dirname(params.filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(params.filePath, JSON.stringify({ version: 1, delays }, null, 2));
return true;
} catch {
return false;
}
}
@@ -1,73 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { estimateSubtitleTimingOffset } from './subtitle-timing-offset';
function cue(startTime: number) {
return { startTime, endTime: startTime + 1, text: `cue ${startTime}` };
}
test('estimate subtitle timing offset detects a late Jellyfin subtitle timeline', () => {
const primary = [
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
87.988, 90.991, 94.094, 97.097,
].map(cue);
const reference = [
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56,
].map(cue);
const result = estimateSubtitleTimingOffset(primary, reference);
assert.ok(result);
assert.ok(result.offsetSeconds > -32);
assert.ok(result.offsetSeconds < -31);
assert.ok(result.matchCount >= 8);
assert.ok(result.meanErrorSeconds <= 0.75);
});
test('estimate subtitle timing offset favors the early episode timeline', () => {
const primary = [
34.935, 36.937, 41.441, 45.279, 48.115, 52.286, 54.955, 59.793, 63.63, 67.634, 76.643, 80.814,
87.988, 90.991, 94.094, 97.097, 207.974, 212.579, 222.422, 228.095, 232.432, 238.271, 244.778,
246.78, 249.282, 251.284, 253.62, 256.289, 259.626, 262.129, 264.965, 267.634, 270.303, 274.407,
277.077, 280.08, 284.084, 288.421, 291.925, 295.262, 298.431, 301.101, 306.773, 308.942,
312.946, 316.283, 321.621, 326.626, 331.131, 336.069, 340.407, 343.41, 351.418, 355.422,
357.924, 362.429, 365.432, 370.604, 373.273, 377.944, 381.114, 384.618, 387.621, 390.957,
396.73, 399.232, 401.568, 403.57, 405.572, 407.574, 409.743, 412.746, 418.752, 425.258, 427.26,
435.602, 440.44, 442.942, 445.445, 449.783,
].map(cue);
const reference = [
3.46, 9.48, 13.61, 21.4, 28.16, 32.06, 35.93, 45.1, 56.57, 59.68, 62.44, 65.56, 165.77, 172.81,
176.1, 177.27, 186.33, 191.33, 195.78, 201.83, 212.9, 214.09, 216.73, 220.2, 222.91, 225.65,
232.8, 237.92, 242.23, 243.28, 247.53, 252.04, 255.9, 258.86, 262.09, 264.43, 276.07, 278.01,
280.98, 285.67, 289.89, 294.57, 300, 303.56, 308.58, 316.37, 318.38, 319.86, 325.38, 328.82,
333.68, 335.26, 336.82, 340.11, 342.11, 344.36, 346.39, 347.53, 350.92, 370.18, 372.88, 376.43,
388.2, 390.57, 403.96, 406.36, 409.72, 413.78, 425.55, 432.76, 435.03, 438.06, 443.73, 448.31,
450.57, 457.62, 463.41, 465.85, 473.79, 480.59,
].map(cue);
const result = estimateSubtitleTimingOffset(primary, reference);
assert.ok(result);
assert.ok(result.offsetSeconds > -32);
assert.ok(result.offsetSeconds < -31);
});
test('estimate subtitle timing offset ignores subtitle timelines that are already aligned', () => {
const starts = [1, 5, 9, 14, 20, 25, 31, 38];
const result = estimateSubtitleTimingOffset(
starts.map(cue),
starts.map((start) => cue(start + 0.04)),
);
assert.equal(result, null);
});
test('estimate subtitle timing offset rejects weak timeline matches', () => {
const primary = [10, 20, 30, 40, 50, 60, 70, 80].map(cue);
const reference = [1, 2, 3, 4, 5, 6, 7, 8].map(cue);
const result = estimateSubtitleTimingOffset(primary, reference);
assert.equal(result, null);
});
-153
View File
@@ -1,153 +0,0 @@
import type { SubtitleCue } from './subtitle-cue-parser';
export type SubtitleTimingOffsetResult = {
offsetSeconds: number;
matchCount: number;
meanErrorSeconds: number;
maxErrorSeconds: number;
};
export type SubtitleTimingOffsetOptions = {
maxCueCount?: number;
maxOffsetSeconds?: number;
matchThresholdSeconds?: number;
maxMeanErrorSeconds?: number;
minMatchCount?: number;
minMatchRatio?: number;
minUsefulOffsetSeconds?: number;
};
type OffsetScore = SubtitleTimingOffsetResult;
const DEFAULT_MAX_CUE_COUNT = 60;
const DEFAULT_MAX_OFFSET_SECONDS = 180;
const DEFAULT_MATCH_THRESHOLD_SECONDS = 1;
const DEFAULT_MAX_MEAN_ERROR_SECONDS = 0.75;
const DEFAULT_MIN_MATCH_COUNT = 8;
const DEFAULT_MIN_MATCH_RATIO = 0.25;
const DEFAULT_MIN_USEFUL_OFFSET_SECONDS = 0.25;
function normalizeCueStarts(cues: SubtitleCue[], maxCueCount: number): number[] {
const starts = cues
.map((cue) => cue.startTime)
.filter((start) => Number.isFinite(start) && start >= 0)
.sort((a, b) => a - b);
const deduped: number[] = [];
for (const start of starts) {
const previous = deduped[deduped.length - 1];
if (previous === undefined || Math.abs(start - previous) > 0.05) {
deduped.push(start);
}
if (deduped.length >= maxCueCount) {
break;
}
}
return deduped;
}
function roundToMillis(value: number): number {
return Math.round(value * 1000) / 1000;
}
function scoreOffset(
primaryStarts: number[],
referenceStarts: number[],
offsetSeconds: number,
matchThresholdSeconds: number,
): OffsetScore {
let primaryIndex = 0;
let referenceIndex = 0;
let matchCount = 0;
let totalErrorSeconds = 0;
let maxErrorSeconds = 0;
while (primaryIndex < primaryStarts.length && referenceIndex < referenceStarts.length) {
const shiftedPrimary = primaryStarts[primaryIndex]! + offsetSeconds;
const reference = referenceStarts[referenceIndex]!;
const errorSeconds = Math.abs(shiftedPrimary - reference);
if (errorSeconds <= matchThresholdSeconds) {
matchCount += 1;
totalErrorSeconds += errorSeconds;
maxErrorSeconds = Math.max(maxErrorSeconds, errorSeconds);
primaryIndex += 1;
referenceIndex += 1;
continue;
}
if (shiftedPrimary < reference) {
primaryIndex += 1;
} else {
referenceIndex += 1;
}
}
return {
offsetSeconds,
matchCount,
meanErrorSeconds: matchCount > 0 ? totalErrorSeconds / matchCount : Number.POSITIVE_INFINITY,
maxErrorSeconds,
};
}
function isBetterScore(next: OffsetScore, current: OffsetScore | null): boolean {
if (current === null) return true;
if (next.matchCount !== current.matchCount) return next.matchCount > current.matchCount;
if (next.meanErrorSeconds !== current.meanErrorSeconds) {
return next.meanErrorSeconds < current.meanErrorSeconds;
}
return Math.abs(next.offsetSeconds) < Math.abs(current.offsetSeconds);
}
export function estimateSubtitleTimingOffset(
primaryCues: SubtitleCue[],
referenceCues: SubtitleCue[],
options: SubtitleTimingOffsetOptions = {},
): SubtitleTimingOffsetResult | null {
const maxCueCount = options.maxCueCount ?? DEFAULT_MAX_CUE_COUNT;
const maxOffsetSeconds = options.maxOffsetSeconds ?? DEFAULT_MAX_OFFSET_SECONDS;
const matchThresholdSeconds = options.matchThresholdSeconds ?? DEFAULT_MATCH_THRESHOLD_SECONDS;
const maxMeanErrorSeconds = options.maxMeanErrorSeconds ?? DEFAULT_MAX_MEAN_ERROR_SECONDS;
const minMatchCount = options.minMatchCount ?? DEFAULT_MIN_MATCH_COUNT;
const minMatchRatio = options.minMatchRatio ?? DEFAULT_MIN_MATCH_RATIO;
const minUsefulOffsetSeconds =
options.minUsefulOffsetSeconds ?? DEFAULT_MIN_USEFUL_OFFSET_SECONDS;
const primaryStarts = normalizeCueStarts(primaryCues, maxCueCount);
const referenceStarts = normalizeCueStarts(referenceCues, maxCueCount);
const comparableCueCount = Math.min(primaryStarts.length, referenceStarts.length);
if (comparableCueCount < minMatchCount) {
return null;
}
const candidates = new Set<number>();
for (const primaryStart of primaryStarts) {
for (const referenceStart of referenceStarts) {
const offsetSeconds = roundToMillis(referenceStart - primaryStart);
if (Math.abs(offsetSeconds) <= maxOffsetSeconds) {
candidates.add(offsetSeconds);
}
}
}
let best: OffsetScore | null = null;
for (const offsetSeconds of candidates) {
if (Math.abs(offsetSeconds) < minUsefulOffsetSeconds) {
continue;
}
const score = scoreOffset(primaryStarts, referenceStarts, offsetSeconds, matchThresholdSeconds);
if (score.matchCount < minMatchCount) {
continue;
}
if (score.matchCount / comparableCueCount < minMatchRatio) {
continue;
}
if (score.meanErrorSeconds > maxMeanErrorSeconds) {
continue;
}
if (isBetterScore(score, best)) {
best = score;
}
}
return best;
}
-23
View File
@@ -302,7 +302,6 @@ import {
listJellyfinItemsRuntime,
listJellyfinLibrariesRuntime,
listJellyfinSubtitleTracksRuntime,
loadJellyfinSubtitleDelay,
loadSubtitlePosition as loadSubtitlePositionCore,
loadYomitanExtension as loadYomitanExtensionCore,
markLastCardAsAudioCard as markLastCardAsAudioCardCore,
@@ -315,7 +314,6 @@ import {
resolveSanitizedSubtitleSeekCommand,
resolveJellyfinPlaybackPlanRuntime,
runStartupBootstrapRuntime,
saveJellyfinSubtitleDelay,
saveSubtitlePosition as saveSubtitlePositionCore,
clearYomitanParserCachesForWindow,
getYomitanCurrentAnkiDeckName as getYomitanCurrentAnkiDeckNameCore,
@@ -677,7 +675,6 @@ function spawnManagedMpvProcess(args: string[]): ReturnType<typeof spawn> {
}
let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null;
let activeJellyfinSubtitleDelayKey: { itemId: string; streamIndex: number } | null = null;
let jellyfinRemoteLastProgressAtMs = 0;
let jellyfinMpvAutoLaunchInFlight: Promise<boolean> | null = null;
let backgroundWarmupsStarted = false;
@@ -2482,7 +2479,6 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime<OverlayHos
const createFieldGroupingCallback = fieldGroupingOverlayRuntime.createFieldGroupingCallback;
const SUBTITLE_POSITIONS_DIR = path.join(CONFIG_DIR, 'subtitle-positions');
const JELLYFIN_SUBTITLE_DELAYS_PATH = path.join(CONFIG_DIR, 'jellyfin-subtitle-delays.json');
const mediaRuntime = createMediaRuntimeService(
createBuildMediaRuntimeMainDepsHandler({
@@ -3106,23 +3102,6 @@ const {
wait: (ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)),
cacheSubtitleTrack: (track) => jellyfinSubtitleCacheIo.cacheSubtitleTrack(track),
cleanupCachedSubtitles: (dirs) => jellyfinSubtitleCacheIo.cleanupCachedSubtitles(dirs),
getSavedSubtitleDelay: (itemId, streamIndex) =>
loadJellyfinSubtitleDelay({
filePath: JELLYFIN_SUBTITLE_DELAYS_PATH,
itemId,
streamIndex,
}),
setActiveSubtitleDelayKey: (key) => {
activeJellyfinSubtitleDelayKey = key;
},
loadSubtitleSourceText,
saveSubtitleDelay: (itemId, streamIndex, delaySeconds) =>
saveJellyfinSubtitleDelay({
filePath: JELLYFIN_SUBTITLE_DELAYS_PATH,
itemId,
streamIndex,
delaySeconds,
}),
initSubtitlePrefetch: (sourcePath) =>
subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath),
logDebug: (message, error) => {
@@ -3188,7 +3167,6 @@ const {
getActivePlayback: () => activeJellyfinRemotePlayback,
clearActivePlayback: () => {
activeJellyfinRemotePlayback = null;
activeJellyfinSubtitleDelayKey = null;
},
getSession: () => appState.jellyfinRemoteSession,
getNow: () => Date.now(),
@@ -4545,7 +4523,6 @@ const {
appState.activeParsedSubtitleSource = null;
appState.activeParsedSubtitleMediaPath = null;
}
activeJellyfinSubtitleDelayKey = null;
overlayManager.broadcastToOverlayWindows('subtitle:set', resetSubtitlePayload);
subtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
annotationSubtitleWsService.broadcast(resetSubtitlePayload, frequencyOptions);
@@ -19,19 +19,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
return { path: '/tmp/sub.srt', cleanupDir: '/tmp/subs' };
},
cleanupCachedSubtitles: () => calls.push('cleanup'),
getSavedSubtitleDelay: (_itemId, streamIndex) => {
calls.push(`load-delay:${streamIndex}`);
return 1.25;
},
setActiveSubtitleDelayKey: (key) => calls.push(`active-delay:${key?.streamIndex ?? 'none'}`),
loadSubtitleSourceText: async (source) => {
calls.push(`load-source:${source}`);
return 'subtitle';
},
saveSubtitleDelay: (_itemId, streamIndex, delaySeconds) => {
calls.push(`save-delay:${streamIndex}:${delaySeconds}`);
return true;
},
logDebug: (message) => calls.push(`debug:${message}`),
})();
@@ -41,21 +28,6 @@ test('preload jellyfin external subtitles main deps builder maps callbacks', asy
await deps.wait(1);
await deps.cacheSubtitleTrack({ index: 1, deliveryUrl: 'https://example.test/sub.srt' });
deps.cleanupCachedSubtitles(['/tmp/subs']);
assert.equal(deps.getSavedSubtitleDelay?.('item', 3), 1.25);
deps.setActiveSubtitleDelayKey?.({ itemId: 'item', streamIndex: 3 });
assert.equal(await deps.loadSubtitleSourceText?.('/tmp/sub.srt'), 'subtitle');
assert.equal(deps.saveSubtitleDelay?.('item', 3, -31.5), true);
deps.logDebug('oops', null);
assert.deepEqual(calls, [
'list',
'send',
'wait',
'cache',
'cleanup',
'load-delay:3',
'active-delay:3',
'load-source:/tmp/sub.srt',
'save-delay:3:-31.5',
'debug:oops',
]);
assert.deepEqual(calls, ['list', 'send', 'wait', 'cache', 'cleanup', 'debug:oops']);
});
@@ -15,19 +15,6 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
wait: (ms: number) => deps.wait(ms),
cacheSubtitleTrack: (track) => deps.cacheSubtitleTrack(track),
cleanupCachedSubtitles: (dirs) => deps.cleanupCachedSubtitles(dirs),
getSavedSubtitleDelay: deps.getSavedSubtitleDelay
? (itemId, streamIndex) => deps.getSavedSubtitleDelay!(itemId, streamIndex)
: undefined,
setActiveSubtitleDelayKey: deps.setActiveSubtitleDelayKey
? (key) => deps.setActiveSubtitleDelayKey!(key)
: undefined,
loadSubtitleSourceText: deps.loadSubtitleSourceText
? (source) => deps.loadSubtitleSourceText!(source)
: undefined,
saveSubtitleDelay: deps.saveSubtitleDelay
? (itemId, streamIndex, delaySeconds) =>
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
: undefined,
initSubtitlePrefetch: deps.initSubtitlePrefetch
? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath)
: undefined,
@@ -32,14 +32,6 @@ function makeDeps(overrides: {
cleanupCachedSubtitles?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['cleanupCachedSubtitles'];
getSavedSubtitleDelay?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['getSavedSubtitleDelay'];
setActiveSubtitleDelayKey?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['setActiveSubtitleDelayKey'];
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
initSubtitlePrefetch?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['initSubtitlePrefetch'];
@@ -57,10 +49,6 @@ function makeDeps(overrides: {
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
})),
cleanupCachedSubtitles: overrides.cleanupCachedSubtitles ?? (() => {}),
getSavedSubtitleDelay: overrides.getSavedSubtitleDelay,
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
saveSubtitleDelay: overrides.saveSubtitleDelay,
initSubtitlePrefetch: overrides.initSubtitlePrefetch,
logDebug: overrides.logDebug ?? (() => {}),
};
@@ -377,20 +365,17 @@ test('preload jellyfin subtitles waits for delayed external japanese track inste
test('preload jellyfin subtitles clears managed delay when no external tracks are available', async () => {
const commands: Array<Array<string | number>> = [];
const activeDelayKeys: Array<unknown> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Embedded Japanese' },
],
sendMpvCommand: (command) => commands.push(command),
setActiveSubtitleDelayKey: (key) => activeDelayKeys.push(key),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
assert.deepEqual(activeDelayKeys, [null]);
assert.deepEqual(commands, [['set_property', 'sub-delay', 0]]);
});
@@ -461,42 +446,7 @@ test('preload jellyfin subtitles prefers Jellyfin default and embedded japanese
]);
});
test('preload jellyfin subtitles applies saved delay for selected japanese stream', async () => {
const commands: Array<Array<string | number>> = [];
const activeKeys: Array<{ itemId: string; streamIndex: number } | null> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 3, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 11,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/3.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
getSavedSubtitleDelay: (_itemId, streamIndex) => (streamIndex === 3 ? 1.25 : null),
setActiveSubtitleDelayKey: (key) => activeKeys.push(key),
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
assert.deepEqual(setPropertyCommandsExceptTrackAutoSelection(commands), [
['set_property', 'sub-delay', 1.25],
['set_property', 'sid', 11],
]);
assert.deepEqual(activeKeys, [{ itemId: 'item-9', streamIndex: 3 }]);
});
test('preload jellyfin subtitles applies saved delay before selecting japanese stream', async () => {
test('preload jellyfin subtitles resets delay before selecting japanese stream', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
@@ -516,14 +466,13 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s
],
}),
sendMpvCommand: (command) => commands.push(command),
getSavedSubtitleDelay: () => 1.25,
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
const delayIndex = commands.findIndex(
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 1.25,
(command) => command[0] === 'set_property' && command[1] === 'sub-delay' && command[2] === 0,
);
const selectedSidIndex = commands.findIndex(
(command) => command[0] === 'set_property' && command[1] === 'sid' && command[2] === 11,
@@ -533,143 +482,6 @@ test('preload jellyfin subtitles applies saved delay before selecting japanese s
assert.ok(delayIndex < selectedSidIndex);
});
test('preload jellyfin subtitles auto-aligns late japanese track from english reference', async () => {
const commands: Array<Array<string | number>> = [];
const savedDelays: Array<{ itemId: string; streamIndex: number; delaySeconds: number }> = [];
const primarySrt = `1
00:00:34,935 --> 00:00:36,937
Japanese 1
2
00:00:36,937 --> 00:00:41,441
Japanese 2
3
00:00:41,441 --> 00:00:45,279
Japanese 3
4
00:00:45,279 --> 00:00:48,115
Japanese 4
5
00:00:48,115 --> 00:00:52,286
Japanese 5
6
00:00:52,286 --> 00:00:54,955
Japanese 6
7
00:00:54,955 --> 00:00:59,793
Japanese 7
8
00:00:59,793 --> 00:01:03,630
Japanese 8
9
00:01:03,630 --> 00:01:07,634
Japanese 9
10
00:01:07,634 --> 00:01:13,040
Japanese 10
11
00:01:16,643 --> 00:01:20,814
Japanese 11
12
00:01:20,814 --> 00:01:23,116
Japanese 12
13
00:01:27,988 --> 00:01:30,991
Japanese 13
14
00:01:30,991 --> 00:01:34,094
Japanese 14
15
00:01:34,094 --> 00:01:37,097
Japanese 15
16
00:01:37,097 --> 00:01:39,100
Japanese 16
`;
const referenceAss = `[Events]
Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text
Dialogue: 0,0:00:03.46,0:00:08.73,Default,,0,0,0,,English 1
Dialogue: 0,0:00:09.48,0:00:13.61,Default,,0,0,0,,English 2
Dialogue: 0,0:00:13.61,0:00:19.64,Default,,0,0,0,,English 3
Dialogue: 0,0:00:21.40,0:00:27.32,Default,,0,0,0,,English 4
Dialogue: 0,0:00:28.16,0:00:31.75,Default,,0,0,0,,English 5
Dialogue: 0,0:00:32.06,0:00:34.52,Default,,0,0,0,,English 6
Dialogue: 0,0:00:35.93,0:00:40.57,Default,,0,0,0,,English 7
Dialogue: 0,0:00:45.10,0:00:51.01,Default,,0,0,0,,English 8
Dialogue: 0,0:00:56.57,0:00:59.12,Default,,0,0,0,,English 9
Dialogue: 0,0:00:59.68,0:01:02.44,Default,,0,0,0,,English 10
Dialogue: 0,0:01:02.44,0:01:05.56,Default,,0,0,0,,English 11
Dialogue: 0,0:01:05.56,0:01:06.87,Default,,0,0,0,,English 12
`;
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/jpn.srt' },
{ index: 4, language: 'eng', title: 'English', deliveryUrl: 'https://sub/eng.ass' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 10,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 12,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/4.ass',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.${track.index === 4 ? 'ass' : 'srt'}`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
}),
getSavedSubtitleDelay: () => null,
loadSubtitleSourceText: async (source) =>
source.endsWith('.ass') ? referenceAss : primarySrt,
saveSubtitleDelay: (itemId, streamIndex, delaySeconds) => {
savedDelays.push({ itemId, streamIndex, delaySeconds });
},
}),
);
await preload({ session, clientInfo, itemId: 'item-9' });
const delayCommand = commands.find(
(command) => command[0] === 'set_property' && command[1] === 'sub-delay',
);
assert.ok(delayCommand);
const delaySeconds = delayCommand[2];
if (typeof delaySeconds !== 'number') {
assert.fail('Expected numeric subtitle delay.');
}
assert.ok(delaySeconds > -32);
assert.ok(delaySeconds < -31);
assert.deepEqual(savedDelays, [{ itemId: 'item-9', streamIndex: 0, delaySeconds }]);
});
test('preload jellyfin subtitles accepts numeric string mpv track ids', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
+1 -89
View File
@@ -1,6 +1,3 @@
import { parseSubtitleCues } from '../../core/services/subtitle-cue-parser';
import { estimateSubtitleTimingOffset } from '../../core/services/subtitle-timing-offset';
type JellyfinSession = {
serverUrl: string;
accessToken: string;
@@ -35,11 +32,6 @@ type CachedExternalSubtitleTrack = CachedSubtitleTrack & {
source: JellyfinSubtitleTrack;
};
type JellyfinSubtitleDelayKey = {
itemId: string;
streamIndex: number;
};
type MpvSubtitleTrack = {
id: number;
lang: string;
@@ -257,54 +249,6 @@ async function waitForPreferredSubtitleTracks(
return subtitleTracks;
}
async function estimateSubtitleDelayFromReference(
deps: {
loadSubtitleSourceText?: (source: string) => Promise<string>;
logDebug: (message: string, error: unknown) => void;
},
primaryTrack: CachedExternalSubtitleTrack | null,
referenceTrack: CachedExternalSubtitleTrack | null,
): Promise<number | null> {
if (!deps.loadSubtitleSourceText || !primaryTrack || !referenceTrack) {
return null;
}
try {
const [primaryContent, referenceContent] = await Promise.all([
deps.loadSubtitleSourceText(primaryTrack.path),
deps.loadSubtitleSourceText(referenceTrack.path),
]);
const primaryCues = parseSubtitleCues(primaryContent, primaryTrack.path);
const referenceCues = parseSubtitleCues(referenceContent, referenceTrack.path);
return estimateSubtitleTimingOffset(primaryCues, referenceCues)?.offsetSeconds ?? null;
} catch (error) {
deps.logDebug('Failed to auto-align Jellyfin subtitle timing', error);
return null;
}
}
function saveEstimatedSubtitleDelay(
deps: {
saveSubtitleDelay?: (
itemId: string,
streamIndex: number,
delaySeconds: number,
) => boolean | void;
logDebug: (message: string, error: unknown) => void;
},
key: JellyfinSubtitleDelayKey,
delaySeconds: number,
): void {
try {
const saved = deps.saveSubtitleDelay?.(key.itemId, key.streamIndex, delaySeconds);
if (saved === false) {
deps.logDebug('Failed to save Jellyfin auto subtitle delay', key);
}
} catch (error) {
deps.logDebug('Failed to save Jellyfin auto subtitle delay', error);
}
}
export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
listJellyfinSubtitleTracks: (
session: JellyfinSession,
@@ -316,10 +260,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
wait: (ms: number) => Promise<void>;
cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise<CachedSubtitleTrack>;
cleanupCachedSubtitles: (dirs: string[]) => void;
getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null;
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
logDebug: (message: string, error: unknown) => void;
}): PreloadJellyfinExternalSubtitlesHandler {
@@ -357,6 +297,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
itemId: string;
}): Promise<void> => {
try {
resetManagedSubtitleDelay();
try {
cleanupActiveCache();
} catch (error) {
@@ -369,8 +310,6 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
);
const externalTracks = tracks.filter((track) => Boolean(track.deliveryUrl));
if (externalTracks.length === 0) {
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
return;
}
@@ -427,40 +366,13 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
japanesePrimaryId,
);
if (selectedCachedTrack) {
const delayKey = { itemId: params.itemId, streamIndex: selectedCachedTrack.source.index };
deps.setActiveSubtitleDelayKey?.(delayKey);
const savedDelay = deps.getSavedSubtitleDelay?.(delayKey.itemId, delayKey.streamIndex);
if (typeof savedDelay === 'number' && Number.isFinite(savedDelay)) {
deps.sendMpvCommand(['set_property', 'sub-delay', savedDelay]);
} else {
const referenceCachedTrack = findCachedTrackForMpvTrackId(
resolvedSubtitleTracks,
cachedTracks,
englishSecondaryId,
);
const estimatedDelay = await estimateSubtitleDelayFromReference(
deps,
selectedCachedTrack,
referenceCachedTrack,
);
if (estimatedDelay !== null) {
deps.sendMpvCommand(['set_property', 'sub-delay', estimatedDelay]);
saveEstimatedSubtitleDelay(deps, delayKey, estimatedDelay);
} else {
resetManagedSubtitleDelay();
}
}
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
} else {
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
}
} else {
deps.sendMpvCommand(['set_property', 'sid', 'no']);
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
}
if (englishSecondaryId !== null) {