diff --git a/changes/jellyfin-zero-subtitle-delay.md b/changes/jellyfin-zero-subtitle-delay.md new file mode 100644 index 00000000..92ae32ff --- /dev/null +++ b/changes/jellyfin-zero-subtitle-delay.md @@ -0,0 +1,4 @@ +type: fixed +area: jellyfin + +- Jellyfin subtitle files now load with zero mpv delay instead of inferring and saving an offset from Japanese and English cue timelines. diff --git a/docs-site/jellyfin-integration.md b/docs-site/jellyfin-integration.md index 064cdc18..a07197a2 100644 --- a/docs-site/jellyfin-integration.md +++ b/docs-site/jellyfin-integration.md @@ -54,7 +54,7 @@ From then on, pause / resume / seek / stop and audio or subtitle track changes y - **Resume works.** If Jellyfin has a saved position for the item, SubMiner seeks there on load. - **Direct play first.** When the source allows it and the container is in your direct-play allowlist, SubMiner streams the original file; otherwise it requests a transcoded stream from Jellyfin. - **Japanese subtitles are auto-selected,** preferring Jellyfin's default and embedded tracks over external sidecar files when several match. -- **Subtitle timing is corrected when possible.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages downloaded subtitle tracks without letting mpv auto-switch between tracks, then selects the Japanese track once after applying any saved or inferred timing delay. When Jellyfin provides both Japanese and English subtitle files, SubMiner compares their cue timelines and applies a global delay if one track is clearly offset. Manual delay shifts you make with SubMiner's adjacent-cue controls are saved per item and subtitle track, then restored the next time you select that track. +- **Downloaded subtitles keep their original timing.** SubMiner removes Jellyfin's server-selected subtitle stream from the mpv load URL, suppresses the mpv plugin's one-shot subtitle auto-selection and overlay auto-start for managed Jellyfin loads, stages the subtitle files exposed by Jellyfin without letting mpv auto-switch between tracks, resets mpv's subtitle delay to zero, then selects the Japanese track. SubMiner does not compare Japanese and English cue timelines or save an inferred delay. ## Settings diff --git a/src/core/services/index.ts b/src/core/services/index.ts index b348c2ce..503493ba 100644 --- a/src/core/services/index.ts +++ b/src/core/services/index.ts @@ -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, diff --git a/src/core/services/jellyfin-subtitle-delay.test.ts b/src/core/services/jellyfin-subtitle-delay.test.ts deleted file mode 100644 index 6f844d49..00000000 --- a/src/core/services/jellyfin-subtitle-delay.test.ts +++ /dev/null @@ -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, - ); -}); diff --git a/src/core/services/jellyfin-subtitle-delay.ts b/src/core/services/jellyfin-subtitle-delay.ts deleted file mode 100644 index 18bf8b3b..00000000 --- a/src/core/services/jellyfin-subtitle-delay.ts +++ /dev/null @@ -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 { - 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 = {}; - for (const [key, value] of Object.entries(parsed.delays as Record)) { - 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; - } -} diff --git a/src/core/services/subtitle-timing-offset.test.ts b/src/core/services/subtitle-timing-offset.test.ts deleted file mode 100644 index 15cad6e6..00000000 --- a/src/core/services/subtitle-timing-offset.test.ts +++ /dev/null @@ -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); -}); diff --git a/src/core/services/subtitle-timing-offset.ts b/src/core/services/subtitle-timing-offset.ts deleted file mode 100644 index e52ec79d..00000000 --- a/src/core/services/subtitle-timing-offset.ts +++ /dev/null @@ -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(); - 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; -} diff --git a/src/main.ts b/src/main.ts index c6929e85..80dc1d77 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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 { } let activeJellyfinRemotePlayback: ActiveJellyfinRemotePlaybackState | null = null; -let activeJellyfinSubtitleDelayKey: { itemId: string; streamIndex: number } | null = null; let jellyfinRemoteLastProgressAtMs = 0; let jellyfinMpvAutoLaunchInFlight: Promise | null = null; let backgroundWarmupsStarted = false; @@ -2482,7 +2479,6 @@ const fieldGroupingOverlayRuntime = createFieldGroupingOverlayRuntime new Promise((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); diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts index bb79a4de..f6f4d1b5 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.test.ts @@ -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']); }); diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts index f5ca73a7..b00f08ff 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts @@ -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, diff --git a/src/main/runtime/jellyfin-subtitle-preload.test.ts b/src/main/runtime/jellyfin-subtitle-preload.test.ts index 8477f174..86a405fb 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.test.ts @@ -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; - 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> = []; - const activeDelayKeys: Array = []; 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> = []; - 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> = []; 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> = []; - 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> = []; const preload = createPreloadJellyfinExternalSubtitlesHandler( diff --git a/src/main/runtime/jellyfin-subtitle-preload.ts b/src/main/runtime/jellyfin-subtitle-preload.ts index 5843075f..dcd12518 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.ts @@ -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; - logDebug: (message: string, error: unknown) => void; - }, - primaryTrack: CachedExternalSubtitleTrack | null, - referenceTrack: CachedExternalSubtitleTrack | null, -): Promise { - 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; cacheSubtitleTrack: (track: JellyfinSubtitleTrack) => Promise; cleanupCachedSubtitles: (dirs: string[]) => void; - getSavedSubtitleDelay?: (itemId: string, streamIndex: number) => number | null; - setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void; - loadSubtitleSourceText?: (source: string) => Promise; - saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void; initSubtitlePrefetch?: (sourcePath: string) => void | Promise; logDebug: (message: string, error: unknown) => void; }): PreloadJellyfinExternalSubtitlesHandler { @@ -357,6 +297,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { itemId: string; }): Promise => { 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) {