mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-30 17:40:53 -07:00
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.
This commit is contained in:
@@ -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<string | null> = [];
|
||||
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']);
|
||||
});
|
||||
|
||||
@@ -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<SubtitleData | null>;
|
||||
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<void> => {
|
||||
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}`);
|
||||
}
|
||||
|
||||
@@ -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), []);
|
||||
});
|
||||
@@ -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<string>();
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user