fix(streaming): keep subtitle tokenization prefetch warm for full episodes (#183)

This commit is contained in:
2026-08-03 21:22:18 -07:00
committed by GitHub
parent bffb1c5982
commit b08cd0db35
15 changed files with 231 additions and 59 deletions
+17 -18
View File
@@ -74,7 +74,6 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
preCacheTokenization: (text, data) => {
cached.set(text, data);
},
isCacheFull: () => false,
priorityWindowSize: 3,
});
@@ -91,32 +90,38 @@ test('prefetch service tokenizes priority window cues and caches them', async ()
assert.ok(cached.has('line-2'));
});
test('prefetch service stops when cache is full', async () => {
test('prefetch service warms every cue even when the cache evicts along the way', async () => {
const cues = makeCues(20);
let tokenizeCalls = 0;
let cacheSize = 0;
const tokenized: string[] = [];
// Stand-in for the LRU: only the last 5 entries survive, so later cues evict earlier ones.
const cache = new Set<string>();
const service = createSubtitlePrefetchService({
cues,
tokenizeSubtitle: async (text) => {
tokenizeCalls += 1;
tokenized.push(text);
return { text, tokens: [] };
},
preCacheTokenization: () => {
cacheSize += 1;
preCacheTokenization: (text) => {
cache.add(text);
while (cache.size > 5) {
const oldest = cache.values().next().value;
if (oldest === undefined) break;
cache.delete(oldest);
}
},
isCacheFull: () => cacheSize >= 5,
hasCachedTokenization: (text) => cache.has(text),
priorityWindowSize: 3,
});
service.start(0);
for (let i = 0; i < 30; i += 1) {
for (let i = 0; i < 60; i += 1) {
await flushMicrotasks();
}
service.stop();
// Should have stopped at 5 (cache full), not tokenized all 20
assert.ok(tokenizeCalls <= 6, `Expected <= 6 tokenize calls, got ${tokenizeCalls}`);
assert.equal(tokenized.length, 20, `Expected all 20 cues warmed, got ${tokenized.length}`);
assert.equal(new Set(tokenized).size, 20, 'Each cue is tokenized at most once per run');
});
test('prefetch service can be stopped mid-flight', async () => {
@@ -130,7 +135,6 @@ test('prefetch service can be stopped mid-flight', async () => {
return { text, tokens: [] };
},
preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3,
});
@@ -159,7 +163,6 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
preCacheTokenization: (text) => {
cachedTexts.push(text);
},
isCacheFull: () => false,
priorityWindowSize: 3,
});
@@ -183,7 +186,7 @@ test('prefetch service onSeek re-prioritizes from new position', async () => {
assert.ok(hasPostSeekCue, 'Should have cached cues after seek position');
});
test('prefetch service still warms the priority window when cache is full', async () => {
test('prefetch service warms the priority window ahead of the rest of the file', async () => {
const cues = makeCues(20);
const cachedTexts: string[] = [];
@@ -193,7 +196,6 @@ test('prefetch service still warms the priority window when cache is full', asyn
preCacheTokenization: (text) => {
cachedTexts.push(text);
},
isCacheFull: () => true,
priorityWindowSize: 3,
});
@@ -217,7 +219,6 @@ test('prefetch service pause/resume halts and continues tokenization', async ()
return { text, tokens: [] };
},
preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3,
});
@@ -255,7 +256,6 @@ test('prefetch service skips cues already present in tokenization cache', async
},
preCacheTokenization: () => {},
hasCachedTokenization: (text) => text === 'line-0' || text === 'line-1',
isCacheFull: () => false,
priorityWindowSize: 3,
});
@@ -285,7 +285,6 @@ test('prefetch service deduplicates repeated cue text within a run', async () =>
return { text, tokens: [] };
},
preCacheTokenization: () => {},
isCacheFull: () => false,
priorityWindowSize: 3,
});
+5 -7
View File
@@ -7,7 +7,6 @@ export interface SubtitlePrefetchServiceDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean;
priorityWindowSize?: number;
}
@@ -57,11 +56,14 @@ export function createSubtitlePrefetchService(
let paused = false;
let currentRunId = 0;
// A run is a single bounded pass over one file's cues, deduped by `warmedKeys` and by
// `hasCachedTokenization`, so the worst case is one tokenization per cue. The cache is
// an LRU and bounds its own memory, so a full cache is not a reason to stop warming;
// stopping there used to leave the tail of longer media permanently uncached.
async function tokenizeCueList(
cuesToProcess: SubtitleCue[],
runId: number,
warmedKeys: Set<string>,
options: { allowWhenCacheFull?: boolean } = {},
): Promise<void> {
for (const cue of cuesToProcess) {
if (stopped || runId !== currentRunId) {
@@ -77,10 +79,6 @@ export function createSubtitlePrefetchService(
return;
}
if (!options.allowWhenCacheFull && deps.isCacheFull()) {
return;
}
const cacheKey = normalizeSubtitleCacheKey(cue.text);
if (!cacheKey || warmedKeys.has(cacheKey) || deps.hasCachedTokenization?.(cue.text)) {
if (cacheKey) {
@@ -110,7 +108,7 @@ export function createSubtitlePrefetchService(
// Phase 1: Priority window
const priorityCues = computePriorityWindow(cues, currentTimeSeconds, windowSize);
await tokenizeCueList(priorityCues, runId, warmedKeys, { allowWhenCacheFull: true });
await tokenizeCueList(priorityCues, runId, warmedKeys);
if (stopped || runId !== currentRunId) {
return;
@@ -308,25 +308,54 @@ test('hasCachedSubtitle checks prefetched entries without consuming them', async
assert.equal(controller.hasCachedSubtitle('猫\nです'), false);
});
test('isCacheFull returns false when cache is below limit', () => {
test('cache keeps every entry while below the limit', () => {
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: null }),
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: () => {},
cacheLimit: 8,
});
assert.equal(controller.isCacheFull(), false);
for (let i = 0; i < 8; i += 1) {
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
}
assert.deepEqual(
Array.from({ length: 8 }, (_, i) => controller.hasCachedSubtitle(`line-${i}`)),
Array.from({ length: 8 }, () => true),
);
});
test('isCacheFull returns true when cache reaches limit', async () => {
test('cache evicts least recently used entries once the limit is reached', () => {
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: () => {},
cacheLimit: 3,
});
for (const line of ['a', 'b', 'c']) {
controller.preCacheTokenization(line, { text: line, tokens: [] });
}
// Touching 'a' makes 'b' the eviction candidate.
controller.consumeCachedSubtitle('a');
controller.preCacheTokenization('d', { text: 'd', tokens: [] });
assert.equal(controller.hasCachedSubtitle('b'), false);
assert.deepEqual(
['a', 'c', 'd'].map((line) => controller.hasCachedSubtitle(line)),
[true, true, true],
);
});
test('default cache limit covers a full-length title without evicting', () => {
const controller = createSubtitleProcessingController({
tokenizeSubtitle: async (text) => ({ text, tokens: [] }),
emitSubtitle: () => {},
});
// Fill cache to the 256 limit
for (let i = 0; i < 256; i += 1) {
for (let i = 0; i < 2000; i += 1) {
controller.preCacheTokenization(`line-${i}`, { text: `line-${i}`, tokens: [] });
}
assert.equal(controller.isCacheFull(), true);
assert.equal(controller.hasCachedSubtitle('line-0'), true);
assert.equal(controller.hasCachedSubtitle('line-1999'), true);
});
@@ -5,8 +5,17 @@ export interface SubtitleProcessingControllerDeps {
emitSubtitle: (payload: SubtitleData) => void;
logDebug?: (message: string) => void;
now?: () => number;
cacheLimit?: number;
}
/**
* Pure memory bound on the LRU, not a coverage limit: prefetching runs to the end of a
* file regardless of cache pressure. Sized to hold a feature-length title (a 24-minute
* episode runs 300-400 lines, a 2-hour film ~2000) plus room for lines that repeat across
* episodes of a series, so openings and endings stay warm between titles.
*/
export const DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT = 2500;
export interface SubtitleProcessingController {
onSubtitleChange: (text: string) => void;
refreshCurrentSubtitle: (textOverride?: string) => void;
@@ -14,7 +23,6 @@ export interface SubtitleProcessingController {
preCacheTokenization: (text: string, data: SubtitleData) => void;
consumeCachedSubtitle: (text: string) => SubtitleData | null;
hasCachedSubtitle: (text: string) => boolean;
isCacheFull: () => boolean;
}
export function normalizeSubtitleCacheKey(text: string): string {
@@ -24,7 +32,10 @@ export function normalizeSubtitleCacheKey(text: string): string {
export function createSubtitleProcessingController(
deps: SubtitleProcessingControllerDeps,
): SubtitleProcessingController {
const SUBTITLE_TOKENIZATION_CACHE_LIMIT = 256;
const SUBTITLE_TOKENIZATION_CACHE_LIMIT =
deps.cacheLimit && deps.cacheLimit > 0
? deps.cacheLimit
: DEFAULT_SUBTITLE_TOKENIZATION_CACHE_LIMIT;
let latestText = '';
let lastEmittedText = '';
let cacheGeneration = 0;
@@ -174,8 +185,5 @@ export function createSubtitleProcessingController(
hasCachedSubtitle: (text: string) => {
return tokenizationCache.has(normalizeSubtitleCacheKey(text));
},
isCacheFull: () => {
return tokenizationCache.size >= SUBTITLE_TOKENIZATION_CACHE_LIMIT;
},
};
}
+7 -2
View File
@@ -1955,7 +1955,6 @@ const subtitlePrefetchInitController = createSubtitlePrefetchInitController({
subtitleProcessingController.preCacheTokenization(text, data);
},
hasCachedTokenization: (text) => subtitleProcessingController.hasCachedSubtitle(text),
isCacheFull: () => subtitleProcessingController.isCacheFull(),
logInfo: (message) => logger.info(message),
logWarn: (message) => logger.warn(message),
onParsedSubtitleCuesChanged: (cues, sourceKey) => {
@@ -1989,7 +1988,11 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
createRefreshSubtitlePrefetchFromActiveTrackHandler({
getMpvClient: () => appState.mpvClient,
getLastObservedTimePos: () => lastObservedTimePos,
shouldKeepExistingCuesOnMissingSource: (videoPath) => isYoutubeMediaPath(videoPath),
// Remote media has no extractable on-disk track to fall back to, so a transient
// resolve miss (sid briefly 'no', a cycle onto an embedded stream track) would
// otherwise drop a working cue list for the rest of the episode.
shouldKeepExistingCuesOnMissingSource: (videoPath) =>
isYoutubeMediaPath(videoPath) || isRemoteMediaPath(videoPath),
subtitlePrefetchInitController,
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
logDebug: (message) => logger.debug(message),
@@ -2997,6 +3000,8 @@ const {
streamIndex,
delaySeconds,
}),
initSubtitlePrefetch: (sourcePath) =>
subtitlePrefetchRuntime.refreshSubtitleSidebarFromSource(sourcePath),
logDebug: (message, error) => {
logger.debug(message, error);
},
+23
View File
@@ -176,6 +176,29 @@ test('subtitle sidebar media path tag is assigned after prefetch succeeds', () =
);
});
test('remote media keeps parsed cues when the active subtitle source cannot be resolved', () => {
const source = readMainSource();
const actionBlock = source.match(
/createRefreshSubtitlePrefetchFromActiveTrackHandler\(\{(?<body>[\s\S]*?)\n \}\);/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(actionBlock, /isYoutubeMediaPath\(videoPath\) \|\| isRemoteMediaPath\(videoPath\)/);
});
test('jellyfin subtitle preload seeds the tokenization prefetch directly', () => {
const source = readMainSource();
const actionBlock = source.match(
/preloadJellyfinExternalSubtitlesMainDeps:\s*\{(?<body>[\s\S]*?)\n \},/,
)?.groups?.body;
assert.ok(actionBlock);
assert.match(
actionBlock,
/initSubtitlePrefetch: \(sourcePath\) =>\s*subtitlePrefetchRuntime\.refreshSubtitleSidebarFromSource\(sourcePath\),/,
);
});
test('update overlay notification action triggers install flow', () => {
const source = readMainSource();
const runtimeSource = readSource('src/main/runtime/overlay-notifications-runtime.ts');
@@ -28,6 +28,9 @@ export function createBuildPreloadJellyfinExternalSubtitlesMainDepsHandler(
? (itemId, streamIndex, delaySeconds) =>
deps.saveSubtitleDelay!(itemId, streamIndex, delaySeconds)
: undefined,
initSubtitlePrefetch: deps.initSubtitlePrefetch
? (sourcePath) => deps.initSubtitlePrefetch!(sourcePath)
: undefined,
logDebug: (message: string, error: unknown) => deps.logDebug(message, error),
});
}
@@ -40,6 +40,9 @@ function makeDeps(overrides: {
>[0]['setActiveSubtitleDelayKey'];
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void;
initSubtitlePrefetch?: Parameters<
typeof createPreloadJellyfinExternalSubtitlesHandler
>[0]['initSubtitlePrefetch'];
logDebug?: Parameters<typeof createPreloadJellyfinExternalSubtitlesHandler>[0]['logDebug'];
}) {
return {
@@ -58,6 +61,7 @@ function makeDeps(overrides: {
setActiveSubtitleDelayKey: overrides.setActiveSubtitleDelayKey,
loadSubtitleSourceText: overrides.loadSubtitleSourceText,
saveSubtitleDelay: overrides.saveSubtitleDelay,
initSubtitlePrefetch: overrides.initSubtitlePrefetch,
logDebug: overrides.logDebug ?? (() => {}),
};
}
@@ -134,6 +138,92 @@ test('preload jellyfin subtitles caches external tracks locally and chooses japa
]);
});
test('preload jellyfin subtitles starts prefetch for the selected japanese track', async () => {
const prefetched: string[] = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
{ index: 1, language: 'eng', title: 'English', deliveryUrl: 'https://sub/b.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
{
type: 'sub',
id: 6,
lang: 'eng',
title: 'English',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/1.srt',
},
],
}),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
}),
initSubtitlePrefetch: (sourcePath) => {
prefetched.push(sourcePath);
},
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(prefetched, ['/tmp/subminer-jellyfin-subtitles/0.srt']);
});
test('preload jellyfin subtitles survives prefetch start failures', async () => {
const logs: string[] = [];
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
makeDeps({
listJellyfinSubtitleTracks: async () => [
{ index: 0, language: 'jpn', title: 'Japanese', deliveryUrl: 'https://sub/a.srt' },
],
getMpvClient: () => ({
requestProperty: async () => [
{
type: 'sub',
id: 5,
lang: 'jpn',
title: 'Japanese',
external: true,
'external-filename': '/tmp/subminer-jellyfin-subtitles/0.srt',
},
],
}),
sendMpvCommand: (command) => commands.push(command),
cacheSubtitleTrack: async (track) => ({
path: `/tmp/subminer-jellyfin-subtitles/${track.index}.srt`,
cleanupDir: '/tmp/subminer-jellyfin-subtitles',
}),
initSubtitlePrefetch: async () => {
throw new Error('parse failed');
},
logDebug: (message) => logs.push(message),
}),
);
await preload({ session, clientInfo, itemId: 'item-1' });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(logs, ['Failed to start subtitle prefetch for Jellyfin subtitle']);
assert.ok(
commands.some((command) => command[0] === 'set_property' && command[1] === 'sid'),
'subtitle selection still happens when prefetch start fails',
);
});
test('preload jellyfin subtitles stages tracks without temporary subtitle selection', async () => {
const commands: Array<Array<string | number>> = [];
const preload = createPreloadJellyfinExternalSubtitlesHandler(
@@ -320,6 +320,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void;
loadSubtitleSourceText?: (source: string) => Promise<string>;
saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void;
initSubtitlePrefetch?: (sourcePath: string) => void | Promise<void>;
logDebug: (message: string, error: unknown) => void;
}): PreloadJellyfinExternalSubtitlesHandler {
const activeCacheDirs = new Set<string>();
@@ -329,6 +330,18 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
deps.sendMpvCommand(['set_property', 'sub-delay', 0]);
}
// mpv's sid property-change is the only thing that normally starts prefetching, so a
// coalesced or missed event leaves the whole episode uncached. The downloaded path is
// known here, so seed the pipeline directly instead of waiting on the observer.
function startSubtitlePrefetchForCachedTrack(sourcePath: string): void {
if (!deps.initSubtitlePrefetch) return;
void Promise.resolve()
.then(() => deps.initSubtitlePrefetch!(sourcePath))
.catch((error) => {
deps.logDebug('Failed to start subtitle prefetch for Jellyfin subtitle', error);
});
}
function cleanupActiveCache(): void {
const dirs = [...activeCacheDirs];
if (dirs.length === 0) return;
@@ -438,6 +451,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: {
}
}
deps.sendMpvCommand(['set_property', 'sid', japanesePrimaryId]);
startSubtitlePrefetchForCachedTrack(selectedCachedTrack.path);
} else {
deps.setActiveSubtitleDelayKey?.(null);
resetManagedSubtitleDelay();
@@ -54,7 +54,6 @@ test('latest subtitle prefetch init wins over stale async loads', async () => {
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
});
@@ -99,7 +98,6 @@ test('cancelPendingInit prevents an in-flight load from attaching a stale servic
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
});
@@ -137,7 +135,6 @@ test('subtitle prefetch init publishes parsed cues and clears them on cancel', a
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => {
@@ -181,7 +178,6 @@ test('subtitle prefetch init publishes the provided stable source key instead of
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (_cues, source) => {
@@ -222,7 +218,6 @@ test('subtitle prefetch init clears parsed cues when initialization fails', asyn
}),
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: () => {},
onParsedSubtitleCuesChanged: (cues) => {
@@ -247,7 +242,6 @@ test('subtitle prefetch init logs a warning when the source parses to zero cues'
},
tokenizeSubtitle: async () => null,
preCacheTokenization: () => {},
isCacheFull: () => false,
logInfo: () => {},
logWarn: (message) => warnings.push(message),
});
@@ -14,7 +14,6 @@ export interface SubtitlePrefetchInitControllerDeps {
tokenizeSubtitle: (text: string) => Promise<SubtitleData | null>;
preCacheTokenization: (text: string, data: SubtitleData) => void;
hasCachedTokenization?: (text: string) => boolean;
isCacheFull: () => boolean;
logInfo: (message: string) => void;
logWarn: (message: string) => void;
onParsedSubtitleCuesChanged?: (cues: SubtitleCue[] | null, sourceKey: string | null) => void;
@@ -72,7 +71,6 @@ export function createSubtitlePrefetchInitController(
tokenizeSubtitle: (text) => deps.tokenizeSubtitle(text),
preCacheTokenization: (text, data) => deps.preCacheTokenization(text, data),
hasCachedTokenization: (text) => deps.hasCachedTokenization?.(text) ?? false,
isCacheFull: () => deps.isCacheFull(),
});
if (revision !== initRevision) {