diff --git a/src/core/services/subtitle-prefetch.test.ts b/src/core/services/subtitle-prefetch.test.ts index bb76bb1..c27191f 100644 --- a/src/core/services/subtitle-prefetch.test.ts +++ b/src/core/services/subtitle-prefetch.test.ts @@ -20,7 +20,7 @@ test('computePriorityWindow returns next N cues from current position', () => { const window = computePriorityWindow(cues, 12.0, 5); assert.equal(window.length, 5); - // Position 12.0 is during cue index 2 (start=10, end=14). Priority window starts from index 3. + // Position 12.0 falls during cue 2, so the window starts at cue 3 (startTime >= 12.0). assert.equal(window[0]!.text, 'line-3'); assert.equal(window[4]!.text, 'line-7'); }); diff --git a/src/main.ts b/src/main.ts index 49a0229..b1c437f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -425,6 +425,7 @@ import { getActiveExternalSubtitleSource, resolveSubtitleSourcePath, } from './main/runtime/subtitle-prefetch-source'; +import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init'; if (process.platform === 'linux') { app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal'); @@ -1092,36 +1093,23 @@ function clearScheduledSubtitlePrefetchRefresh(): void { } } -async function initSubtitlePrefetch( - externalFilename: string, - currentTimePos: number, -): Promise { - subtitlePrefetchService?.stop(); - subtitlePrefetchService = null; - - try { - const content = await loadSubtitleSourceText(externalFilename); - const cues = parseSubtitleCues(content, externalFilename); - if (cues.length === 0) { - return; - } - - subtitlePrefetchService = createSubtitlePrefetchService({ - cues, - tokenizeSubtitle: async (text) => - tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, - preCacheTokenization: (text, data) => { - subtitleProcessingController.preCacheTokenization(text, data); - }, - isCacheFull: () => subtitleProcessingController.isCacheFull(), - }); - - subtitlePrefetchService.start(currentTimePos); - logger.info(`[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`); - } catch (error) { - logger.warn('[subtitle-prefetch] failed to initialize:', (error as Error).message); - } -} +const subtitlePrefetchInitController = createSubtitlePrefetchInitController({ + getCurrentService: () => subtitlePrefetchService, + setCurrentService: (service) => { + subtitlePrefetchService = service; + }, + loadSubtitleSourceText, + parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename), + createSubtitlePrefetchService: (deps) => createSubtitlePrefetchService(deps), + tokenizeSubtitle: async (text) => + tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null, + preCacheTokenization: (text, data) => { + subtitleProcessingController.preCacheTokenization(text, data); + }, + isCacheFull: () => subtitleProcessingController.isCacheFull(), + logInfo: (message) => logger.info(message), + logWarn: (message) => logger.warn(message), +}); async function refreshSubtitlePrefetchFromActiveTrack(): Promise { const client = appState.mpvClient; @@ -1136,11 +1124,10 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise { ]); const externalFilename = getActiveExternalSubtitleSource(trackListRaw, sidRaw); if (!externalFilename) { - subtitlePrefetchService?.stop(); - subtitlePrefetchService = null; + subtitlePrefetchInitController.cancelPendingInit(); return; } - await initSubtitlePrefetch(externalFilename, lastObservedTimePos); + await subtitlePrefetchInitController.initSubtitlePrefetch(externalFilename, lastObservedTimePos); } catch { // Track list query failed; skip subtitle prefetch refresh. } @@ -2940,8 +2927,7 @@ const { currentMediaTokenizationGate.updateCurrentMediaPath(path); startupOsdSequencer.reset(); clearScheduledSubtitlePrefetchRefresh(); - subtitlePrefetchService?.stop(); - subtitlePrefetchService = null; + subtitlePrefetchInitController.cancelPendingInit(); if (path) { ensureImmersionTrackerStarted(); // Delay slightly to allow MPV's track-list to be populated. diff --git a/src/main/runtime/subtitle-prefetch-init.test.ts b/src/main/runtime/subtitle-prefetch-init.test.ts new file mode 100644 index 0000000..e076d1c --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-init.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import type { SubtitleCue } from '../../core/services/subtitle-cue-parser'; +import type { SubtitlePrefetchService } from '../../core/services/subtitle-prefetch'; +import { createSubtitlePrefetchInitController } from './subtitle-prefetch-init'; + +function createDeferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function flushMicrotasks(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +test('latest subtitle prefetch init wins over stale async loads', async () => { + const loads = new Map>>(); + const started: string[] = []; + const stopped: string[] = []; + let currentService: SubtitlePrefetchService | null = null; + + const controller = createSubtitlePrefetchInitController({ + getCurrentService: () => currentService, + setCurrentService: (service) => { + currentService = service; + }, + loadSubtitleSourceText: async (source) => { + const deferred = createDeferred(); + loads.set(source, deferred); + return await deferred.promise; + }, + parseSubtitleCues: (_content, filename): SubtitleCue[] => [ + { startTime: 0, endTime: 1, text: filename }, + ], + createSubtitlePrefetchService: ({ cues }) => ({ + start: () => { + started.push(cues[0]!.text); + }, + stop: () => { + stopped.push(cues[0]!.text); + }, + onSeek: () => {}, + pause: () => {}, + resume: () => {}, + }), + tokenizeSubtitle: async () => null, + preCacheTokenization: () => {}, + isCacheFull: () => false, + logInfo: () => {}, + logWarn: () => {}, + }); + + const firstInit = controller.initSubtitlePrefetch('old.ass', 1); + const secondInit = controller.initSubtitlePrefetch('new.ass', 2); + + loads.get('new.ass')!.resolve('new'); + await flushMicrotasks(); + + assert.deepEqual(started, ['new.ass']); + + loads.get('old.ass')!.resolve('old'); + await Promise.all([firstInit, secondInit]); + + assert.deepEqual(started, ['new.ass']); + assert.deepEqual(stopped, []); +}); + +test('cancelPendingInit prevents an in-flight load from attaching a stale service', async () => { + const deferred = createDeferred(); + let currentService: SubtitlePrefetchService | null = null; + const started: string[] = []; + + const controller = createSubtitlePrefetchInitController({ + getCurrentService: () => currentService, + setCurrentService: (service) => { + currentService = service; + }, + loadSubtitleSourceText: async () => await deferred.promise, + parseSubtitleCues: (_content, filename): SubtitleCue[] => [ + { startTime: 0, endTime: 1, text: filename }, + ], + createSubtitlePrefetchService: ({ cues }) => ({ + start: () => { + started.push(cues[0]!.text); + }, + stop: () => {}, + onSeek: () => {}, + pause: () => {}, + resume: () => {}, + }), + tokenizeSubtitle: async () => null, + preCacheTokenization: () => {}, + isCacheFull: () => false, + logInfo: () => {}, + logWarn: () => {}, + }); + + const initPromise = controller.initSubtitlePrefetch('stale.ass', 1); + controller.cancelPendingInit(); + deferred.resolve('stale'); + await initPromise; + + assert.equal(currentService, null); + assert.deepEqual(started, []); +}); diff --git a/src/main/runtime/subtitle-prefetch-init.ts b/src/main/runtime/subtitle-prefetch-init.ts new file mode 100644 index 0000000..5d11b30 --- /dev/null +++ b/src/main/runtime/subtitle-prefetch-init.ts @@ -0,0 +1,83 @@ +import type { SubtitleCue } from '../../core/services/subtitle-cue-parser'; +import type { + SubtitlePrefetchService, + SubtitlePrefetchServiceDeps, +} from '../../core/services/subtitle-prefetch'; +import type { SubtitleData } from '../../types'; + +export interface SubtitlePrefetchInitControllerDeps { + getCurrentService: () => SubtitlePrefetchService | null; + setCurrentService: (service: SubtitlePrefetchService | null) => void; + loadSubtitleSourceText: (source: string) => Promise; + parseSubtitleCues: (content: string, filename: string) => SubtitleCue[]; + createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService; + tokenizeSubtitle: (text: string) => Promise; + preCacheTokenization: (text: string, data: SubtitleData) => void; + isCacheFull: () => boolean; + logInfo: (message: string) => void; + logWarn: (message: string) => void; +} + +export interface SubtitlePrefetchInitController { + cancelPendingInit: () => void; + initSubtitlePrefetch: (externalFilename: string, currentTimePos: number) => Promise; +} + +export function createSubtitlePrefetchInitController( + deps: SubtitlePrefetchInitControllerDeps, +): SubtitlePrefetchInitController { + let initRevision = 0; + + const cancelPendingInit = (): void => { + initRevision += 1; + deps.getCurrentService()?.stop(); + deps.setCurrentService(null); + }; + + const initSubtitlePrefetch = async ( + externalFilename: string, + currentTimePos: number, + ): Promise => { + const revision = ++initRevision; + deps.getCurrentService()?.stop(); + deps.setCurrentService(null); + + try { + const content = await deps.loadSubtitleSourceText(externalFilename); + if (revision !== initRevision) { + return; + } + + const cues = deps.parseSubtitleCues(content, externalFilename); + if (revision !== initRevision || cues.length === 0) { + return; + } + + const nextService = deps.createSubtitlePrefetchService({ + cues, + tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text), + preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data), + isCacheFull: () => deps.isCacheFull(), + }); + + if (revision !== initRevision) { + return; + } + + deps.setCurrentService(nextService); + nextService.start(currentTimePos); + deps.logInfo( + `[subtitle-prefetch] started prefetching ${cues.length} cues from ${externalFilename}`, + ); + } catch (error) { + if (revision === initRevision) { + deps.logWarn(`[subtitle-prefetch] failed to initialize: ${(error as Error).message}`); + } + } + }; + + return { + cancelPendingInit, + initSubtitlePrefetch, + }; +} diff --git a/src/main/runtime/subtitle-prefetch-source.test.ts b/src/main/runtime/subtitle-prefetch-source.test.ts index fa00137..9704c47 100644 --- a/src/main/runtime/subtitle-prefetch-source.test.ts +++ b/src/main/runtime/subtitle-prefetch-source.test.ts @@ -39,3 +39,9 @@ test('resolveSubtitleSourcePath converts file URLs with spaces into filesystem p test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => { assert.equal(resolveSubtitleSourcePath('/tmp/subs.ass'), '/tmp/subs.ass'); }); + +test('resolveSubtitleSourcePath returns the original source for malformed file URLs', () => { + const source = 'file://invalid[path'; + + assert.equal(resolveSubtitleSourcePath(source), source); +}); diff --git a/src/main/runtime/subtitle-prefetch-source.ts b/src/main/runtime/subtitle-prefetch-source.ts index a7d74c1..b740ff6 100644 --- a/src/main/runtime/subtitle-prefetch-source.ts +++ b/src/main/runtime/subtitle-prefetch-source.ts @@ -30,5 +30,13 @@ export function getActiveExternalSubtitleSource( } export function resolveSubtitleSourcePath(source: string): string { - return source.startsWith('file://') ? fileURLToPath(new URL(source)) : source; + if (!source.startsWith('file://')) { + return source; + } + + try { + return fileURLToPath(new URL(source)); + } catch { + return source; + } }