feat(plugins): add versioned plugin API with media snapshots and provena

- Add standalone src/plugin-api.ts types and manifest apiVersion check
- Add ctx.media playback snapshots, ctx.signal, ctx.onDispose, and awaitable ctx.mpv.request
- Bound async activation; actions wait only for their own plugin
- card-mined now carries per-note provenance (media + subtitle captured at mining start) through merges and YouTube queues
- Port episode-tags and auto-jimaku to provenance and media snapshots; drop mining-goal docs
This commit is contained in:
sudacode committed 2026-10-29 22:52:56 -07:00
1 parent 10424a57d5
commit 26e93c2344
28 files changed
+1235 -421

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
{
"name": "AI Explain",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js"
}
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Anki Sync",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js"
}
+11 -18
View File
@@ -17,8 +17,7 @@ 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;
let playbackId = null;
function hasJapaneseSubtitle(trackList) {
return trackList.some(
@@ -29,29 +28,23 @@ function activate(ctx) {
);
}
async function check(mediaPath, checkGeneration) {
async function check(media) {
cancelCheck = null;
const [currentPath, trackList] = await Promise.all([
ctx.mpv.getProperty('path'),
ctx.mpv.getProperty('track-list'),
]);
// 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;
const trackList = await ctx.mpv.getProperty('track-list');
if (ctx.media.current()?.playbackId !== media.playbackId || !Array.isArray(trackList)) return;
if (hasJapaneseSubtitle(trackList)) return;
ctx.log.info(`No Japanese subtitles for ${mediaPath}; running ${settings.action}.`);
ctx.log.info(`No Japanese subtitles for ${media.path}; running ${settings.action}.`);
await ctx.actions.run(settings.action);
}
ctx.mpv.on('media-path-change', ({ path: mediaPath }) => {
generation += 1;
ctx.media.on('change', (media) => {
if (media?.playbackId === playbackId) return;
playbackId = media?.playbackId ?? null;
cancelCheck?.();
cancelCheck = null;
if (!mediaPath) return;
if (!settings.streams && /^[a-z]+:\/\//i.test(mediaPath)) return;
const checkGeneration = generation;
cancelCheck = ctx.after(settings.delayMs, () => check(mediaPath, checkGeneration));
if (!media) return;
if (!settings.streams && /^[a-z]+:\/\//i.test(media.path)) return;
cancelCheck = ctx.after(settings.delayMs, () => check(media));
});
}
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Auto Jimaku",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js"
}
+8 -45
View File
@@ -59,9 +59,6 @@ function renderTag(template, values) {
return levels.join('::');
}
// 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)) {
@@ -79,51 +76,17 @@ function renderTags(templates, name) {
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 }) => {
ctx.anki.on('card-mined', async ({ notes }) => {
// Notes from the same file share one addTags call.
const notesByName = new Map();
for (const noteId of noteIds) {
const name = nameAt(noteId);
for (const { noteId, provenance } of notes) {
const name = mediaName(provenance?.media?.path, provenance?.media?.title);
if (!name) {
ctx.log.debug(`Note ${noteId} was not added during a file SubMiner saw; not tagging it.`);
ctx.log.debug(`Note ${noteId} has no known media source; not tagging it.`);
continue;
}
notesByName.set(name, [...(notesByName.get(name) ?? []), noteId]);
if (!notesByName.has(name)) notesByName.set(name, new Set());
notesByName.get(name).add(noteId);
}
for (const [name, notes] of notesByName) {
@@ -133,8 +96,8 @@ function activate(ctx) {
continue;
}
try {
await ctx.anki.invoke('addTags', { notes, tags: tags.join(' ') });
ctx.log.info(`Tagged ${notes.length} note(s): ${tags.join(' ')}`);
await ctx.anki.invoke('addTags', { notes: [...notes], tags: tags.join(' ') });
ctx.log.info(`Tagged ${notes.size} note(s): ${tags.join(' ')}`);
if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`);
} catch (error) {
ctx.log.warn(`Tagging failed: ${error.message}`);
+1
View File
@@ -1,5 +1,6 @@
{
"name": "Episode Tags",
"version": "1.0.0",
"apiVersion": 1,
"main": "main.js"
}