From a4325740b759f66d874f9e30d120a8185358367c Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 28 Sep 2026 01:17:59 -0700 Subject: [PATCH] fix(subtitle-prefetch): reuse startup runs and prefetch overlap lines - Warm combined text for overlapping subtitle cues. - Preserve valid in-flight tokenization across startup restarts while rejecting invalidated cache results. --- changes/prefetch-startup-restarts.md | 5 + src/core/services/subtitle-prefetch.test.ts | 62 ++++++++++++ src/core/services/subtitle-prefetch.ts | 11 ++- .../subtitle-processing-controller.ts | 3 + src/main.ts | 1 + .../runtime/subtitle-prefetch-init.test.ts | 96 +++++++++++++++++++ src/main/runtime/subtitle-prefetch-init.ts | 40 +++++++- .../subtitle-prefetch-overlaps.test.ts | 24 +++++ .../runtime/subtitle-prefetch-overlaps.ts | 38 ++++++++ 9 files changed, 274 insertions(+), 6 deletions(-) create mode 100644 changes/prefetch-startup-restarts.md create mode 100644 src/main/runtime/subtitle-prefetch-overlaps.test.ts create mode 100644 src/main/runtime/subtitle-prefetch-overlaps.ts diff --git a/changes/prefetch-startup-restarts.md b/changes/prefetch-startup-restarts.md new file mode 100644 index 00000000..dc8c5d5c --- /dev/null +++ b/changes/prefetch-startup-restarts.md @@ -0,0 +1,5 @@ +type: fixed +area: annotations + +- Subtitle lines shown while two cues overlap (common in Jimaku and Jellyfin SRTs, and usually the long lines) are now prefetched, so their annotations appear immediately instead of after a delay. +- Subtitle prefetching no longer restarts each time the same subtitle file is re-selected during startup, and a line tokenized before a restart or seek is kept instead of discarded. diff --git a/src/core/services/subtitle-prefetch.test.ts b/src/core/services/subtitle-prefetch.test.ts index ad2c9f8c..c428f07d 100644 --- a/src/core/services/subtitle-prefetch.test.ts +++ b/src/core/services/subtitle-prefetch.test.ts @@ -300,3 +300,65 @@ test('prefetch service deduplicates repeated cue text within a run', async () => ); assert.ok(tokenizedTexts.includes('other')); }); + +test('prefetch service keeps an in-flight result from a superseded run', async () => { + const cues = makeCues(5); + const cachedTexts: string[] = []; + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + if (text === 'line-0') await firstGate; + return { text, tokens: [] }; + }, + preCacheTokenization: (text) => { + cachedTexts.push(text); + }, + getCacheGeneration: () => 0, + priorityWindowSize: 3, + }); + + service.start(0); + await flushMicrotasks(); + service.stop(); + releaseFirst(); + await flushMicrotasks(); + + assert.deepEqual(cachedTexts, ['line-0']); +}); + +test('prefetch service drops an in-flight result after cache invalidation', async () => { + const cues = makeCues(5); + const cachedTexts: string[] = []; + let generation = 0; + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => { + releaseFirst = resolve; + }); + + const service = createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: async (text) => { + if (text === 'line-0') await firstGate; + return { text, tokens: [] }; + }, + preCacheTokenization: (text) => { + cachedTexts.push(text); + }, + getCacheGeneration: () => generation, + priorityWindowSize: 3, + }); + + service.start(0); + await flushMicrotasks(); + service.stop(); + generation += 1; + releaseFirst(); + await flushMicrotasks(); + + assert.deepEqual(cachedTexts, []); +}); diff --git a/src/core/services/subtitle-prefetch.ts b/src/core/services/subtitle-prefetch.ts index fd169f87..a532a9ba 100644 --- a/src/core/services/subtitle-prefetch.ts +++ b/src/core/services/subtitle-prefetch.ts @@ -7,6 +7,12 @@ export interface SubtitlePrefetchServiceDeps { tokenizeSubtitle: (text: string) => Promise; preCacheTokenization: (text: string, data: SubtitleData) => void; hasCachedTokenization?: (text: string) => boolean; + /** + * Bumped whenever the tokenization cache is invalidated. A result is cached only if + * the generation is unchanged since its tokenization began; without this dep every + * result counts as fresh. + */ + getCacheGeneration?: () => number; priorityWindowSize?: number; } @@ -88,9 +94,12 @@ export function createSubtitlePrefetchService( } warmedKeys.add(cacheKey); + // Freshness follows the cache generation, not the run: a seek or restart that + // supersedes this run leaves the result valid, and the parser time is already spent. + const generation = deps.getCacheGeneration?.(); try { const result = await deps.tokenizeSubtitle(cue.text); - if (result && !stopped && runId === currentRunId) { + if (result && generation === deps.getCacheGeneration?.()) { deps.preCacheTokenization(cue.text, result); } } catch { diff --git a/src/core/services/subtitle-processing-controller.ts b/src/core/services/subtitle-processing-controller.ts index d03d8c88..27710146 100644 --- a/src/core/services/subtitle-processing-controller.ts +++ b/src/core/services/subtitle-processing-controller.ts @@ -43,6 +43,8 @@ export interface SubtitleProcessingController { */ notePlainSubtitleEmitted: (text: string) => void; invalidateTokenizationCache: () => void; + /** Incremented by invalidateTokenizationCache; lets async writers detect stale results. */ + getCacheGeneration: () => number; preCacheTokenization: (text: string, data: SubtitleData) => void; consumeCachedSubtitle: (text: string) => SubtitleData | null; hasCachedSubtitle: (text: string) => boolean; @@ -253,6 +255,7 @@ export function createSubtitleProcessingController( tokenizationCache.clear(); cacheGeneration += 1; }, + getCacheGeneration: () => cacheGeneration, preCacheTokenization: (text: string, data: SubtitleData) => { setCachedTokenization(text, data); }, diff --git a/src/main.ts b/src/main.ts index 849e66cf..85902098 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2152,6 +2152,7 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ subtitleProcessingController.preCacheTokenization(text, data); }, hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text), + getCacheGeneration: () => subtitleProcessingController.getCacheGeneration(), logInfo: (message) => logger.info(message), logWarn: (message) => logger.warn(message), onParsedSubtitleCuesChanged: (cues, sourceKey) => { diff --git a/src/main/runtime/subtitle-prefetch-init.test.ts b/src/main/runtime/subtitle-prefetch-init.test.ts index 1754fca6..640657cd 100644 --- a/src/main/runtime/subtitle-prefetch-init.test.ts +++ b/src/main/runtime/subtitle-prefetch-init.test.ts @@ -251,3 +251,99 @@ test('subtitle prefetch init logs a warning when the source parses to zero cues' assert.equal(warnings.length, 1); assert.match(warnings[0]!, /\[subtitle-prefetch\].*0 cues.*\/tmp\/broken\.ass/); }); + +function createTrackingController(loadContent: () => string) { + let currentService: SubtitlePrefetchService | null = null; + const events: string[] = []; + const cueUpdates: Array = []; + let serviceCount = 0; + + const controller = createSubtitlePrefetchInitController({ + getCurrentService: () => currentService, + setCurrentService: (service) => { + currentService = service; + }, + loadSubtitleSourceText: async () => loadContent(), + parseSubtitleCues: (content): SubtitleCue[] => [{ startTime: 0, endTime: 1, text: content }], + createSubtitlePrefetchService: () => { + const id = ++serviceCount; + return { + start: () => events.push(`start:${id}`), + stop: () => events.push(`stop:${id}`), + onSeek: () => {}, + pause: () => {}, + resume: () => {}, + }; + }, + tokenizeSubtitle: async () => null, + preCacheTokenization: () => {}, + logInfo: () => {}, + logWarn: () => {}, + onParsedSubtitleCuesChanged: (cues, source) => { + cueUpdates.push(cues ? `${source}:${cues[0]!.text}` : null); + }, + }); + + return { controller, events, cueUpdates }; +} + +test('re-initializing an unchanged source keeps the running service and republishes cues', async () => { + const { controller, events, cueUpdates } = createTrackingController(() => 'content'); + + await controller.initSubtitlePrefetch('track-1.srt', 0); + await controller.initSubtitlePrefetch('track-1.srt', 3); + + assert.deepEqual(events, ['start:1']); + assert.deepEqual(cueUpdates, ['track-1.srt:content', 'track-1.srt:content']); +}); + +test('re-initializing a source whose content changed restarts the service', async () => { + let content = 'first'; + const { controller, events } = createTrackingController(() => content); + + await controller.initSubtitlePrefetch('track-1.srt', 0); + content = 'second'; + await controller.initSubtitlePrefetch('track-1.srt', 0); + + assert.deepEqual(events, ['start:1', 'stop:1', 'start:2']); +}); + +test('subtitle prefetch init warms overlap lines without publishing them as cues', async () => { + let prefetchedTexts: string[] = []; + let publishedTexts: string[] = []; + let currentService: SubtitlePrefetchService | null = null; + + const controller = createSubtitlePrefetchInitController({ + getCurrentService: () => currentService, + setCurrentService: (service) => { + currentService = service; + }, + loadSubtitleSourceText: async () => 'content', + parseSubtitleCues: (): SubtitleCue[] => [ + { startTime: 0, endTime: 4, text: 'first' }, + { startTime: 2, endTime: 6, text: 'second' }, + ], + createSubtitlePrefetchService: ({ cues }) => { + prefetchedTexts = cues.map((cue) => cue.text); + return { + start: () => {}, + stop: () => {}, + onSeek: () => {}, + pause: () => {}, + resume: () => {}, + }; + }, + tokenizeSubtitle: async () => null, + preCacheTokenization: () => {}, + logInfo: () => {}, + logWarn: () => {}, + onParsedSubtitleCuesChanged: (cues) => { + publishedTexts = cues?.map((cue) => cue.text) ?? []; + }, + }); + + await controller.initSubtitlePrefetch('track.srt', 0); + + assert.deepEqual(prefetchedTexts, ['first', 'second', 'first\n\nsecond']); + assert.deepEqual(publishedTexts, ['first', 'second']); +}); diff --git a/src/main/runtime/subtitle-prefetch-init.ts b/src/main/runtime/subtitle-prefetch-init.ts index aa605a0d..fc14e662 100644 --- a/src/main/runtime/subtitle-prefetch-init.ts +++ b/src/main/runtime/subtitle-prefetch-init.ts @@ -4,6 +4,7 @@ import type { } from '../../core/services/subtitle-prefetch'; import type { SubtitleData } from '../../types'; import type { SubtitleCue } from '../../types'; +import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps'; export interface SubtitlePrefetchInitControllerDeps { getCurrentService: () => SubtitlePrefetchService | null; @@ -14,6 +15,7 @@ export interface SubtitlePrefetchInitControllerDeps { tokenizeSubtitle: (text: string) => Promise; preCacheTokenization: (text: string, data: SubtitleData) => void; hasCachedTokenization?: (text: string) => boolean; + getCacheGeneration?: () => number; logInfo: (message: string) => void; logWarn: (message: string) => void; onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void; @@ -32,11 +34,20 @@ export function createSubtitlePrefetchInitController( deps: SubtitlePrefetchInitControllerDeps, ): SubtitlePrefetchInitController { let initRevision = 0; + // What the running service was built from. Startup fires several inits for one source + // (Jellyfin preload seed, sid change, each track-list update); restarting on each would + // discard the in-flight tokenization and re-queue the priority window behind it. + let activeSource: { key: string; content: string; cues: SubtitleCue[] } | null = null; + + const stopCurrentService = (): void => { + deps.getCurrentService()?.stop(); + deps.setCurrentService(null); + activeSource = null; + }; const cancelPendingInit = (): void => { initRevision += 1; - deps.getCurrentService()?.stop(); - deps.setCurrentService(null); + stopCurrentService(); deps.onParsedSubtitleCuesChanged?.(null, null); }; @@ -46,14 +57,26 @@ export function createSubtitlePrefetchInitController( sourceKey = sourcePath, ): Promise => { const revision = ++initRevision; - deps.getCurrentService()?.stop(); - deps.setCurrentService(null); + // The same source may still be current once loaded; keep its service running until then. + if (activeSource?.key !== sourceKey) { + stopCurrentService(); + } try { const content = await deps.loadSubtitleSourceText(sourcePath); if (revision !== initRevision) { return; } + if ( + activeSource?.key === sourceKey && + activeSource.content === content && + deps.getCurrentService() + ) { + // A track change clears the published cues synchronously, so republish them. + deps.onParsedSubtitleCuesChanged?.(activeSource.cues, sourceKey); + return; + } + stopCurrentService(); const cues = deps.parseSubtitleCues(content, sourcePath); if (revision !== initRevision || cues.length === 0) { @@ -66,11 +89,16 @@ export function createSubtitlePrefetchInitController( return; } + // Overlap cues only feed the cache; the published cue list stays as authored. + const prefetchCues = [...cues, ...buildOverlapPrefetchCues(cues)].sort( + (a, b) => a.startTime - b.startTime, + ); const nextService = deps.createSubtitlePrefetchService({ - cues, + cues: prefetchCues, tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text), preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data), hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false, + getCacheGeneration: deps.getCacheGeneration, }); if (revision !== initRevision) { @@ -78,6 +106,7 @@ export function createSubtitlePrefetchInitController( } deps.setCurrentService(nextService); + activeSource = { key: sourceKey, content, cues }; deps.onParsedSubtitleCuesChanged?.(cues, sourceKey); nextService.start(currentTimePos); deps.logInfo( @@ -85,6 +114,7 @@ export function createSubtitlePrefetchInitController( ); } catch (error) { if (revision === initRevision) { + stopCurrentService(); deps.onParsedSubtitleCuesChanged?.(null, null); deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`); } diff --git a/src/main/runtime/subtitle-prefetch-overlaps.test.ts b/src/main/runtime/subtitle-prefetch-overlaps.test.ts new file mode 100644 index 00000000..d992df86 --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-overlaps.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { SubtitleCue } from '../../types'; +import { buildOverlapPrefetchCues } from './subtitle-prefetch-overlaps'; + +test('overlapping cues yield the combined line the live path resolves', () => { + const cues: SubtitleCue[] = [ + { startTime: 0, endTime: 4, text: 'このまま頑張ったって―' }, + { startTime: 2, endTime: 6, text: 'ちょっとカズマ\n聞こえてんの?' }, + ]; + + assert.deepEqual(buildOverlapPrefetchCues(cues), [ + { startTime: 2, endTime: 4, text: 'このまま頑張ったって―\n\nちょっとカズマ\n聞こえてんの?' }, + ]); +}); + +test('back-to-back cues yield no overlap lines', () => { + const cues: SubtitleCue[] = [ + { startTime: 0, endTime: 2, text: 'first' }, + { startTime: 2, endTime: 4, text: 'second' }, + ]; + + assert.deepEqual(buildOverlapPrefetchCues(cues), []); +}); diff --git a/src/main/runtime/subtitle-prefetch-overlaps.ts b/src/main/runtime/subtitle-prefetch-overlaps.ts new file mode 100644 index 00000000..a6254dca --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-overlaps.ts @@ -0,0 +1,38 @@ +import type { SubtitleCue } from '../../types'; +import { resolvePrimarySubtitleText } from './primary-subtitle-text'; + +/** + * While cues overlap, mpv publishes their texts joined as one `sub-text`, and the live + * path tokenizes and caches that combined line as its own entry. Prefetching single cues + * never warms it, so overlapping lines (usually the long ones) always miss the cache. + * + * Returns one synthetic cue per overlap span carrying the text the live path resolves + * for it, so the prefetcher can warm those lines in timeline order with the rest. + */ +export function buildOverlapPrefetchCues(cues: readonly SubtitleCue[]): SubtitleCue[] { + const boundaries = [...new Set(cues.flatMap((cue) => [cue.startTime, cue.endTime]))].sort( + (a, b) => a - b, + ); + const singleTexts = new Set(cues.map((cue) => cue.text)); + const seen = new Set(); + const overlapCues: SubtitleCue[] = []; + + for (let i = 0; i + 1 < boundaries.length; i += 1) { + const startTime = boundaries[i]!; + const endTime = boundaries[i + 1]!; + const midpoint = (startTime + endTime) / 2; + const active = cues.filter((cue) => cue.startTime <= midpoint && cue.endTime > midpoint); + if (active.length < 2) continue; + + const text = resolvePrimarySubtitleText({ + liveText: active.map((cue) => cue.text).join('\n'), + currentTimeSec: midpoint, + cues, + }); + if (!text.trim() || singleTexts.has(text) || seen.has(text)) continue; + seen.add(text); + overlapCues.push({ startTime, endTime, text }); + } + + return overlapCues; +}