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

+342 -21
View File
@@ -11,8 +11,10 @@ import type { SessionActionDispatchRequest } from '../../../types/runtime';
import { createPluginHost, type PluginHostDeps } from './plugin-host';
import type {
PluginConfigEntry,
PluginContext,
PluginLogger,
PluginMpvEventName,
PluginMpvEventMap,
PluginWindowRequest,
} from './types';
@@ -66,6 +68,7 @@ function createHarness(
isMpvConnected: () => true,
sendMpvCommand: () => {},
requestMpvProperty: async () => null,
requestMpvCommand: async () => null,
ensureMpvRunning: async () => true,
invokeAnkiConnect: async () => null,
dispatchSessionAction: async (request) => {
@@ -106,14 +109,27 @@ const enabled = (settings: Record<string, unknown> = {}): PluginConfigEntry => (
settings,
});
function deferred() {
let resolve = () => {};
const promise = new Promise<void>((done) => {
resolve = done;
});
return { promise, resolve: () => resolve() };
}
function createFakeMpvClient() {
const bus = new EventEmitter();
return {
on: (event: PluginMpvEventName, listener: (payload: unknown) => void) =>
bus.on(event, listener),
off: (event: PluginMpvEventName, listener: (payload: unknown) => void) =>
bus.off(event, listener),
emit: (event: PluginMpvEventName, payload: unknown) => bus.emit(event, payload),
on: <E extends PluginMpvEventName>(
event: E,
listener: (payload: PluginMpvEventMap[E]) => void,
) => bus.on(event, listener),
off: <E extends PluginMpvEventName>(
event: E,
listener: (payload: PluginMpvEventMap[E]) => void,
) => bus.off(event, listener),
emit: <E extends PluginMpvEventName>(event: E, payload: PluginMpvEventMap[E]) =>
bus.emit(event, payload),
};
}
@@ -280,55 +296,180 @@ test('events follow a replacement mpv client', async () => {
}
});
test('unload waits for an in-progress load so late plugins are unloaded too', async () => {
test('unload skips modules whose manifest is still being read and prevents later loads', async () => {
const h = createHarness({ late: enabled() });
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'late', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.mpv.on('pause-change', () => ctx.notify('still listening'));
return () => ctx.notify('deactivated');
};`,
'main.cjs': `throw new Error('module must not execute after unload starts');`,
});
h.host.attachMpvClient(client);
// The plugin is still reading its manifest when unload starts.
void h.host.loadAll();
const loading = h.host.loadAll();
await h.host.unloadAll();
await loading;
await h.host.loadAll();
client.emit('pause-change', { paused: true });
assert.deepEqual(h.notifications, ['deactivated']);
assert.deepEqual(h.notifications, []);
assert.deepEqual(h.errors, []);
} finally {
h.cleanup();
}
});
test('a plugin that finishes activating after unload gave up unloads itself', async () => {
const h = createHarness({ slow: enabled() }, { deactivateTimeoutMs: 20 });
const started = deferred();
const resume = deferred();
const cleaned = deferred();
let deactivated = 0;
const h = createHarness(
{ slow: enabled() },
{
deactivateTimeoutMs: 20,
loadModule: () => ({
activate: async (ctx) => {
started.resolve();
await resume.promise;
ctx.mpv.on('pause-change', () => ctx.notify('still listening'));
return async () => {
deactivated++;
await Promise.resolve();
cleaned.resolve();
};
},
}),
},
);
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'slow', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = async (ctx) => {
await new Promise((resolve) => setTimeout(resolve, 60));
ctx.mpv.on('pause-change', () => ctx.notify('still listening'));
return () => ctx.notify('deactivated');
};`,
});
h.host.attachMpvClient(client);
const loading = h.host.loadAll();
await started.promise;
await h.host.unloadAll();
resume.resolve();
await loading;
await cleaned.promise;
await h.host.unloadAll();
client.emit('pause-change', { paused: true });
assert.deepEqual(h.notifications, ['deactivated']);
assert.equal(deactivated, 1);
assert.deepEqual(h.notifications, []);
assert.deepEqual(h.errors, []);
} finally {
h.cleanup();
}
});
test('unload awaits asynchronous cleanup once when activation finishes during unload', async () => {
const started = deferred();
const resume = deferred();
const deactivating = deferred();
const finish = deferred();
let deactivated = 0;
const h = createHarness(
{ slow: enabled() },
{
loadModule: () => ({
activate: async (ctx) => {
started.resolve();
await resume.promise;
return async () => {
deactivated++;
deactivating.resolve();
await finish.promise;
await ctx.anki.invoke('sync');
ctx.notify('cleaned');
};
},
}),
},
);
try {
writePlugin(h.root, 'slow', { 'plugin.json': '{"main":"main.cjs"}' });
const loading = h.host.loadAll();
await started.promise;
let unloaded = false;
const unloading = Promise.all([h.host.unloadAll(), h.host.unloadAll()]).then(() => {
unloaded = true;
});
resume.resolve();
await deactivating.promise;
await new Promise((resolve) => setImmediate(resolve));
assert.equal(unloaded, false);
assert.equal(deactivated, 1);
finish.resolve();
await unloading;
await loading;
assert.deepEqual(h.notifications, ['cleaned']);
assert.deepEqual(h.errors, []);
} finally {
finish.resolve();
resume.resolve();
await h.host.unloadAll();
h.cleanup();
}
});
test('a timed-out plugin cannot call app APIs after disposal', async () => {
let context: PluginContext | undefined;
const calls: string[] = [];
const h = createHarness(
{ stuck: enabled() },
{
activateTimeoutMs: 10,
loadModule: () => ({
activate: (ctx) => {
context = ctx;
return new Promise(() => {});
},
}),
sendMpvCommand: () => {
calls.push('command');
},
requestMpvCommand: async () => {
calls.push('request');
},
requestMpvProperty: async () => {
calls.push('property');
},
ensureMpvRunning: async () => {
calls.push('start');
return true;
},
invokeAnkiConnect: async () => {
calls.push('anki');
},
},
);
try {
writePlugin(h.root, 'stuck', { 'plugin.json': '{"main":"main.cjs"}' });
await h.host.loadAll();
assert.ok(context);
const ctx = context;
assert.equal(ctx.signal.aborted, true);
assert.throws(() => ctx.mpv.command(['stop']), /disposed/);
assert.throws(() => ctx.notify('late'), /disposed/);
await assert.rejects(ctx.mpv.request(['stop']), /disposed/);
await assert.rejects(ctx.mpv.getProperty('path'), /disposed/);
await assert.rejects(ctx.mpv.ensureRunning(), /disposed/);
await assert.rejects(ctx.anki.invoke('sync'), /disposed/);
await assert.rejects(ctx.actions.run('openJimaku'), /disposed/);
assert.deepEqual(calls, []);
assert.deepEqual(h.notifications, []);
assert.deepEqual(h.sessionActions, []);
} finally {
await h.host.unloadAll();
h.cleanup();
}
});
test('runAction waits for a load that is still in progress', async () => {
const h = createHarness({ slow: enabled() });
try {
@@ -349,6 +490,186 @@ test('runAction waits for a load that is still in progress', async () => {
}
});
test('a stuck activation cannot block another plugin and times out its own registrations', async () => {
const h = createHarness({ stuck: enabled(), healthy: enabled() }, { activateTimeoutMs: 30 });
try {
writePlugin(h.root, 'stuck', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.actions.register('go', () => ctx.notify('stuck ran'));
return new Promise(() => {});
};`,
});
writePlugin(h.root, 'healthy', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => ctx.actions.register('go', () => ctx.notify('healthy ran'));`,
});
const loading = h.host.loadAll();
assert.equal(await h.host.runAction('healthy/go'), true);
assert.deepEqual(h.notifications, ['healthy ran']);
assert.deepEqual(h.errors, []);
await loading;
assert.equal(await h.host.runAction('stuck/go'), false);
assert.match(h.errors[0]!, /timed out/);
} finally {
await h.host.unloadAll();
h.cleanup();
}
});
test('lifetime aborts before deactivate and disposes early and late registrations once', async () => {
const events: string[] = [];
const h = createHarness(
{ lifetime: enabled() },
{
loadModule: () => ({
activate: (ctx) => {
const dispose = ctx.onDispose(() => events.push('early'));
dispose();
dispose();
ctx.onDispose(() => events.push('cleanup'));
ctx.signal.addEventListener('abort', () => events.push('aborted'));
return () => {
events.push('deactivate aborted=' + ctx.signal.aborted);
ctx.onDispose(() => events.push('late cleanup'));
ctx.after(0, () => events.push('leaked timer'));
ctx.actions.register('late', () => events.push('leaked action'));
};
},
}),
},
);
try {
writePlugin(h.root, 'lifetime', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
});
await h.host.loadAll();
await h.host.unloadAll();
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(await h.host.runAction('lifetime/late'), false);
assert.deepEqual(events, [
'early',
'aborted',
'deactivate aborted=true',
'late cleanup',
'cleanup',
]);
} finally {
h.cleanup();
}
});
test('media subscriptions replay the initial state and follow repeated loads and disconnects', async () => {
const h = createHarness({ media: enabled() });
const client = createFakeMpvClient();
try {
writePlugin(h.root, 'media', {
'plugin.json': JSON.stringify({ main: 'main.cjs' }),
'main.cjs': `exports.activate = (ctx) => {
ctx.media.on('change', (media) => ctx.notify(JSON.stringify(media)));
};`,
});
h.host.attachMpvClient(client);
client.emit('media-path-change', { path: 'episode.mkv' });
client.emit('media-title-change', { title: 'Episode 1' });
await h.host.loadAll();
const first = h.host.getMediaSnapshot();
assert.ok(first);
assert.deepEqual(h.notifications, [JSON.stringify(first)]);
client.emit('file-loaded', undefined);
client.emit('file-loaded', undefined);
assert.notEqual(h.host.getMediaSnapshot()?.playbackId, first.playbackId);
assert.equal(first.title, 'Episode 1');
client.emit('media-path-change', { path: 'next.mkv' });
assert.equal(h.host.getMediaSnapshot()?.title, null);
client.emit('connection-change', { connected: false });
assert.equal(h.host.getMediaSnapshot(), null);
await h.host.unloadAll();
const count = h.notifications.length;
client.emit('media-path-change', { path: 'later.mkv' });
assert.equal(h.notifications.length, count);
} finally {
await h.host.unloadAll();
h.cleanup();
}
});
test('incompatible API versions are rejected before the entry module executes', async () => {
const h = createHarness({ future: enabled() });
try {
writePlugin(h.root, 'future', {
'plugin.json': JSON.stringify({ apiVersion: 999, main: 'main.cjs' }),
'main.cjs': `throw new Error('should never execute');`,
});
await h.host.loadAll();
assert.match(h.errors[0]!, /requires API 999/);
assert.equal(h.errors.length, 1);
} finally {
h.cleanup();
}
});
test('a malformed manifest version cannot prevent compatible plugins from loading', async () => {
const h = createHarness({ bad: enabled(), good: enabled() });
try {
writePlugin(h.root, 'bad', {
'plugin.json': JSON.stringify({ main: 'main.cjs', apiVersion: { toString: null } }),
'main.cjs': `throw new Error('should never execute');`,
});
writePlugin(h.root, 'good', {
'plugin.json': JSON.stringify({ main: 'main.cjs', apiVersion: 1 }),
'main.cjs': `exports.activate = (ctx) => { ctx.actions.register('go', () => ctx.notify('good')); };`,
});
await h.host.loadAll();
assert.equal(await h.host.runAction('good/go'), true);
assert.deepEqual(h.notifications, ['good']);
assert.deepEqual(h.errors, ['plugin.json "apiVersion" must be a positive integer.']);
} finally {
await h.host.unloadAll();
h.cleanup();
}
});
test('episode-tags uses mining provenance for a kept note older than this playback', async () => {
const calls: unknown[] = [];
const h = createHarness(
{ tags: enabled() },
{
invokeAnkiConnect: async (action, params) => {
calls.push({ action, params });
},
},
);
try {
writePlugin(h.root, 'tags', {
'plugin.json': JSON.stringify({ main: 'main.cjs', apiVersion: 1 }),
'main.cjs': `module.exports = require(${JSON.stringify(path.resolve(__dirname, '../../../../assets/plugins/episode-tags/main.js'))});`,
});
await h.host.loadAll();
h.host.emitAnkiEvent('card-mined', {
noteIds: [7],
notes: [
{
originalNoteId: 1234,
noteId: 7,
provenance: {
minedAt: 1234,
media: { playbackId: 1, path: 'Show - 02.mkv', title: null },
subtitle: null,
},
},
],
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(calls, [
{ action: 'addTags', params: { notes: [7], tags: 'show::Show::E02' } },
]);
} finally {
await h.host.unloadAll();
h.cleanup();
}
});
test('each plugin gets its own data folder, created before activate', async () => {
const h = createHarness({ keeper: enabled() });
try {
@@ -394,13 +715,13 @@ test('anki card-mined reaches listeners until unload, and invoke forwards to Ank
});
await h.host.loadAll();
h.host.emitAnkiEvent('card-mined', { noteIds: [1, 2] });
h.host.emitAnkiEvent('card-mined', { noteIds: [1, 2], notes: [] });
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(h.notifications, ['1,2 synced']);
assert.deepEqual(invoked, [['sync', {}, { timeoutMs: 5 }]]);
await h.host.unloadAll();
h.host.emitAnkiEvent('card-mined', { noteIds: [3] });
h.host.emitAnkiEvent('card-mined', { noteIds: [3], notes: [] });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(h.notifications.length, 1);
} finally {