mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
fix: guard subtitle prefetch init races
This commit is contained in:
@@ -20,7 +20,7 @@ test('computePriorityWindow returns next N cues from current position', () => {
|
|||||||
const window = computePriorityWindow(cues, 12.0, 5);
|
const window = computePriorityWindow(cues, 12.0, 5);
|
||||||
|
|
||||||
assert.equal(window.length, 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[0]!.text, 'line-3');
|
||||||
assert.equal(window[4]!.text, 'line-7');
|
assert.equal(window[4]!.text, 'line-7');
|
||||||
});
|
});
|
||||||
|
|||||||
56
src/main.ts
56
src/main.ts
@@ -425,6 +425,7 @@ import {
|
|||||||
getActiveExternalSubtitleSource,
|
getActiveExternalSubtitleSource,
|
||||||
resolveSubtitleSourcePath,
|
resolveSubtitleSourcePath,
|
||||||
} from './main/runtime/subtitle-prefetch-source';
|
} from './main/runtime/subtitle-prefetch-source';
|
||||||
|
import { createSubtitlePrefetchInitController } from './main/runtime/subtitle-prefetch-init';
|
||||||
|
|
||||||
if (process.platform === 'linux') {
|
if (process.platform === 'linux') {
|
||||||
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
app.commandLine.appendSwitch('enable-features', 'GlobalShortcutsPortal');
|
||||||
@@ -1092,36 +1093,23 @@ function clearScheduledSubtitlePrefetchRefresh(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initSubtitlePrefetch(
|
const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
|
||||||
externalFilename: string,
|
getCurrentService: () => subtitlePrefetchService,
|
||||||
currentTimePos: number,
|
setCurrentService: (service) => {
|
||||||
): Promise<void> {
|
subtitlePrefetchService = service;
|
||||||
subtitlePrefetchService?.stop();
|
},
|
||||||
subtitlePrefetchService = null;
|
loadSubtitleSourceText,
|
||||||
|
parseSubtitleCues: (content, filename) => parseSubtitleCues(content, filename),
|
||||||
try {
|
createSubtitlePrefetchService: (deps) => createSubtitlePrefetchService(deps),
|
||||||
const content = await loadSubtitleSourceText(externalFilename);
|
tokenizeSubtitle: async (text) =>
|
||||||
const cues = parseSubtitleCues(content, externalFilename);
|
tokenizeSubtitleDeferred ? await tokenizeSubtitleDeferred(text) : null,
|
||||||
if (cues.length === 0) {
|
preCacheTokenization: (text, data) => {
|
||||||
return;
|
subtitleProcessingController.preCacheTokenization(text, data);
|
||||||
}
|
},
|
||||||
|
isCacheFull: () => subtitleProcessingController.isCacheFull(),
|
||||||
subtitlePrefetchService = createSubtitlePrefetchService({
|
logInfo: (message) => logger.info(message),
|
||||||
cues,
|
logWarn: (message) => logger.warn(message),
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
||||||
const client = appState.mpvClient;
|
const client = appState.mpvClient;
|
||||||
@@ -1136,11 +1124,10 @@ async function refreshSubtitlePrefetchFromActiveTrack(): Promise<void> {
|
|||||||
]);
|
]);
|
||||||
const externalFilename = getActiveExternalSubtitleSource(trackListRaw, sidRaw);
|
const externalFilename = getActiveExternalSubtitleSource(trackListRaw, sidRaw);
|
||||||
if (!externalFilename) {
|
if (!externalFilename) {
|
||||||
subtitlePrefetchService?.stop();
|
subtitlePrefetchInitController.cancelPendingInit();
|
||||||
subtitlePrefetchService = null;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await initSubtitlePrefetch(externalFilename, lastObservedTimePos);
|
await subtitlePrefetchInitController.initSubtitlePrefetch(externalFilename, lastObservedTimePos);
|
||||||
} catch {
|
} catch {
|
||||||
// Track list query failed; skip subtitle prefetch refresh.
|
// Track list query failed; skip subtitle prefetch refresh.
|
||||||
}
|
}
|
||||||
@@ -2940,8 +2927,7 @@ const {
|
|||||||
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
currentMediaTokenizationGate.updateCurrentMediaPath(path);
|
||||||
startupOsdSequencer.reset();
|
startupOsdSequencer.reset();
|
||||||
clearScheduledSubtitlePrefetchRefresh();
|
clearScheduledSubtitlePrefetchRefresh();
|
||||||
subtitlePrefetchService?.stop();
|
subtitlePrefetchInitController.cancelPendingInit();
|
||||||
subtitlePrefetchService = null;
|
|
||||||
if (path) {
|
if (path) {
|
||||||
ensureImmersionTrackerStarted();
|
ensureImmersionTrackerStarted();
|
||||||
// Delay slightly to allow MPV's track-list to be populated.
|
// Delay slightly to allow MPV's track-list to be populated.
|
||||||
|
|||||||
114
src/main/runtime/subtitle-prefetch-init.test.ts
Normal file
114
src/main/runtime/subtitle-prefetch-init.test.ts
Normal file
@@ -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<T>(): {
|
||||||
|
promise: Promise<T>;
|
||||||
|
resolve: (value: T) => void;
|
||||||
|
reject: (error: unknown) => void;
|
||||||
|
} {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (error: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
function flushMicrotasks(): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('latest subtitle prefetch init wins over stale async loads', async () => {
|
||||||
|
const loads = new Map<string, ReturnType<typeof createDeferred<string>>>();
|
||||||
|
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<string>();
|
||||||
|
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<string>();
|
||||||
|
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, []);
|
||||||
|
});
|
||||||
83
src/main/runtime/subtitle-prefetch-init.ts
Normal file
83
src/main/runtime/subtitle-prefetch-init.ts
Normal file
@@ -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<string>;
|
||||||
|
parseSubtitleCues: (content: string, filename: string) => SubtitleCue[];
|
||||||
|
createSubtitlePrefetchService: (deps: SubtitlePrefetchServiceDeps) => SubtitlePrefetchService;
|
||||||
|
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
|
||||||
|
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<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<void> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -39,3 +39,9 @@ test('resolveSubtitleSourcePath converts file URLs with spaces into filesystem p
|
|||||||
test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => {
|
test('resolveSubtitleSourcePath leaves non-file sources unchanged', () => {
|
||||||
assert.equal(resolveSubtitleSourcePath('/tmp/subs.ass'), '/tmp/subs.ass');
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -30,5 +30,13 @@ export function getActiveExternalSubtitleSource(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveSubtitleSourcePath(source: string): string {
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user