fix(jellyfin): stop inferring subtitle delays (#227)

This commit is contained in:
2026-08-31 23:31:31 -07:00
committed by GitHub
parent 2ad491e95c
commit a20269e9f5
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;
}