From b08cd0db353d00bd25acedfe164a981418c99961 Mon Sep 17 00:00:00 2001 From: sudacode Date: Mon, 3 Aug 2026 21:22:18 -0700 Subject: [PATCH] fix(streaming): keep subtitle tokenization prefetch warm for full episodes (#183) --- bun.lock | 16 ++-- changes/security-audit-high-advisories.md | 4 + changes/stream-subtitle-tokenization-cache.md | 7 ++ package.json | 8 +- src/core/services/subtitle-prefetch.test.ts | 35 ++++---- src/core/services/subtitle-prefetch.ts | 12 ++- .../subtitle-processing-controller.test.ts | 43 +++++++-- .../subtitle-processing-controller.ts | 18 ++-- src/main.ts | 9 +- src/main/main-wiring.test.ts | 23 +++++ .../jellyfin-subtitle-preload-main-deps.ts | 3 + .../runtime/jellyfin-subtitle-preload.test.ts | 90 +++++++++++++++++++ src/main/runtime/jellyfin-subtitle-preload.ts | 14 +++ .../runtime/subtitle-prefetch-init.test.ts | 6 -- src/main/runtime/subtitle-prefetch-init.ts | 2 - 15 files changed, 231 insertions(+), 59 deletions(-) create mode 100644 changes/security-audit-high-advisories.md create mode 100644 changes/stream-subtitle-tokenization-cache.md diff --git a/bun.lock b/bun.lock index 168ae77f..3b3ca69f 100644 --- a/bun.lock +++ b/bun.lock @@ -26,7 +26,7 @@ "eslint": "^10.8.0", "prettier": "^3.8.1", "typescript": "^5.9.3", - "undici": "7.28.0", + "undici": "7.29.0", }, }, }, @@ -36,8 +36,9 @@ "overrides": { "@xmldom/xmldom": "0.8.13", "app-builder-lib": "26.15.3", - "brace-expansion": "5.0.8", + "brace-expansion": "5.0.9", "electron-builder-squirrel-windows": "26.15.3", + "fast-uri": "3.1.5", "form-data": "4.0.6", "ip-address": "10.2.0", "js-yaml": "4.3.0", @@ -46,6 +47,7 @@ "picomatch": "4.0.4", "tar": "7.5.21", "tmp": "0.2.7", + "undici": "7.29.0", }, "packages": { "@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="], @@ -266,7 +268,7 @@ "boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="], - "brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="], + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], "buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="], @@ -404,7 +406,7 @@ "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], - "fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="], + "fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -714,7 +716,7 @@ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - "undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="], + "undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], @@ -754,8 +756,6 @@ "@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="], - "@discordjs/rest/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="], - "@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="], "@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], @@ -808,8 +808,6 @@ "node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="], - "node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="], - "node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="], "pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="], diff --git a/changes/security-audit-high-advisories.md b/changes/security-audit-high-advisories.md new file mode 100644 index 00000000..0a03318d --- /dev/null +++ b/changes/security-audit-high-advisories.md @@ -0,0 +1,4 @@ +type: internal +area: dependencies + +- Patched three high-severity dependency advisories flagged by `bun audit`: `undici` (cross-user information disclosure via degenerate private cache directives), `brace-expansion` (denial of service via unbounded intermediate arrays), and `fast-uri` (host confusion via backslash authority introducer). diff --git a/changes/stream-subtitle-tokenization-cache.md b/changes/stream-subtitle-tokenization-cache.md new file mode 100644 index 00000000..5d39a0a1 --- /dev/null +++ b/changes/stream-subtitle-tokenization-cache.md @@ -0,0 +1,7 @@ +type: fixed +area: streaming + +- Jellyfin playback now seeds the subtitle tokenization prefetch straight from the subtitle file it downloads, instead of waiting on an mpv track-selection event that could be missed or coalesced and leave a whole episode tokenizing line by line. +- Streamed media no longer drops its parsed subtitle cues when the active subtitle track briefly cannot be resolved, such as when cycling onto a subtitle track embedded in the stream. +- Subtitle prefetching now runs to the end of a file instead of stopping as soon as the tokenization cache fills, so the back half of an episode no longer gets tokenized line by line during playback. Previously the cache was also never cleared between episodes, so the stall carried over to every later title in a session. +- Raised the tokenization cache from 256 to 2500 lines. It is now purely a memory bound rather than a limit on how much gets prefetched, and it leaves room for lines that repeat across episodes so openings and endings stay warm between titles. diff --git a/package.json b/package.json index 45a9375e..c90a5ffb 100644 --- a/package.json +++ b/package.json @@ -84,8 +84,9 @@ "overrides": { "@xmldom/xmldom": "0.8.13", "app-builder-lib": "26.15.3", - "brace-expansion": "5.0.8", + "brace-expansion": "5.0.9", "electron-builder-squirrel-windows": "26.15.3", + "fast-uri": "3.1.5", "form-data": "4.0.6", "ip-address": "10.2.0", "js-yaml": "4.3.0", @@ -93,7 +94,8 @@ "minimatch": "10.2.5", "picomatch": "4.0.4", "tar": "7.5.21", - "tmp": "0.2.7" + "tmp": "0.2.7", + "undici": "7.29.0" }, "keywords": [ "anki", @@ -125,7 +127,7 @@ "@types/ws": "^8.18.1", "electron": "42.6.0", "electron-builder": "26.15.3", - "undici": "7.28.0", + "undici": "7.29.0", "esbuild": "^0.25.12", "eslint": "^10.8.0", "prettier": "^3.8.1", diff --git a/src/core/services/subtitle-prefetch.test.ts b/src/core/services/subtitle-prefetch.test.ts index 5458e349..ad2c9f8c 100644 --- a/src/core/services/subtitle-prefetch.test.ts +++ b/src/core/services/subtitle-prefetch.test.ts @@ -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(); 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, }); diff --git a/src/core/services/subtitle-prefetch.ts b/src/core/services/subtitle-prefetch.ts index c8e0174d..fd169f87 100644 --- a/src/core/services/subtitle-prefetch.ts +++ b/src/core/services/subtitle-prefetch.ts @@ -7,7 +7,6 @@ export interface SubtitlePrefetchServiceDeps { tokenizeSubtitle: (text: string) => Promise; 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, - options: { allowWhenCacheFull?: boolean } = {}, ): Promise { 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; diff --git a/src/core/services/subtitle-processing-controller.test.ts b/src/core/services/subtitle-processing-controller.test.ts index 26ddbddf..a7214dd5 100644 --- a/src/core/services/subtitle-processing-controller.test.ts +++ b/src/core/services/subtitle-processing-controller.test.ts @@ -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); }); diff --git a/src/core/services/subtitle-processing-controller.ts b/src/core/services/subtitle-processing-controller.ts index 59d88d31..8a70538b 100644 --- a/src/core/services/subtitle-processing-controller.ts +++ b/src/core/services/subtitle-processing-controller.ts @@ -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; - }, }; } diff --git a/src/main.ts b/src/main.ts index 0578c84f..854a33b1 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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); }, diff --git a/src/main/main-wiring.test.ts b/src/main/main-wiring.test.ts index 728964bf..db3f96da 100644 --- a/src/main/main-wiring.test.ts +++ b/src/main/main-wiring.test.ts @@ -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\(\{(?[\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*\{(?[\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'); diff --git a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts b/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts index 63cb0e8b..f5ca73a7 100644 --- a/src/main/runtime/jellyfin-subtitle-preload-main-deps.ts +++ b/src/main/runtime/jellyfin-subtitle-preload-main-deps.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), }); } diff --git a/src/main/runtime/jellyfin-subtitle-preload.test.ts b/src/main/runtime/jellyfin-subtitle-preload.test.ts index bc8ae77c..8477f174 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.test.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.test.ts @@ -40,6 +40,9 @@ function makeDeps(overrides: { >[0]['setActiveSubtitleDelayKey']; loadSubtitleSourceText?: (source: string) => Promise; saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => void; + initSubtitlePrefetch?: Parameters< + typeof createPreloadJellyfinExternalSubtitlesHandler + >[0]['initSubtitlePrefetch']; logDebug?: Parameters[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> = []; + 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> = []; const preload = createPreloadJellyfinExternalSubtitlesHandler( diff --git a/src/main/runtime/jellyfin-subtitle-preload.ts b/src/main/runtime/jellyfin-subtitle-preload.ts index abcebff8..5843075f 100644 --- a/src/main/runtime/jellyfin-subtitle-preload.ts +++ b/src/main/runtime/jellyfin-subtitle-preload.ts @@ -320,6 +320,7 @@ export function createPreloadJellyfinExternalSubtitlesHandler(deps: { setActiveSubtitleDelayKey?: (key: JellyfinSubtitleDelayKey | null) => void; loadSubtitleSourceText?: (source: string) => Promise; saveSubtitleDelay?: (itemId: string, streamIndex: number, delaySeconds: number) => boolean | void; + initSubtitlePrefetch?: (sourcePath: string) => void | Promise; logDebug: (message: string, error: unknown) => void; }): PreloadJellyfinExternalSubtitlesHandler { const activeCacheDirs = new Set(); @@ -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(); diff --git a/src/main/runtime/subtitle-prefetch-init.test.ts b/src/main/runtime/subtitle-prefetch-init.test.ts index e918fa91..1754fca6 100644 --- a/src/main/runtime/subtitle-prefetch-init.test.ts +++ b/src/main/runtime/subtitle-prefetch-init.test.ts @@ -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), }); diff --git a/src/main/runtime/subtitle-prefetch-init.ts b/src/main/runtime/subtitle-prefetch-init.ts index f7cf3fec..aa605a0d 100644 --- a/src/main/runtime/subtitle-prefetch-init.ts +++ b/src/main/runtime/subtitle-prefetch-init.ts @@ -14,7 +14,6 @@ export interface SubtitlePrefetchInitControllerDeps { tokenizeSubtitle: (text: string) => Promise; 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) {