From 8b9e43760737f60cf94f7483fcb7e00d6560ee91 Mon Sep 17 00:00:00 2001 From: sudacode Date: Fri, 30 Oct 2026 00:12:25 -0700 Subject: [PATCH] fix(plugins): apply sub-delay to plugin current subtitle lookup - subtitles.current() maps playback time onto the subtitle clock so retimed and repeated lines resolve to the right cue - Add e2e coverage for repeated lines under sub-delay - Document the timing behavior and update the changelog fragment --- changes/plugin-system.md | 2 +- docs-site/plugins.md | 2 ++ e2e/plugins.e2e.test.ts | 53 ++++++++++++++++++++++++++++++++++++++++ src/main.ts | 5 +++- src/plugin-api.test.ts | 2 +- 5 files changed, 61 insertions(+), 3 deletions(-) diff --git a/changes/plugin-system.md b/changes/plugin-system.md index ea990e66..7afcc490 100644 --- a/changes/plugin-system.md +++ b/changes/plugin-system.md @@ -7,4 +7,4 @@ area: plugins - Added an example `episode-tags` plugin that tags mined cards with the show and episode, such as `show::Frieren::S01E07`. - Added an example `auto-jimaku` plugin that opens the Jimaku search when a file starts without a Japanese subtitle track. - Added a versioned plugin API with standalone authoring types, cached playback snapshots, lifecycle cancellation and cleanup, and awaitable mpv commands. Async activation is bounded and actions wait only for their own plugin. Shutdown skips pending loads, runs each deactivation once, and blocks calls from disposed plugins. -- Mining events preserve original and kept note ids with the media and subtitle captured when mining starts, so delayed enrichment and merges into older notes keep the source episode. +- Mining events preserve original and kept note ids with the media and subtitle captured when mining starts, so delayed enrichment and merges into older notes keep the source episode. Plugin subtitle lookups account for subtitle delay and return timing on the subtitle file's timeline. diff --git a/docs-site/plugins.md b/docs-site/plugins.md index 181093fa..b359e746 100644 --- a/docs-site/plugins.md +++ b/docs-site/plugins.md @@ -190,6 +190,8 @@ Anki events: `card-mined` gives `{ noteIds, notes }` once SubMiner has finished Each `notes` entry has `{ originalNoteId, noteId, provenance }`. `originalNoteId` identifies the note before merges; `noteId` is the note that kept its content. `provenance` is `{ minedAt, media, subtitle }`, captured before asynchronous creation or the AnkiConnect proxy's upstream request. `minedAt` is Unix time in milliseconds. `media` uses the snapshot format above, and `subtitle` uses `{ text, startTime, endTime }`. Either may be `null`. Polling cannot establish the original source, so its provenance is `null` rather than the currently playing file. +`subtitles.current()` resolves parsed cues using the current subtitle delay and returns their times on the subtitle file's timeline. + Several original notes can merge into one kept note. `notes` preserves each source, while `noteIds` contains unique kept ids. Existing listeners using only `noteIds` continue to work. Cards mined from the stats dashboard do not emit this event. `anki.invoke` takes `{ timeoutMs }` (default 10 seconds), rejects on an Anki error, and never retries, so an action like `sync` or `addNote` runs at most once. diff --git a/e2e/plugins.e2e.test.ts b/e2e/plugins.e2e.test.ts index d9378dc1..b96f42a9 100644 --- a/e2e/plugins.e2e.test.ts +++ b/e2e/plugins.e2e.test.ts @@ -21,6 +21,7 @@ exports.activate = (ctx) => { ctx.onDispose(() => ipcMain.removeHandler('e2e-probe:info')); ctx.media.on('change', (media) => ctx.log.info('probe media ' + JSON.stringify(media))); ctx.anki.on('card-mined', ({ notes }) => ctx.log.info('probe mined ' + JSON.stringify(notes))); + ctx.actions.register('subtitle', () => ctx.log.info('probe subtitle ' + JSON.stringify(ctx.subtitles.current()))); ctx.actions.register('request', async () => { const title = await ctx.mpv.request(['get_property', 'media-title']); ctx.log.info('probe request ' + JSON.stringify(title)); @@ -224,6 +225,58 @@ test('plugins receive mpv end-file events', { timeout: TEST_TIMEOUT_MS }, async ); }); +test( + 'plugin subtitle lookup uses the subtitle clock for repeated retimed lines', + { timeout: TEST_TIMEOUT_MS }, + async () => { + const { session, overlay } = await useSession(); + const text = FIXTURE_CUES[1].text; + const subtitlePath = path.join(session.root, 'repeated.srt'); + fs.writeFileSync( + subtitlePath, + `1\n00:00:01,000 --> 00:00:03,000\n${text}\n\n2\n00:00:06,000 --> 00:00:08,000\n${text}\n`, + ); + const originalSid = await session.mpv.command('get_property', 'sid'); + try { + await session.mpv.command('seek', 0, 'absolute+exact'); + await session.mpv.command('sub-add', subtitlePath, 'select'); + await session.mpv.command('set_property', 'sub-delay', 5); + await session.runAppCommand('--toggle-subtitle-sidebar'); + await overlay.waitFor( + `document.querySelectorAll('#subtitleSidebarList .subtitle-sidebar-item').length === 2`, + { description: 'the repeated subtitle cues to be parsed' }, + ); + await session.mpv.command('seek', 7, 'absolute+exact'); + await overlay.waitFor( + `document.getElementById('subtitleRoot').textContent === ${JSON.stringify(text)}`, + { description: 'the first delayed line to be visible' }, + ); + await session.runAppCommand('--plugin-action', 'e2e-probe/subtitle'); + const line = await waitUntil( + () => + fs + .readFileSync(path.join(session.root, 'app.log'), 'utf8') + .split('\n') + .find((entry) => entry.includes('probe subtitle ')), + { description: 'the plugin to report its current subtitle' }, + ); + assert.ok(line); + assert.deepEqual( + JSON.parse(line.slice(line.indexOf('probe subtitle ') + 'probe subtitle '.length)), + { + text, + startTime: 1, + endTime: 3, + }, + ); + } finally { + await session.mpv.command('set_property', 'sub-delay', 0); + await session.mpv.command('set_property', 'sid', originalSid); + await session.runAppCommand('--toggle-subtitle-sidebar'); + } + }, +); + test( 'plugin requests report command failures and mined notes carry the captured source', { timeout: TEST_TIMEOUT_MS }, diff --git a/src/main.ts b/src/main.ts index 3a8bb66b..cfc122b5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3126,7 +3126,10 @@ function getPluginCurrentSubtitle(): PluginSubtitleLine | null { // Same view as mining: the parsed line with its cue timings on the subtitle file's timeline. const resolved = resolvePrimarySubtitle({ liveText: appState.mpvClient?.currentSubText ?? '', - currentTimeSec: Number(appState.mpvClient?.currentTimePos), + currentTimeSec: toSubtitleClock( + Number(appState.mpvClient?.currentTimePos), + appState.mpvClient?.currentSubDelay ?? 0, + ), cues: appState.activeParsedSubtitleCues, }); const resolvedText = resolved?.text.replace(/\n{2,}/g, '\n').trim(); diff --git a/src/plugin-api.test.ts b/src/plugin-api.test.ts index c72e604c..854d7eb3 100644 --- a/src/plugin-api.test.ts +++ b/src/plugin-api.test.ts @@ -5,7 +5,7 @@ import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; -test('plugin authors can use the copied declaration without internal SubMiner sources', () => { +test('plugin declarations work without SubMiner sources', { timeout: 30_000 }, () => { const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugin-types-')); try { copyFileSync(path.join(__dirname, 'plugin-api.ts'), path.join(root, 'subminer.d.ts'));