mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-10-27 23:35:17 -07:00
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:
3 files changed
+74
-31
No files matched your search
@@ -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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,39 +70,77 @@ async function currentMediaName(ctx) {
|
|||||||
return typeof title === 'string' && title ? title : null;
|
return typeof title === 'string' && title ? title : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function activate(ctx) {
|
function renderTags(templates, name) {
|
||||||
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
|
||||||
|
|
||||||
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 { show, season, episode } = parseMediaName(name);
|
||||||
const ep = episode ? `${season ? `S${season}` : ''}E${episode}` : null;
|
const ep = episode ? `${season ? `S${season}` : ''}E${episode}` : null;
|
||||||
const values = { show, season, episode, ep };
|
const values = { show, season, episode, ep };
|
||||||
const tags = [...new Set(settings.tags.map((template) => renderTag(template, values)))].filter(
|
return [...new Set(templates.map((template) => renderTag(template, values)))].filter(Boolean);
|
||||||
Boolean,
|
}
|
||||||
);
|
|
||||||
if (tags.length === 0) {
|
function activate(ctx) {
|
||||||
ctx.log.debug(`No tags for "${name}".`);
|
const settings = { ...DEFAULT_SETTINGS, ...ctx.settings };
|
||||||
return;
|
// 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 }) => {
|
||||||
|
// 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]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
await ctx.anki.invoke('addTags', { notes: noteIds, tags: tags.join(' ') });
|
await ctx.anki.invoke('addTags', { notes, tags: tags.join(' ') });
|
||||||
ctx.log.info(`Tagged ${noteIds.length} note(s): ${tags.join(' ')}`);
|
ctx.log.info(`Tagged ${notes.length} note(s): ${tags.join(' ')}`);
|
||||||
if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`);
|
if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ctx.log.warn(`Tagging failed: ${error.message}`);
|
ctx.log.warn(`Tagging failed: ${error.message}`);
|
||||||
ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' });
|
ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 |
|
||||||
| -------- | ------------------------ | ----------------------------------------------- |
|
| -------- | ------------------------ | ----------------------------------------------- |
|
||||||
|
|||||||
Reference in new issue
Block a user