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
This commit is contained in:
sudacode committed 2026-10-27 22:41:32 -07:00
1 parent 15f32b10c7
commit 10424a57d5
3 files changed
+78 -35

No files matched your search

+9 -3
View File
@@ -17,6 +17,8 @@ function activate(ctx) {
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
const languages = new Set(settings.languages.map((lang) => lang.toLowerCase())); const languages = new Set(settings.languages.map((lang) => lang.toLowerCase()));
let cancelCheck = null; 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) { function hasJapaneseSubtitle(trackList) {
return trackList.some( return trackList.some(
@@ -27,13 +29,15 @@ function activate(ctx) {
); );
} }
async function check(mediaPath) { async function check(mediaPath, checkGeneration) {
cancelCheck = null; cancelCheck = null;
const [currentPath, trackList] = await Promise.all([ const [currentPath, trackList] = await Promise.all([
ctx.mpv.getProperty('path'), ctx.mpv.getProperty('path'),
ctx.mpv.getProperty('track-list'), 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 (currentPath !== mediaPath || !Array.isArray(trackList)) return;
if (hasJapaneseSubtitle(trackList)) return; if (hasJapaneseSubtitle(trackList)) return;
ctx.log.info(`No Japanese subtitles for ${mediaPath}; running ${settings.action}.`); 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 }) => { ctx.mpv.on('media-path-change', ({ path: mediaPath }) => {
generation += 1;
cancelCheck?.(); cancelCheck?.();
cancelCheck = null; cancelCheck = null;
if (!mediaPath) return; if (!mediaPath) return;
if (!settings.streams && /^[a-z]+:\/\//i.test(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));
}); });
} }
+68 -31
View File
@@ -59,11 +59,10 @@ function renderTag(template, values) {
return levels.join('::'); return levels.join('::');
} }
async function currentMediaName(ctx) { // Files remembered for matching cards to what was playing; older cards are not tagged.
const [mediaPath, title] = await Promise.all([ const MAX_HISTORY = 50;
ctx.mpv.getProperty('path'),
ctx.mpv.getProperty('media-title'), function mediaName(mediaPath, title) {
]);
// A stream URL's last segment is usually an id, so streams use the title mpv shows. // 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)) { if (typeof mediaPath === 'string' && mediaPath && !/^[a-z]+:\/\//i.test(mediaPath)) {
return path.basename(mediaPath); return path.basename(mediaPath);
@@ -71,38 +70,76 @@ async function currentMediaName(ctx) {
return typeof title === 'string' && title ? title : null; 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) { function activate(ctx) {
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; 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 }) => { ctx.anki.on('card-mined', async ({ noteIds }) => {
if (noteIds.length === 0) return; // Notes from the same file share one addTags call.
let name; const notesByName = new Map();
try { for (const noteId of noteIds) {
name = await currentMediaName(ctx); const name = nameAt(noteId);
} catch (error) { if (!name) {
ctx.log.warn(`Could not read the playing media: ${error.message}`); ctx.log.debug(`Note ${noteId} was not added during a file SubMiner saw; not tagging it.`);
return; continue;
} }
if (!name) return; notesByName.set(name, [...(notesByName.get(name) ?? []), noteId]);
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;
} }
try { for (const [name, notes] of notesByName) {
await ctx.anki.invoke('addTags', { notes: noteIds, tags: tags.join(' ') }); const tags = renderTags(settings.tags, name);
ctx.log.info(`Tagged ${noteIds.length} note(s): ${tags.join(' ')}`); if (tags.length === 0) {
if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`); ctx.log.debug(`No tags for "${name}".`);
} catch (error) { continue;
ctx.log.warn(`Tagging failed: ${error.message}`); }
ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' }); 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' });
}
} }
}); });
} }
+1 -1
View File
@@ -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 ## 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 | | Setting | Default | What it does |
| -------- | ------------------------ | ----------------------------------------------- | | -------- | ------------------------ | ----------------------------------------------- |