From 10424a57d5b1a41bab260e8750414af662cd5110 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 27 Oct 2026 22:41:32 -0700 Subject: [PATCH] fix(plugins): tag cards with the episode playing when they were added - episode-tags: match each note id to the file history instead of the current media, so late card-mined events (YouTube media, field grouping) get the right episode - episode-tags: group notes per file into one addTags call; skip cards added before the plugin started - auto-jimaku: track a generation counter so a pending check goes stale when the same file reloads - docs: describe the per-card episode matching in plugins.md --- assets/plugins/auto-jimaku/main.js | 12 +++- assets/plugins/episode-tags/main.js | 99 ++++++++++++++++++++--------- docs-site/plugins.md | 2 +- 3 files changed, 78 insertions(+), 35 deletions(-) diff --git a/assets/plugins/auto-jimaku/main.js b/assets/plugins/auto-jimaku/main.js index 80762e24..2e46549b 100644 --- a/assets/plugins/auto-jimaku/main.js +++ b/assets/plugins/auto-jimaku/main.js @@ -17,6 +17,8 @@ function activate(ctx) { const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; const languages = new Set(settings.languages.map((lang) => lang.toLowerCase())); let cancelCheck = null; + // Bumped on every file change so a check still waiting on mpv knows it is stale. + let generation = 0; function hasJapaneseSubtitle(trackList) { return trackList.some( @@ -27,13 +29,15 @@ function activate(ctx) { ); } - async function check(mediaPath) { + async function check(mediaPath, checkGeneration) { cancelCheck = null; const [currentPath, trackList] = await Promise.all([ ctx.mpv.getProperty('path'), ctx.mpv.getProperty('track-list'), ]); - // Another file started while we waited; its own check is already scheduled. + // Another file started while we waited, even if it was this one again; its own check is + // already scheduled. + if (checkGeneration !== generation) return; if (currentPath !== mediaPath || !Array.isArray(trackList)) return; if (hasJapaneseSubtitle(trackList)) return; ctx.log.info(`No Japanese subtitles for ${mediaPath}; running ${settings.action}.`); @@ -41,11 +45,13 @@ function activate(ctx) { } ctx.mpv.on('media-path-change', ({ path: mediaPath }) => { + generation += 1; cancelCheck?.(); cancelCheck = null; if (!mediaPath) return; if (!settings.streams && /^[a-z]+:\/\//i.test(mediaPath)) return; - cancelCheck = ctx.after(settings.delayMs, () => check(mediaPath)); + const checkGeneration = generation; + cancelCheck = ctx.after(settings.delayMs, () => check(mediaPath, checkGeneration)); }); } diff --git a/assets/plugins/episode-tags/main.js b/assets/plugins/episode-tags/main.js index 775c03d9..a15b0f79 100644 --- a/assets/plugins/episode-tags/main.js +++ b/assets/plugins/episode-tags/main.js @@ -59,11 +59,10 @@ function renderTag(template, values) { return levels.join('::'); } -async function currentMediaName(ctx) { - const [mediaPath, title] = await Promise.all([ - ctx.mpv.getProperty('path'), - ctx.mpv.getProperty('media-title'), - ]); +// Files remembered for matching cards to what was playing; older cards are not tagged. +const MAX_HISTORY = 50; + +function mediaName(mediaPath, title) { // A stream URL's last segment is usually an id, so streams use the title mpv shows. if (typeof mediaPath === 'string' && mediaPath && !/^[a-z]+:\/\//i.test(mediaPath)) { return path.basename(mediaPath); @@ -71,38 +70,76 @@ async function currentMediaName(ctx) { return typeof title === 'string' && title ? title : null; } +function renderTags(templates, name) { + const { show, season, episode } = parseMediaName(name); + const ep = episode ? `${season ? `S${season}` : ''}E${episode}` : null; + const values = { show, season, episode, ep }; + return [...new Set(templates.map((template) => renderTag(template, values)))].filter(Boolean); +} + function activate(ctx) { const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; + // Files played since activation, oldest first. An Anki note id is the time the note was + // added in milliseconds, so each card is tagged with the file playing when it was added, + // even when card-mined fires after playback moved on (YouTube media, field grouping). + const history = []; + + function remember(mediaPath, since) { + history.push({ since, path: mediaPath || null, title: null }); + if (history.length > MAX_HISTORY) history.shift(); + } + + function nameAt(noteId) { + for (let i = history.length - 1; i >= 0; i -= 1) { + const entry = history[i]; + if (entry.since <= noteId) return mediaName(entry.path, entry.title); + } + return null; + } + + ctx.mpv.on('media-path-change', ({ path: mediaPath }) => remember(mediaPath, Date.now())); + // SubMiner reads the title back after each path change, so it lands on the new file. + ctx.mpv.on('media-title-change', ({ title }) => { + const latest = history.at(-1); + if (latest) latest.title = title; + }); + + // A file may already be playing when the plugin starts. + const startedAt = Date.now(); + Promise.all([ctx.mpv.getProperty('path'), ctx.mpv.getProperty('media-title')]) + .then(([mediaPath, title]) => { + if (history.length > 0 || typeof mediaPath !== 'string' || !mediaPath) return; + remember(mediaPath, startedAt); + history[0].title = typeof title === 'string' ? title : null; + }) + .catch((error) => ctx.log.debug(`Could not read the playing media: ${error.message}`)); ctx.anki.on('card-mined', async ({ noteIds }) => { - if (noteIds.length === 0) return; - let name; - try { - name = await currentMediaName(ctx); - } catch (error) { - ctx.log.warn(`Could not read the playing media: ${error.message}`); - return; - } - if (!name) return; - - const { show, season, episode } = parseMediaName(name); - const ep = episode ? `${season ? `S${season}` : ''}E${episode}` : null; - const values = { show, season, episode, ep }; - const tags = [...new Set(settings.tags.map((template) => renderTag(template, values)))].filter( - Boolean, - ); - if (tags.length === 0) { - ctx.log.debug(`No tags for "${name}".`); - return; + // Notes from the same file share one addTags call. + const notesByName = new Map(); + for (const noteId of noteIds) { + const name = nameAt(noteId); + if (!name) { + ctx.log.debug(`Note ${noteId} was not added during a file SubMiner saw; not tagging it.`); + continue; + } + notesByName.set(name, [...(notesByName.get(name) ?? []), noteId]); } - try { - await ctx.anki.invoke('addTags', { notes: noteIds, tags: tags.join(' ') }); - ctx.log.info(`Tagged ${noteIds.length} note(s): ${tags.join(' ')}`); - if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`); - } catch (error) { - ctx.log.warn(`Tagging failed: ${error.message}`); - ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' }); + for (const [name, notes] of notesByName) { + const tags = renderTags(settings.tags, name); + if (tags.length === 0) { + ctx.log.debug(`No tags for "${name}".`); + continue; + } + try { + await ctx.anki.invoke('addTags', { notes, tags: tags.join(' ') }); + ctx.log.info(`Tagged ${notes.length} note(s): ${tags.join(' ')}`); + if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`); + } catch (error) { + ctx.log.warn(`Tagging failed: ${error.message}`); + ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' }); + } } }); } diff --git a/docs-site/plugins.md b/docs-site/plugins.md index b1b3a174..f28ff79e 100644 --- a/docs-site/plugins.md +++ b/docs-site/plugins.md @@ -74,7 +74,7 @@ Anki must be running and logged in to AnkiWeb. Cards mined from the stats dashbo ## Example: tag cards with the episode -[`assets/plugins/episode-tags`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/episode-tags) tags each mined card with the show and episode it came from, such as `show::Sousou_no_Frieren::E07`. It reads them from the file name (`[Group] Show - 07 (1080p).mkv`, `Show.S01E07.mkv`, `Show 第7話.mkv`), or from the title mpv shows for streams. Spaces become `_`. +[`assets/plugins/episode-tags`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/episode-tags) tags each mined card with the show and episode it came from, such as `show::Sousou_no_Frieren::E07`. It reads them from the file name (`[Group] Show - 07 (1080p).mkv`, `Show.S01E07.mkv`, `Show 第7話.mkv`), or from the title mpv shows for streams. Each card is tagged with the file that was playing when it was added, so a card that finishes later (YouTube media, field grouping) still gets its own episode, and cards added before the plugin started are left alone. Spaces become `_`. | Setting | Default | What it does | | -------- | ------------------------ | ----------------------------------------------- |