diff --git a/backlog/tasks/task-131 - Avoid-duplicate-tokenization-warmup-after-background-startup.md b/backlog/tasks/task-131 - Avoid-duplicate-tokenization-warmup-after-background-startup.md new file mode 100644 index 0000000..e960755 --- /dev/null +++ b/backlog/tasks/task-131 - Avoid-duplicate-tokenization-warmup-after-background-startup.md @@ -0,0 +1,63 @@ +--- +id: TASK-131 +title: Avoid duplicate tokenization warmup after background startup +status: Done +assignee: + - codex +created_date: '2026-03-08 10:12' +updated_date: '2026-03-08 12:00' +labels: + - bug +dependencies: [] +references: + - >- + /Users/sudacode/projects/japanese/SubMiner/src/main/runtime/composers/mpv-runtime-composer.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/main/runtime/startup-warmups.ts + - >- + /Users/sudacode/projects/japanese/SubMiner/src/main/runtime/composers/mpv-runtime-composer.test.ts +priority: medium +--- + +## Description + + +When SubMiner is already running in the background and mpv is launched from the launcher or mpv plugin, the live app should reuse startup tokenization warmup state instead of re-entering the Yomitan/tokenization/annotation warmup path on first overlay use. + + +## Acceptance Criteria + +- [x] #1 Background startup tokenization warmup is recorded in the runtime state used by later mpv/tokenization flows. +- [x] #2 Launching mpv from the launcher or plugin against an already-running background app does not re-run duplicate Yomitan/tokenization annotation warmup work in the live process. +- [x] #3 Regression tests cover the warmed-background path and protect against re-entering duplicate warmup work. + + +## Implementation Plan + + +1. Add a regression test covering the case where background startup warmups already completed and a later tokenize call must not re-enter Yomitan/MeCab/dictionary warmups. +2. Update mpv tokenization warmup composition so startup background warmups and on-demand tokenization share the same completion state. +3. Run the focused composer/runtime tests and update acceptance criteria/notes with results. + + +## Implementation Notes + + +Root-cause hypothesis: startup background warmups and on-demand tokenization warmups use separate state, so later mpv launch can re-enter warmup bookkeeping even though background startup already warmed dependencies. + +Implemented shared warmup state between startup background warmups and on-demand tokenization warmups by forwarding scheduled Yomitan/tokenization promises into the mpv runtime composer. Added regression coverage for the warmed-background path. Verified with `bun run test:fast` plus focused composer/startup warmup tests. + +Follow-up root cause from live retest: second mpv open could still pause on the startup gate because the runtime only treated full background tokenization warmup completion as reusable readiness. In practice, first-file tokenization could already be ready while slower dictionary prewarm work was still finishing, so reopening a video waited on duplicate warmup completion even though annotations were already usable. + +Adjusted `src/main/runtime/composers/mpv-runtime-composer.ts` so autoplay reuse keys off a separate playback-ready latch. The latch flips true either when background warmups fully cover tokenization or when `onTokenizationReady` fires for a real subtitle line. `src/main.ts` already uses `isTokenizationWarmupReady()` to fast-signal `subminer-autoplay-ready` on a fresh media-path change, so reopened videos can now resume immediately once tokenization has succeeded once in the persistent app. + +Validation update: `bun test src/core/services/cli-command.test.ts src/main/runtime/mpv-main-event-actions.test.ts src/main/runtime/composers/mpv-runtime-composer.test.ts launcher/mpv.test.ts launcher/smoke.e2e.test.ts` passed, `lua scripts/test-plugin-start-gate.lua` passed, and `bun run typecheck` passed. + + +## Final Summary + + +Background startup tokenization warmups now feed the same in-memory warmup state used by later mpv tokenization. When the app is already running and warmed in the background, launcher/plugin-driven mpv startup reuses that state instead of re-entering Yomitan/tokenization annotation warmups. Added a regression test for the warmed-background path and verified with `bun run test:fast`. + +A later follow-up fixed the remaining second-open delay: autoplay reuse no longer waits for the entire background dictionary warmup pipeline to finish. After the persistent app has produced one tokenization-ready event, later mpv reconnects reuse that readiness immediately, so reopening the same or another video does not pause again on duplicate warmup bookkeeping. + diff --git a/src/main/runtime/composers/mpv-runtime-composer.test.ts b/src/main/runtime/composers/mpv-runtime-composer.test.ts index d9f8eb2..72a780e 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.test.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.test.ts @@ -212,6 +212,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject assert.equal(typeof composed.createMecabTokenizerAndCheck, 'function'); assert.equal(typeof composed.prewarmSubtitleDictionaries, 'function'); assert.equal(typeof composed.startTokenizationWarmups, 'function'); + assert.equal(typeof composed.isTokenizationWarmupReady, 'function'); assert.equal(typeof composed.launchBackgroundWarmupTask, 'function'); assert.equal(typeof composed.startBackgroundWarmups, 'function'); @@ -219,7 +220,9 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject assert.equal(client.connected, true); composed.updateMpvSubtitleRenderMetrics({ subPos: 90 }); + assert.equal(composed.isTokenizationWarmupReady(), false); await composed.startTokenizationWarmups(); + assert.equal(composed.isTokenizationWarmupReady(), true); const tokenized = await composed.tokenizeSubtitle('subtitle text'); await composed.createMecabTokenizerAndCheck(); await composed.prewarmSubtitleDictionaries(); @@ -789,9 +792,11 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization- const warmupPromise = composed.startTokenizationWarmups(); await new Promise((resolve) => setImmediate(resolve)); assert.deepEqual(osdMessages, []); + assert.equal(composed.isTokenizationWarmupReady(), false); await composed.tokenizeSubtitle('first line'); assert.deepEqual(osdMessages, ['Loading subtitle annotations |']); + assert.equal(composed.isTokenizationWarmupReady(), true); jlptDeferred.resolve(); frequencyDeferred.resolve(); @@ -800,3 +805,154 @@ test('composeMpvRuntimeHandlers shows annotation loading OSD after tokenization- assert.deepEqual(osdMessages, ['Loading subtitle annotations |', 'Subtitle annotations loaded']); }); + +test('composeMpvRuntimeHandlers reuses completed background tokenization warmups for later tokenize calls', async () => { + let started = false; + let yomitanWarmupCalls = 0; + let mecabWarmupCalls = 0; + let jlptWarmupCalls = 0; + let frequencyWarmupCalls = 0; + let mecabTokenizer: { tokenize: () => Promise } | null = null; + + const composed = composeMpvRuntimeHandlers< + { connect: () => void; on: () => void }, + { isKnownWord: () => boolean }, + { text: string } + >({ + bindMpvMainEventHandlersMainDeps: { + appState: { + initialArgs: null, + overlayRuntimeInitialized: true, + mpvClient: null, + immersionTracker: null, + subtitleTimingTracker: null, + currentSubText: '', + currentSubAssText: '', + playbackPaused: null, + previousSecondarySubVisibility: null, + }, + getQuitOnDisconnectArmed: () => false, + scheduleQuitCheck: () => {}, + quitApp: () => {}, + reportJellyfinRemoteStopped: () => {}, + syncOverlayMpvSubtitleSuppression: () => {}, + maybeRunAnilistPostWatchUpdate: async () => {}, + logSubtitleTimingError: () => {}, + broadcastToOverlayWindows: () => {}, + onSubtitleChange: () => {}, + refreshDiscordPresence: () => {}, + ensureImmersionTrackerInitialized: () => {}, + updateCurrentMediaPath: () => {}, + restoreMpvSubVisibility: () => {}, + getCurrentAnilistMediaKey: () => null, + resetAnilistMediaTracking: () => {}, + maybeProbeAnilistDuration: () => {}, + ensureAnilistMediaGuess: () => {}, + syncImmersionMediaState: () => {}, + updateCurrentMediaTitle: () => {}, + resetAnilistMediaGuessState: () => {}, + reportJellyfinRemoteProgress: () => {}, + updateSubtitleRenderMetrics: () => {}, + }, + mpvClientRuntimeServiceFactoryMainDeps: { + createClient: class { + connect(): void {} + on(): void {} + }, + getSocketPath: () => '/tmp/mpv.sock', + getResolvedConfig: () => ({ auto_start_overlay: false }), + isAutoStartOverlayEnabled: () => false, + setOverlayVisible: () => {}, + isVisibleOverlayVisible: () => false, + getReconnectTimer: () => null, + setReconnectTimer: () => {}, + }, + updateMpvSubtitleRenderMetricsMainDeps: { + getCurrentMetrics: () => BASE_METRICS, + setCurrentMetrics: () => {}, + applyPatch: (current, patch) => ({ next: { ...current, ...patch }, changed: true }), + broadcastMetrics: () => {}, + }, + tokenizer: { + buildTokenizerDepsMainDeps: { + getYomitanExt: () => null, + getYomitanParserWindow: () => null, + setYomitanParserWindow: () => {}, + getYomitanParserReadyPromise: () => null, + setYomitanParserReadyPromise: () => {}, + getYomitanParserInitPromise: () => null, + setYomitanParserInitPromise: () => {}, + isKnownWord: () => false, + recordLookup: () => {}, + getKnownWordMatchMode: () => 'headword', + getNPlusOneEnabled: () => true, + getMinSentenceWordsForNPlusOne: () => 3, + getJlptLevel: () => null, + getJlptEnabled: () => true, + getFrequencyDictionaryEnabled: () => true, + getFrequencyDictionaryMatchMode: () => 'headword', + getFrequencyRank: () => null, + getYomitanGroupDebugEnabled: () => false, + getMecabTokenizer: () => mecabTokenizer, + }, + createTokenizerRuntimeDeps: () => ({ isKnownWord: () => false }), + tokenizeSubtitle: async (text) => ({ text }), + createMecabTokenizerAndCheckMainDeps: { + getMecabTokenizer: () => mecabTokenizer, + setMecabTokenizer: (next) => { + mecabTokenizer = next as { tokenize: () => Promise }; + }, + createMecabTokenizer: () => ({ tokenize: async () => [] }), + checkAvailability: async () => { + mecabWarmupCalls += 1; + }, + }, + prewarmSubtitleDictionariesMainDeps: { + ensureJlptDictionaryLookup: async () => { + jlptWarmupCalls += 1; + }, + ensureFrequencyDictionaryLookup: async () => { + frequencyWarmupCalls += 1; + }, + }, + }, + warmups: { + launchBackgroundWarmupTaskMainDeps: { + now: () => 0, + logDebug: () => {}, + logWarn: () => {}, + }, + startBackgroundWarmupsMainDeps: { + getStarted: () => started, + setStarted: (next) => { + started = next; + }, + isTexthookerOnlyMode: () => false, + ensureYomitanExtensionLoaded: async () => { + yomitanWarmupCalls += 1; + }, + shouldWarmupMecab: () => true, + shouldWarmupYomitanExtension: () => true, + shouldWarmupSubtitleDictionaries: () => true, + shouldWarmupJellyfinRemoteSession: () => false, + shouldAutoConnectJellyfinRemote: () => false, + startJellyfinRemoteSession: async () => {}, + }, + }, + }); + + composed.startBackgroundWarmups(); + await new Promise((resolve) => setImmediate(resolve)); + + assert.equal(yomitanWarmupCalls, 1); + assert.equal(mecabWarmupCalls, 1); + assert.equal(jlptWarmupCalls, 1); + assert.equal(frequencyWarmupCalls, 1); + + await composed.tokenizeSubtitle('first line after background warmup'); + + assert.equal(yomitanWarmupCalls, 1); + assert.equal(mecabWarmupCalls, 1); + assert.equal(jlptWarmupCalls, 1); + assert.equal(frequencyWarmupCalls, 1); +}); diff --git a/src/main/runtime/composers/mpv-runtime-composer.ts b/src/main/runtime/composers/mpv-runtime-composer.ts index 010e05b..7a4ce82 100644 --- a/src/main/runtime/composers/mpv-runtime-composer.ts +++ b/src/main/runtime/composers/mpv-runtime-composer.ts @@ -88,6 +88,7 @@ export type MpvRuntimeComposerResult< createMecabTokenizerAndCheck: () => Promise; prewarmSubtitleDictionaries: () => Promise; startTokenizationWarmups: () => Promise; + isTokenizationWarmupReady: () => boolean; launchBackgroundWarmupTask: ReturnType; startBackgroundWarmups: ReturnType; }>; @@ -151,6 +152,36 @@ export function composeMpvRuntimeHandlers< let tokenizationPrerequisiteWarmupInFlight: Promise | null = null; let tokenizationPrerequisiteWarmupCompleted = false; let tokenizationWarmupCompleted = false; + let tokenizationPlaybackReady = false; + const markTokenizationPrerequisiteWarmupCompleted = (): void => { + tokenizationPrerequisiteWarmupCompleted = true; + }; + const markTokenizationPlaybackReady = (): void => { + tokenizationPlaybackReady = true; + }; + const markTokenizationWarmupCompleted = (): void => { + tokenizationPrerequisiteWarmupCompleted = true; + tokenizationWarmupCompleted = true; + tokenizationPlaybackReady = true; + }; + const backgroundWarmupCoversOnDemandTokenization = (): boolean => { + if (!options.warmups.startBackgroundWarmupsMainDeps.shouldWarmupYomitanExtension()) { + return false; + } + if ( + shouldInitializeMecabForAnnotations() && + !options.warmups.startBackgroundWarmupsMainDeps.shouldWarmupMecab() + ) { + return false; + } + if ( + shouldWarmupAnnotationDictionaries() && + !options.warmups.startBackgroundWarmupsMainDeps.shouldWarmupSubtitleDictionaries() + ) { + return false; + } + return true; + }; const ensureTokenizationPrerequisites = (): Promise => { if (tokenizationPrerequisiteWarmupCompleted) { return Promise.resolve(); @@ -159,7 +190,7 @@ export function composeMpvRuntimeHandlers< tokenizationPrerequisiteWarmupInFlight = options.warmups.startBackgroundWarmupsMainDeps .ensureYomitanExtensionLoaded() .then(() => { - tokenizationPrerequisiteWarmupCompleted = true; + markTokenizationPrerequisiteWarmupCompleted(); }) .finally(() => { tokenizationPrerequisiteWarmupInFlight = null; @@ -184,7 +215,7 @@ export function composeMpvRuntimeHandlers< warmupTasks.push(prewarmSubtitleDictionaries().catch(() => {})); } await Promise.all(warmupTasks); - tokenizationWarmupCompleted = true; + markTokenizationWarmupCompleted(); })().finally(() => { tokenizationWarmupInFlight = null; }); @@ -198,6 +229,7 @@ export function composeMpvRuntimeHandlers< if (shouldWarmupAnnotationDictionaries()) { const onTokenizationReady = tokenizerMainDeps.onTokenizationReady; tokenizerMainDeps.onTokenizationReady = (tokenizedText: string): void => { + markTokenizationPlaybackReady(); onTokenizationReady?.(tokenizedText); if (!tokenizationWarmupCompleted) { void prewarmSubtitleDictionaries({ showLoadingOsd: true }).catch(() => {}); @@ -221,6 +253,36 @@ export function composeMpvRuntimeHandlers< launchTask: (label, task) => launchBackgroundWarmupTask(label, task), createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(), prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(), + onYomitanExtensionWarmupScheduled: (promise) => { + if (tokenizationPrerequisiteWarmupCompleted) { + return; + } + const finalizedPromise = promise + .then(() => { + markTokenizationPrerequisiteWarmupCompleted(); + }) + .finally(() => { + if (tokenizationPrerequisiteWarmupInFlight === finalizedPromise) { + tokenizationPrerequisiteWarmupInFlight = null; + } + }); + tokenizationPrerequisiteWarmupInFlight = finalizedPromise; + }, + onTokenizationWarmupScheduled: (promise) => { + if (tokenizationWarmupCompleted || !backgroundWarmupCoversOnDemandTokenization()) { + return; + } + const finalizedPromise = promise + .then(() => { + markTokenizationWarmupCompleted(); + }) + .finally(() => { + if (tokenizationWarmupInFlight === finalizedPromise) { + tokenizationWarmupInFlight = null; + } + }); + tokenizationWarmupInFlight = finalizedPromise; + }, })(), ); @@ -232,6 +294,7 @@ export function composeMpvRuntimeHandlers< createMecabTokenizerAndCheck: () => createMecabTokenizerAndCheck(), prewarmSubtitleDictionaries: () => prewarmSubtitleDictionaries(), startTokenizationWarmups, + isTokenizationWarmupReady: () => tokenizationPlaybackReady, launchBackgroundWarmupTask: (label, task) => launchBackgroundWarmupTask(label, task), startBackgroundWarmups: () => startBackgroundWarmups(), }; diff --git a/src/main/runtime/startup-warmups-main-deps.ts b/src/main/runtime/startup-warmups-main-deps.ts index 675c775..7766f17 100644 --- a/src/main/runtime/startup-warmups-main-deps.ts +++ b/src/main/runtime/startup-warmups-main-deps.ts @@ -35,6 +35,18 @@ export function createBuildStartBackgroundWarmupsMainDepsHandler( shouldWarmupJellyfinRemoteSession: () => deps.shouldWarmupJellyfinRemoteSession(), shouldAutoConnectJellyfinRemote: () => deps.shouldAutoConnectJellyfinRemote(), startJellyfinRemoteSession: () => deps.startJellyfinRemoteSession(), + ...(deps.onYomitanExtensionWarmupScheduled + ? { + onYomitanExtensionWarmupScheduled: (promise: Promise) => + deps.onYomitanExtensionWarmupScheduled!(promise), + } + : {}), + ...(deps.onTokenizationWarmupScheduled + ? { + onTokenizationWarmupScheduled: (promise: Promise) => + deps.onTokenizationWarmupScheduled!(promise), + } + : {}), logDebug: deps.logDebug, }); } diff --git a/src/main/runtime/startup-warmups.ts b/src/main/runtime/startup-warmups.ts index fadc8ba..be1e956 100644 --- a/src/main/runtime/startup-warmups.ts +++ b/src/main/runtime/startup-warmups.ts @@ -30,6 +30,8 @@ export function createStartBackgroundWarmupsHandler(deps: { shouldWarmupJellyfinRemoteSession: () => boolean; shouldAutoConnectJellyfinRemote: () => boolean; startJellyfinRemoteSession: () => Promise; + onYomitanExtensionWarmupScheduled?: (promise: Promise) => void; + onTokenizationWarmupScheduled?: (promise: Promise) => void; logDebug?: (message: string) => void; }) { return (): void => { @@ -46,37 +48,42 @@ export function createStartBackgroundWarmupsHandler(deps: { const shouldWarmupTokenization = warmupMecab || warmupYomitanExtension || warmupSubtitleDictionaries; if (shouldWarmupTokenization) { - deps.launchTask('subtitle-tokenization', async () => { - await Promise.all([ - warmupYomitanExtension - ? (async () => { - deps.logDebug?.('[startup-warmup] stage start: yomitan-extension'); - await deps.ensureYomitanExtensionLoaded(); - deps.logDebug?.('[startup-warmup] stage ready: yomitan-extension'); - })() - : Promise.resolve().then(() => { - deps.logDebug?.('[startup-warmup] stage skipped: yomitan-extension'); - }), - warmupMecab - ? (async () => { - deps.logDebug?.('[startup-warmup] stage start: mecab'); - await deps.createMecabTokenizerAndCheck(); - deps.logDebug?.('[startup-warmup] stage ready: mecab'); - })() - : Promise.resolve().then(() => { - deps.logDebug?.('[startup-warmup] stage skipped: mecab'); - }), - warmupSubtitleDictionaries - ? (async () => { - deps.logDebug?.('[startup-warmup] stage start: subtitle-dictionaries'); - await deps.prewarmSubtitleDictionaries(); - deps.logDebug?.('[startup-warmup] stage ready: subtitle-dictionaries'); - })() - : Promise.resolve().then(() => { - deps.logDebug?.('[startup-warmup] stage skipped: subtitle-dictionaries'); - }), - ]); - }); + const yomitanWarmupPromise = warmupYomitanExtension + ? (async () => { + deps.logDebug?.('[startup-warmup] stage start: yomitan-extension'); + await deps.ensureYomitanExtensionLoaded(); + deps.logDebug?.('[startup-warmup] stage ready: yomitan-extension'); + })() + : Promise.resolve().then(() => { + deps.logDebug?.('[startup-warmup] stage skipped: yomitan-extension'); + }); + if (warmupYomitanExtension) { + deps.onYomitanExtensionWarmupScheduled?.(yomitanWarmupPromise); + } + + const tokenizationWarmupPromise = Promise.all([ + yomitanWarmupPromise, + warmupMecab + ? (async () => { + deps.logDebug?.('[startup-warmup] stage start: mecab'); + await deps.createMecabTokenizerAndCheck(); + deps.logDebug?.('[startup-warmup] stage ready: mecab'); + })() + : Promise.resolve().then(() => { + deps.logDebug?.('[startup-warmup] stage skipped: mecab'); + }), + warmupSubtitleDictionaries + ? (async () => { + deps.logDebug?.('[startup-warmup] stage start: subtitle-dictionaries'); + await deps.prewarmSubtitleDictionaries(); + deps.logDebug?.('[startup-warmup] stage ready: subtitle-dictionaries'); + })() + : Promise.resolve().then(() => { + deps.logDebug?.('[startup-warmup] stage skipped: subtitle-dictionaries'); + }), + ]).then(() => {}); + deps.onTokenizationWarmupScheduled?.(tokenizationWarmupPromise); + deps.launchTask('subtitle-tokenization', () => tokenizationWarmupPromise); } if (warmupJellyfinRemoteSession && autoConnectJellyfinRemote) { deps.launchTask('jellyfin-remote-session', async () => {