mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-11-10 04:46:07 -08:00
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:
28 files changed
+1235
-421
No files matched your search
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user