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
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "AI Explain",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"name": "Anki Sync",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
{
|
||||
"name": "Auto Jimaku",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -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,5 +1,6 @@
|
||||
{
|
||||
"name": "Episode Tags",
|
||||
"version": "1.0.0",
|
||||
"apiVersion": 1,
|
||||
"main": "main.js"
|
||||
}
|
||||
@@ -6,4 +6,5 @@ area: plugins
|
||||
- Added an example `anki-sync` plugin that syncs Anki to AnkiWeb after you mine cards, once mining goes quiet or playback ends.
|
||||
- Added an example `episode-tags` plugin that tags mined cards with the show and episode, such as `show::Frieren::S01E07`.
|
||||
- Added an example `auto-jimaku` plugin that opens the Jimaku search when a file starts without a Japanese subtitle track.
|
||||
- Added an example `mining-goal` plugin that tracks cards mined per day, notifies you at a daily goal, and shows your streak and the last week in a panel.
|
||||
- Added a versioned plugin API with standalone authoring types, cached playback snapshots, lifecycle cancellation and cleanup, and awaitable mpv commands. Async activation is bounded and actions wait only for their own plugin. Shutdown skips pending loads, runs each deactivation once, and blocks calls from disposed plugins.
|
||||
- Mining events preserve original and kept note ids with the media and subtitle captured when mining starts, so delayed enrichment and merges into older notes keep the source episode.
|
||||
+44
-18
@@ -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
|
||||
|
||||
[`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 `_`.
|
||||
[`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. It uses the source captured when mining started, so delayed media and field grouping into an older note keep the correct episode. Notes with unknown provenance, including cards discovered by polling, are left alone. Spaces become `_`.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
| -------- | ------------------------ | ----------------------------------------------- |
|
||||
@@ -94,31 +94,25 @@ Templates can use `{show}`, `{season}`, `{episode}`, and `{ep}` (`S01E07`, or `E
|
||||
| `delayMs` | `3000` | Wait this long after the file starts, so external subs load |
|
||||
| `streams` | `false` | Also check `http(s)` streams, which Jimaku usually can't search |
|
||||
|
||||
## Example: daily mining goal
|
||||
|
||||
[`assets/plugins/mining-goal`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/mining-goal) counts the cards you mine each day and shows a notification when you reach your goal. Its `stats` action opens a panel with today's count, your streak of days at the goal, and the last 7 days. Counts are kept in `plugin-data/mining-goal/counts.json`.
|
||||
|
||||
| Setting | Default | What it does |
|
||||
| -------------- | ------- | ------------------------------------------------------------------- |
|
||||
| `goal` | `10` | Cards per day |
|
||||
| `dayStartHour` | `4` | Cards mined before this hour count toward the day before, like Anki |
|
||||
| `notify` | `true` | Show a notification when you reach the goal |
|
||||
|
||||
Only cards SubMiner mines count. Cards mined from the stats dashboard or added in Anki don't.
|
||||
|
||||
## Write a plugin
|
||||
|
||||
A plugin folder needs a `plugin.json`:
|
||||
|
||||
```json
|
||||
{ "name": "My Plugin", "version": "1.0.0", "main": "main.js" }
|
||||
{ "name": "My Plugin", "version": "1.0.0", "apiVersion": 1, "main": "main.js" }
|
||||
```
|
||||
|
||||
`main` points to a CommonJS module (`.js`) or an ES module (`.mjs`) that exports `activate(ctx)`. It may return a cleanup function, or the module can export `deactivate()`. On quit, SubMiner waits up to 2 seconds for it, including any promise it returns. Everything a plugin registers through `ctx` (listeners, timers, actions, panels, running programs) is cleaned up automatically when SubMiner quits.
|
||||
`apiVersion` declares the required SubMiner plugin API version. The current version is `1`; omitted values also mean v1 for existing plugins. An unsupported version is rejected before the plugin's entry module runs. `version` is the plugin's own version and does not control compatibility.
|
||||
|
||||
`main` points to a CommonJS module (`.js`) or an ES module (`.mjs`) that exports `activate(ctx)`. Activation may be asynchronous and has a 10-second timeout. A plugin's actions wait for its own activation, so a slow plugin does not hold up other plugins. If activation fails or times out, its registrations are removed and its signal aborts. Synchronous code still shares the app's main thread.
|
||||
|
||||
Activation may return a cleanup function, or the module can export `deactivate()`. On quit, SubMiner aborts `ctx.signal` first and waits up to 2 seconds in total for loading and deactivation. A plugin still reading its files is skipped. Everything a plugin registers through `ctx` is cleaned up automatically. A late activation cannot leave new listeners, timers, or actions behind, and its returned cleanup still runs once. Deactivation can call Anki, mpv, built-in actions, and notifications until disposal. After disposal, those calls throw or reject, including work that resumes after an activation or deactivation timeout.
|
||||
|
||||
```js
|
||||
function activate(ctx) {
|
||||
ctx.mpv.on('media-path-change', ({ path }) => ctx.log.info(`Now playing ${path}`));
|
||||
ctx.media.on('change', (media) => {
|
||||
if (media) ctx.log.info(`Now playing ${media.path}`);
|
||||
});
|
||||
ctx.every(60_000, () => ctx.log.info('Still watching'));
|
||||
ctx.actions.register('hello', () => {
|
||||
const line = ctx.subtitles.current();
|
||||
@@ -131,19 +125,43 @@ module.exports = { activate };
|
||||
|
||||
An error thrown by a plugin is logged and does not crash SubMiner. A plugin that blocks with a long synchronous loop still freezes the app, so keep slow work asynchronous.
|
||||
|
||||
### Types and cleanup
|
||||
|
||||
The public types live in [`src/plugin-api.ts`](https://github.com/ksyasuda/SubMiner/blob/main/src/plugin-api.ts). A build emits `dist/plugin-api.d.ts`; copy that file into your plugin as `subminer.d.ts`, or copy the source file with that name. It has no imports from SubMiner internals. Electron's types must be available to your editor because `ui.openWindow` returns a `BrowserWindow`.
|
||||
|
||||
JavaScript plugins can use the types through JSDoc:
|
||||
|
||||
```js
|
||||
/** @param {import('./subminer').PluginContext} ctx */
|
||||
function activate(ctx) {
|
||||
const { ipcMain } = require('electron');
|
||||
ipcMain.handle(`${ctx.id}:info`, () => ({ media: ctx.media.current() }));
|
||||
ctx.onDispose(() => ipcMain.removeHandler(`${ctx.id}:info`));
|
||||
}
|
||||
|
||||
module.exports = { activate };
|
||||
```
|
||||
|
||||
Use `ctx.signal` with `fetch` or other cancellable work. It aborts when unloading starts, before `deactivate` runs. `ctx.onDispose(fn)` registers synchronous cleanup for resources outside `ctx`, such as file watchers and Electron IPC handlers. The returned function runs the cleanup early, at most once. Registering cleanup after unloading starts runs it immediately. Timers and listeners registered after that point are removed immediately too. Use the returned activation cleanup or `deactivate` for asynchronous teardown, within the shared quit timeout.
|
||||
|
||||
### `ctx` reference
|
||||
|
||||
| Member | What it does |
|
||||
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `id`, `dir`, `settings` | Plugin id, plugin folder path, and `plugins.<id>.settings` |
|
||||
| `apiVersion` | The host's plugin API version, currently `1` |
|
||||
| `signal`, `onDispose(fn)` | Lifetime cancellation and cleanup for resources created outside `ctx` |
|
||||
| `dataDir` | `plugin-data/<id>/` next to your config file, for caches and state. Exists before `activate` runs |
|
||||
| `log.debug/info/warn/error(message)` | Write to the app log |
|
||||
| `every(ms, fn)`, `after(ms, fn)` | Repeating and one-shot timers. Return a function that cancels them |
|
||||
| `mpv.on(event, fn)` | Listen to mpv events (below). Returns an unsubscribe function |
|
||||
| `mpv.command(args)` | Send an mpv command, e.g. `['set', 'pause', 'yes']` |
|
||||
| `mpv.request(args)` | Await an mpv command result; rejects on command error, disconnect, or timeout |
|
||||
| `mpv.getProperty(name)` | Read an mpv property (Promise) |
|
||||
| `mpv.connected` | Whether mpv is connected |
|
||||
| `mpv.ensureRunning()` | Connect to mpv, starting an idle mpv if none is running. Resolves `false` if that fails |
|
||||
| `media.current()` | Cached `{ playbackId, path, title }`, or `null` when there is no connected media |
|
||||
| `media.on('change', fn, options)` | Subscribe to playback snapshots; immediately delivers the current snapshot unless `emitCurrent: false` |
|
||||
| `anki.on(event, fn)` | Listen to Anki events (below). Returns an unsubscribe function |
|
||||
| `anki.invoke(action, params, options)` | Send an AnkiConnect action to `ankiConnect.url`, e.g. `('sync')`. Resolves its result (below) |
|
||||
| `subtitles.current()` | The displayed line as `{ text, startTime, endTime }`, or `null` |
|
||||
@@ -162,8 +180,16 @@ A panel's `signal` aborts when the user closes it or another panel replaces it.
|
||||
|
||||
`ui.openWindow` takes `html` and an optional `preload`, both relative to the plugin folder, plus `title`, `width`, and `height`. Opening the same `html` again focuses the open window. The page runs with context isolation and no Node access. The preload is sandboxed too: it can `require('electron')` but nothing else, so bundle anything more into it. Expose what the page needs from the preload with `contextBridge`, and handle its messages with `ipcMain` from `require('electron')` in the plugin. Prefix channel names with the plugin id. Plugin windows close when SubMiner quits.
|
||||
|
||||
mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, `client-message`, and `end-file`. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`.
|
||||
`media` snapshots are immutable. `playbackId` changes on a new load, including reopening the same path. A title may arrive later with the same id. Disconnecting or ending playback delivers `null`. Use the id to discard stale asynchronous results. Snapshots describe cached playback state; they do not promise that external subtitle discovery has finished.
|
||||
|
||||
Anki events: `card-mined` gives `{ noteIds }` once SubMiner has finished filling in a mined card (sentence, audio, image, and field grouping). For YouTube cards whose media waits on the download, it fires once that media is added or the download fails. With `ankiConnect.behavior.autoUpdateNewCards` off, it fires as soon as SubMiner sees the card. If field grouping merged the card into an older one, you get the older card's id.
|
||||
`mpv.request` accepts string, number, and boolean arguments, e.g. `await ctx.mpv.request(['set_property', 'pause', true])`. It returns mpv's result data and rejects after 4 seconds. A timeout does not cancel the command in mpv, so a slow command may still complete. `mpv.command` remains available for commands whose result you do not need.
|
||||
|
||||
mpv events: `connection-change`, `subtitle-change`, `subtitle-timing`, `time-pos-change`, `duration-change`, `pause-change`, `fullscreen-change`, `media-path-change`, `media-title-change`, `subtitle-track-change`, `subtitle-track-list-change`, `secondary-subtitle-change`, `secondary-subtitle-track-change`, `secondary-subtitle-delay-change`, `secondary-subtitle-visibility`, `client-message`, `file-loaded`, and `end-file`. `file-loaded` has no payload. `end-file` gives `{ reason, playlistEntryId, error }`, where `reason` is `eof`, `stop`, `quit`, `error`, `redirect`, or `unknown`.
|
||||
|
||||
Anki events: `card-mined` gives `{ noteIds, notes }` once SubMiner has finished filling in a mined card (sentence, audio, image, and field grouping). For YouTube cards whose media waits on the download, it fires once that media is added or the download fails. With `ankiConnect.behavior.autoUpdateNewCards` off, it fires as soon as SubMiner sees the card. If field grouping merged the card into an older one, `noteIds` contains the older card's id.
|
||||
|
||||
Each `notes` entry has `{ originalNoteId, noteId, provenance }`. `originalNoteId` identifies the note before merges; `noteId` is the note that kept its content. `provenance` is `{ minedAt, media, subtitle }`, captured before asynchronous creation or the AnkiConnect proxy's upstream request. `minedAt` is Unix time in milliseconds. `media` uses the snapshot format above, and `subtitle` uses `{ text, startTime, endTime }`. Either may be `null`. Polling cannot establish the original source, so its provenance is `null` rather than the currently playing file.
|
||||
|
||||
Several original notes can merge into one kept note. `notes` preserves each source, while `noteIds` contains unique kept ids. Existing listeners using only `noteIds` continue to work. Cards mined from the stats dashboard do not emit this event.
|
||||
|
||||
`anki.invoke` takes `{ timeoutMs }` (default 10 seconds), rejects on an Anki error, and never retries, so an action like `sync` or `addNote` runs at most once.
|
||||
@@ -206,7 +206,7 @@ The same pattern works for n8n or Zapier workflows, Discord notifiers, or append
|
||||
|
||||
## mpv script messages
|
||||
|
||||
SubMiner has no in-app plugin SDK. Besides the streams above, you can drive it from other mpv scripts and from the [launcher CLI](/launcher-script).
|
||||
SubMiner also supports [JavaScript app plugins](/plugins) for playback events, mining events, actions, and custom UI. Besides the streams above, you can drive it from other mpv scripts and from the [launcher CLI](/launcher-script).
|
||||
|
||||
The mpv plugin accepts these script messages:
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Update checks and startup launcher migration share a serialized update-state sto
|
||||
- `subtitle-generation-reference.ts` ranks mpv's loaded text subtitle tracks, excludes signs/songs and forced references, and extracts timing hints with FFmpeg. The overlay and launcher snapshot references only for matching media, including active subtitle delays. Hints guide long-passage cuts with or without VAD; they never limit audio coverage or replace Whisper timestamps.
|
||||
- `src/main/runtime/subtitle-selection.ts` reads and validates mpv subtitle tracks and applies primary/secondary selections. Its opt-in session shortcut opens the shared overlay modal window, with renderer focus and subtitle suppression handled by the modal registry.
|
||||
- `src/shared/session-key-sequences.ts` rejects sequence prefixes reserved by single-key actions. The session-binding compiler reserves configured and built-in overlay keys; `src/main/runtime/session-bindings-runtime.ts` adds active mpv bindings and publishes the effective list to both the plugin artifact and the renderer through `session-bindings:changed`. mpv no-op `ignore` bindings do not reserve prefixes.
|
||||
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them first in the `will-quit` cleanup, before any teardown. Plugins run in the main process with no sandbox. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `ctx.anki` carries the `card-mined` event, fed by `AnkiIntegration.setCardsReadyCallback`: polling, the AnkiConnect proxy, and sentence-card creation report a note once its enrichment finishes (or when they see it, if auto-update is off); `AnkiIntegration` holds a sentence card's report until field grouping finishes, and the pending YouTube media queue reports notes whose media it is still waiting on (directly or through the note field grouping merged them into), which is later than `recordCardsMined` (stats counts at add time). Stats-dashboard mining does not emit it. `ctx.anki.invoke` sends one action to the effective `ankiConnect.url` with no retries. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
|
||||
- `src/core/services/plugins/` owns user plugins: discovery from `<config dir>/plugins/<id>/plugin.json` for ids enabled in `plugins`, the per-plugin `ctx`, guarded callbacks, and `exec` (no shell, AppImage `LD_LIBRARY_PATH` dropped). `main.ts` supplies the host's deps, attaches each new mpv client so plugin listeners survive client replacement, starts plugins from the app-ready path after logging is configured, and unloads them first in the `will-quit` cleanup, before any teardown. Plugins run in the main process with no sandbox. `src/plugin-api.ts` is the standalone public v1 type contract; internal mpv event and session-action types derive from it. Manifests may declare `apiVersion`, which is checked before loading code. Async activation is bounded and actions wait only for their own plugin. The host owns cached media snapshots and playback ids, including repeated loads of the same path. `ctx.signal` aborts as unloading starts; `ctx.onDispose` manages outside resources and late registrations are disposed immediately. Awaitable `ctx.mpv.request` uses the existing request/response transport. Plugin actions are the `runPluginAction` session action (keybinding command `__plugin-action:<id>/<name>`, CLI `--plugin-action <id>/<name>`); `runAction` waits for an in-progress load so a cold-start CLI action is not dropped. Each plugin gets `<config dir>/plugin-data/<id>/` as `ctx.dataDir`. `ctx.anki` carries the `card-mined` event with unique kept `noteIds` and per-original-note records. Sentence-card creation snapshots media and subtitle context before asynchronous work; the proxy snapshots before forwarding an upstream request. `AnkiIntegration` keeps provenance under original ids through merges and queued YouTube jobs; polling uses null provenance. The event is fed by `AnkiIntegration.setCardsReadyCallback`: polling, the AnkiConnect proxy, and sentence-card creation report a note once its enrichment finishes (or when they see it, if auto-update is off); `AnkiIntegration` holds a sentence card's report until field grouping finishes, and the pending YouTube media queue reports notes whose media it is still waiting on (directly or through the note field grouping merged them into), which is later than `recordCardsMined` (stats counts at add time). Stats-dashboard mining does not emit it. `ctx.anki.invoke` sends one action to the effective `ankiConnect.url` with no retries. `src/main/runtime/plugin-window-runtime.ts` owns plugin windows (`ctx.ui.openWindow`): one window per plugin + HTML path, raised above the overlay like the settings windows, destroyed on unload. `src/main/runtime/plugin-panel-runtime.ts` owns the single `plugin-panel` modal: a new panel aborts the previous handle's signal, updates broadcast to both overlay windows keyed by `panelId`, and the latest content is re-sent once the renderer acknowledges the open.
|
||||
- `src/renderer/` owns overlay rendering and input behavior.
|
||||
- `src/config/` owns config definitions, defaults, loading, and resolution.
|
||||
- `src/types/` owns shared cross-runtime contracts via domain entrypoints; `src/types.ts` stays a compatibility barrel.
|
||||
|
||||
+56
-1
@@ -18,6 +18,15 @@ const { ipcMain } = require('electron');
|
||||
|
||||
exports.activate = (ctx) => {
|
||||
ipcMain.handle('e2e-probe:info', () => ({ dataDir: ctx.dataDir, greeting: ctx.settings.greeting }));
|
||||
ctx.onDispose(() => ipcMain.removeHandler('e2e-probe:info'));
|
||||
ctx.media.on('change', (media) => ctx.log.info('probe media ' + JSON.stringify(media)));
|
||||
ctx.anki.on('card-mined', ({ notes }) => ctx.log.info('probe mined ' + JSON.stringify(notes)));
|
||||
ctx.actions.register('request', async () => {
|
||||
const title = await ctx.mpv.request(['get_property', 'media-title']);
|
||||
ctx.log.info('probe request ' + JSON.stringify(title));
|
||||
try { await ctx.mpv.request(['subminer-no-such-command']); }
|
||||
catch { ctx.log.info('probe request rejected'); }
|
||||
});
|
||||
ctx.actions.register('window', () => {
|
||||
ctx.ui.openWindow({ html: 'ui/index.html', preload: 'preload.js', title: 'E2E Probe Window' });
|
||||
});
|
||||
@@ -45,7 +54,6 @@ exports.activate = (ctx) => {
|
||||
)
|
||||
.catch((error) => panel.update({ markdown: 'exec failed: ' + error.message }));
|
||||
});
|
||||
return () => ipcMain.removeHandler('e2e-probe:info');
|
||||
};
|
||||
`;
|
||||
|
||||
@@ -215,3 +223,50 @@ test('plugins receive mpv end-file events', { timeout: TEST_TIMEOUT_MS }, async
|
||||
{ description: 'the plugin to log the end-file reason' },
|
||||
);
|
||||
});
|
||||
|
||||
test(
|
||||
'plugin requests report command failures and mined notes carry the captured source',
|
||||
{ timeout: TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
const { session, overlay } = await useSession();
|
||||
const cue = FIXTURE_CUES[1];
|
||||
await session.mpv.command('seek', cue.at, 'absolute+exact');
|
||||
await overlay.waitFor(
|
||||
`document.getElementById('subtitleRoot').textContent === ${JSON.stringify(cue.text)}`,
|
||||
{ description: 'the mined subtitle to be visible' },
|
||||
);
|
||||
await session.runAppCommand('--plugin-action', 'e2e-probe/request');
|
||||
await waitUntil(
|
||||
() =>
|
||||
fs
|
||||
.readFileSync(path.join(session.root, 'app.log'), 'utf8')
|
||||
.includes('probe request rejected'),
|
||||
{ description: 'mpv to reject an invalid plugin request' },
|
||||
);
|
||||
await session.runAppCommand('--mine-sentence');
|
||||
const log = await waitUntil(
|
||||
() => {
|
||||
const text = fs.readFileSync(path.join(session.root, 'app.log'), 'utf8');
|
||||
return text.includes('probe mined ') ? text : null;
|
||||
},
|
||||
{ description: 'the mining event to reach the plugin' },
|
||||
);
|
||||
assert.ok(log);
|
||||
const line = log.split('\n').find((entry) => entry.includes('probe mined '));
|
||||
assert.ok(line);
|
||||
const notes: unknown = JSON.parse(
|
||||
line.slice(line.indexOf('probe mined ') + 'probe mined '.length),
|
||||
);
|
||||
assert.ok(Array.isArray(notes));
|
||||
assert.equal(notes.length, 1);
|
||||
const note: unknown = notes[0];
|
||||
assert.partialDeepStrictEqual(note, {
|
||||
provenance: {
|
||||
subtitle: { text: cue.text },
|
||||
media: { path: await session.mpv.command('get_property', 'path') },
|
||||
},
|
||||
});
|
||||
assert.ok(note && typeof note === 'object' && 'originalNoteId' in note && 'noteId' in note);
|
||||
assert.equal(note.originalNoteId, note.noteId);
|
||||
},
|
||||
);
|
||||
@@ -6,6 +6,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { AnkiIntegration } from './anki-integration';
|
||||
import type { PluginMinedNote, PluginMiningProvenance } from './plugin-api';
|
||||
import type { MediaInput } from './media-input';
|
||||
import { AnkiConnectConfig } from './types';
|
||||
|
||||
@@ -83,6 +84,7 @@ type IntegrationInternals = {
|
||||
}) => void;
|
||||
rememberMergedNoteIds: (deletedNoteId: number, keptNoteId: number) => void;
|
||||
reportCardsReady: (noteIds: number[]) => void;
|
||||
rememberMiningProvenance: (noteIds: number[], provenance: PluginMiningProvenance | null) => void;
|
||||
};
|
||||
|
||||
/** Named stand-ins for AnkiIntegration's long positional constructor arguments. */
|
||||
@@ -286,6 +288,32 @@ test('AnkiIntegration reports ready cards under the note that kept them after a
|
||||
assert.deepEqual(ready, [[222, 444]]);
|
||||
});
|
||||
|
||||
test('ready events preserve each mining source when multiple notes merge into an older note', () => {
|
||||
const integration = createIntegration({});
|
||||
const ready: PluginMinedNote[][] = [];
|
||||
integration.setCardsReadyCallback((_ids, notes) => ready.push(notes));
|
||||
const internals = internalsOf(integration);
|
||||
const provenance = {
|
||||
minedAt: 1234,
|
||||
media: { playbackId: 1, path: 'Show - 02.mkv', title: null },
|
||||
subtitle: { text: 'テスト', startTime: 1, endTime: 2 },
|
||||
};
|
||||
internals.rememberMiningProvenance([111, 222], provenance);
|
||||
provenance.media.path = 'Show - 03.mkv';
|
||||
internals.rememberMergedNoteIds(111, 7);
|
||||
internals.rememberMergedNoteIds(222, 7);
|
||||
internals.reportCardsReady([111, 222]);
|
||||
assert.deepEqual(
|
||||
ready[0]?.map((note) => [note.originalNoteId, note.noteId, note.provenance?.media?.path]),
|
||||
[
|
||||
[111, 7, 'Show - 02.mkv'],
|
||||
[222, 7, 'Show - 02.mkv'],
|
||||
],
|
||||
);
|
||||
internals.reportCardsReady([333]);
|
||||
assert.equal(ready[1]?.[0]?.provenance, null);
|
||||
});
|
||||
|
||||
// --- Sentence highlighting --------------------------------------------------
|
||||
|
||||
const MPV_SENTENCE = '先日 貴様らが潜入した キールダンジョンから―';
|
||||
|
||||
+58
-3
@@ -17,6 +17,7 @@
|
||||
*/
|
||||
|
||||
import { AnkiConnectClient } from './anki-connect';
|
||||
import type { PluginMinedNote, PluginMiningProvenance } from './plugin-api';
|
||||
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
|
||||
import { MediaGenerator } from './media-generator';
|
||||
import path from 'path';
|
||||
@@ -240,7 +241,9 @@ export class AnkiIntegration {
|
||||
private runtime: AnkiIntegrationRuntime;
|
||||
private aiConfig: AiConfig;
|
||||
private recordCardsMinedCallback: ((count: number, noteIds?: number[]) => void) | null = null;
|
||||
private cardsReadyCallback: ((noteIds: number[]) => void) | null = null;
|
||||
private cardsReadyCallback: ((noteIds: number[], notes: PluginMinedNote[]) => void) | null = null;
|
||||
private miningProvenanceProvider: (() => PluginMiningProvenance | null) | null = null;
|
||||
private miningProvenance = new Map<number, PluginMiningProvenance | null>();
|
||||
// Ready reports collected while createSentenceCard still has field grouping to run.
|
||||
private heldReadyNoteIds: number[] | null = null;
|
||||
private knownWordCacheUpdatedCallback: (() => void) | null = null;
|
||||
@@ -359,13 +362,54 @@ export class AnkiIntegration {
|
||||
if (noteIds.length === 0) return;
|
||||
// Field grouping may have merged a note away; report the note that kept its content.
|
||||
const currentNoteIds = [...new Set(noteIds.map((noteId) => this.resolveCurrentNoteId(noteId)))];
|
||||
const notes: PluginMinedNote[] = [];
|
||||
for (const [originalNoteId, provenance] of this.miningProvenance) {
|
||||
const noteId = this.resolveCurrentNoteId(originalNoteId);
|
||||
if (
|
||||
!currentNoteIds.includes(noteId) ||
|
||||
this.pendingYoutubeMediaQueue.hasPendingNote(originalNoteId) ||
|
||||
this.pendingYoutubeMediaQueue.hasPendingNote(noteId)
|
||||
)
|
||||
continue;
|
||||
notes.push({ originalNoteId, noteId, provenance });
|
||||
this.miningProvenance.delete(originalNoteId);
|
||||
}
|
||||
for (const originalNoteId of noteIds) {
|
||||
const noteId = this.resolveCurrentNoteId(originalNoteId);
|
||||
if (
|
||||
!notes.some((note) => note.noteId === noteId) &&
|
||||
![...this.miningProvenance.keys()].some((id) => this.resolveCurrentNoteId(id) === noteId)
|
||||
) {
|
||||
notes.push({ originalNoteId, noteId, provenance: null });
|
||||
}
|
||||
}
|
||||
if (notes.length === 0) return;
|
||||
try {
|
||||
this.cardsReadyCallback?.(currentNoteIds);
|
||||
this.cardsReadyCallback?.([...new Set(notes.map((note) => note.noteId))], notes);
|
||||
} catch (error) {
|
||||
log.warn('cardsReady callback failed:', (error as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
private rememberMiningProvenance(
|
||||
noteIds: number[],
|
||||
provenance: PluginMiningProvenance | null,
|
||||
): void {
|
||||
for (const noteId of noteIds) {
|
||||
if (!this.miningProvenance.get(noteId)) {
|
||||
this.miningProvenance.set(
|
||||
noteId,
|
||||
provenance &&
|
||||
Object.freeze({
|
||||
...provenance,
|
||||
media: provenance.media && Object.freeze({ ...provenance.media }),
|
||||
subtitle: provenance.subtitle && Object.freeze({ ...provenance.subtitle }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getMediaResolverOptions(): MediaGenerationInputResolverOptions {
|
||||
const options: MediaGenerationInputResolverOptions = {
|
||||
logDebug: (message) => log.debug(message),
|
||||
@@ -473,6 +517,7 @@ export class AnkiIntegration {
|
||||
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
||||
processNewCard: (noteId) => this.processNewCard(noteId),
|
||||
recordCardsAdded: (count, noteIds) => {
|
||||
this.rememberMiningProvenance(noteIds, null);
|
||||
this.recordCardsMinedSafely(count, noteIds, 'polling');
|
||||
},
|
||||
onCardsReady: (noteIds) => this.reportCardsReady(noteIds),
|
||||
@@ -495,6 +540,8 @@ export class AnkiIntegration {
|
||||
const { AnkiConnectProxyServer } =
|
||||
require('./anki-integration/anki-connect-proxy') as typeof import('./anki-integration/anki-connect-proxy');
|
||||
return new AnkiConnectProxyServer({
|
||||
captureMiningProvenance: () => this.miningProvenanceProvider?.() ?? null,
|
||||
onNotesAdded: (noteIds, provenance) => this.rememberMiningProvenance(noteIds, provenance),
|
||||
shouldAutoUpdateNewCards: () => this.config.behavior?.autoUpdateNewCards !== false,
|
||||
processNewCard: (noteId: number) => this.processNewCard(noteId),
|
||||
recordCardsAdded: (count, noteIds) => {
|
||||
@@ -626,6 +673,8 @@ export class AnkiIntegration {
|
||||
recordCardsMinedCallback: (count, noteIds) => {
|
||||
this.recordCardsMinedSafely(count, noteIds, 'card creation');
|
||||
},
|
||||
captureMiningProvenance: () => this.miningProvenanceProvider?.() ?? null,
|
||||
onNoteCreated: (noteId, provenance) => this.rememberMiningProvenance([noteId], provenance),
|
||||
onCardsReady: (noteIds) => this.reportCardsReady(noteIds),
|
||||
reviewMediaTiming: (request) => this.reviewMediaTiming(request),
|
||||
});
|
||||
@@ -1830,10 +1879,16 @@ export class AnkiIntegration {
|
||||
* media, and field grouping), or as soon as polling or the proxy sees it when auto-update is off.
|
||||
* Ids merged away by field grouping are reported as the note that kept them.
|
||||
*/
|
||||
setCardsReadyCallback(callback: ((noteIds: number[]) => void) | null): void {
|
||||
setCardsReadyCallback(
|
||||
callback: ((noteIds: number[], notes: PluginMinedNote[]) => void) | null,
|
||||
): void {
|
||||
this.cardsReadyCallback = callback;
|
||||
}
|
||||
|
||||
setMiningProvenanceProvider(provider: (() => PluginMiningProvenance | null) | null): void {
|
||||
this.miningProvenanceProvider = provider;
|
||||
}
|
||||
|
||||
setKnownWordCacheUpdatedCallback(callback: (() => void) | null): void {
|
||||
this.knownWordCacheUpdatedCallback = callback;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import http from 'node:http';
|
||||
import { once } from 'node:events';
|
||||
import test from 'node:test';
|
||||
import { AnkiConnectProxyServer } from './anki-connect-proxy';
|
||||
import type { PluginMiningProvenance } from '../plugin-api';
|
||||
|
||||
async function waitForCondition(
|
||||
condition: () => boolean,
|
||||
@@ -401,8 +402,11 @@ test('proxy tracks duplicate note ids from addNote request metadata before enric
|
||||
assert.deepEqual(processed, [42]);
|
||||
});
|
||||
|
||||
test('proxy strips SubMiner duplicate metadata before forwarding upstream addNote request', async () => {
|
||||
test('proxy strips duplicate metadata and captures mining provenance before the upstream request', async () => {
|
||||
let upstreamBody = '';
|
||||
let mediaPath = 'Show - 02.mkv';
|
||||
let captureCount = 0;
|
||||
const sources: Array<PluginMiningProvenance | null> = [];
|
||||
const upstream = http.createServer(async (req, res) => {
|
||||
upstreamBody = await new Promise<string>((resolve) => {
|
||||
const chunks: Buffer[] = [];
|
||||
@@ -410,6 +414,7 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
});
|
||||
res.statusCode = 200;
|
||||
mediaPath = 'Show - 03.mkv';
|
||||
res.setHeader('content-type', 'application/json');
|
||||
res.end(JSON.stringify({ result: 42, error: null }));
|
||||
});
|
||||
@@ -421,6 +426,15 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
||||
|
||||
const tracked: Array<{ noteId: number; duplicateNoteIds: number[] }> = [];
|
||||
const proxy = new AnkiConnectProxyServer({
|
||||
captureMiningProvenance: () => {
|
||||
captureCount++;
|
||||
return {
|
||||
minedAt: 1234,
|
||||
media: { playbackId: 1, path: mediaPath, title: null },
|
||||
subtitle: null,
|
||||
};
|
||||
},
|
||||
onNotesAdded: (_ids, provenance) => sources.push(provenance),
|
||||
shouldAutoUpdateNewCards: () => true,
|
||||
processNewCard: async () => undefined,
|
||||
trackAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
|
||||
@@ -475,6 +489,16 @@ test('proxy strips SubMiner duplicate metadata before forwarding upstream addNot
|
||||
await waitForCondition(() => tracked.length === 1);
|
||||
assert.equal(upstreamBody.includes('subminerDuplicateNoteIds'), false);
|
||||
assert.deepEqual(tracked, [{ noteId: 42, duplicateNoteIds: [7, 18] }]);
|
||||
assert.equal(sources[0]?.media?.path, 'Show - 02.mkv');
|
||||
assert.equal(captureCount, 1);
|
||||
const preflight = await fetch(`http://127.0.0.1:${proxyPort}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ action: 'canAddNotes', version: 6, params: { notes: [] } }),
|
||||
});
|
||||
assert.equal(preflight.status, 200);
|
||||
await preflight.json();
|
||||
assert.equal(captureCount, 1);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
upstream.close();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import http, { IncomingMessage, ServerResponse } from 'node:http';
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import type { PluginMiningProvenance } from '../plugin-api';
|
||||
|
||||
interface StartProxyOptions {
|
||||
host: string;
|
||||
@@ -13,6 +14,8 @@ interface AnkiConnectEnvelope {
|
||||
}
|
||||
|
||||
export interface AnkiConnectProxyServerDeps {
|
||||
captureMiningProvenance?: () => PluginMiningProvenance | null;
|
||||
onNotesAdded?: (noteIds: number[], provenance: PluginMiningProvenance | null) => void;
|
||||
shouldAutoUpdateNewCards: () => boolean;
|
||||
processNewCard: (noteId: number) => Promise<void>;
|
||||
recordCardsAdded?: (count: number, noteIds: number[]) => void;
|
||||
@@ -188,6 +191,12 @@ export class AnkiConnectProxyServer {
|
||||
typeof req.headers['content-type'] === 'string'
|
||||
? req.headers['content-type']
|
||||
: 'application/json';
|
||||
const action = requestJson?.action;
|
||||
const provenance =
|
||||
typeof action === 'string' &&
|
||||
['addNote', 'addNotes', 'multi', 'updateNoteFields'].includes(action)
|
||||
? (this.deps.captureMiningProvenance?.() ?? null)
|
||||
: null;
|
||||
const upstreamResponse = await this.client.request<ArrayBuffer>({
|
||||
url: targetUrl,
|
||||
method: req.method,
|
||||
@@ -205,7 +214,7 @@ export class AnkiConnectProxyServer {
|
||||
res.end(responseBody);
|
||||
|
||||
if (req.method === 'POST') {
|
||||
this.maybeEnqueueFromRequest(requestJson, responseBody);
|
||||
this.maybeEnqueueFromRequest(requestJson, responseBody, provenance);
|
||||
}
|
||||
} catch (error) {
|
||||
this.deps.logWarn('[anki-proxy] Failed to forward request:', (error as Error).message);
|
||||
@@ -217,6 +226,7 @@ export class AnkiConnectProxyServer {
|
||||
private maybeEnqueueFromRequest(
|
||||
requestJson: Record<string, unknown> | null,
|
||||
responseBody: Buffer,
|
||||
provenance: PluginMiningProvenance | null = null,
|
||||
): void {
|
||||
if (!requestJson) {
|
||||
return;
|
||||
@@ -250,6 +260,7 @@ export class AnkiConnectProxyServer {
|
||||
response.result !== null
|
||||
)
|
||||
return;
|
||||
this.deps.onNotesAdded?.(this.collectSingleResultId(note.id), provenance);
|
||||
this.enqueueNotes(this.collectSingleResultId(note.id), false);
|
||||
return;
|
||||
}
|
||||
@@ -286,9 +297,11 @@ export class AnkiConnectProxyServer {
|
||||
}
|
||||
if (!autoUpdate) {
|
||||
// Nothing to enrich, so the added notes are already in their final state.
|
||||
this.deps.onNotesAdded?.(noteIds, provenance);
|
||||
if (noteIds.length > 0) this.deps.onCardsReady?.(noteIds);
|
||||
return;
|
||||
}
|
||||
this.deps.onNotesAdded?.(noteIds, provenance);
|
||||
this.maybeTrackDuplicateNoteIds(requestJson, action, responseResult);
|
||||
if (noteIds.length === 0 && shouldFallbackToLatestAdded) {
|
||||
void this.enqueueMostRecentAddedNote();
|
||||
|
||||
@@ -11,12 +11,37 @@ import {
|
||||
} from './card-creation-test-utils';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { PluginMiningProvenance } from '../plugin-api';
|
||||
|
||||
const YOUTUBE_URL = 'https://www.youtube.com/watch?v=abc123';
|
||||
|
||||
const mediaInputPath = (mediaInput: MediaInput): string =>
|
||||
typeof mediaInput === 'string' ? mediaInput : mediaInput.path;
|
||||
|
||||
test('sentence provenance is captured before asynchronous creation and uses the mined line', async () => {
|
||||
let currentPath = 'Show - 02.mkv';
|
||||
const captured: Array<PluginMiningProvenance | null> = [];
|
||||
const service = new CardCreationService(
|
||||
createCardCreationDeps({
|
||||
captureMiningProvenance: () => ({
|
||||
minedAt: 1234,
|
||||
media: { playbackId: 1, path: currentPath, title: null },
|
||||
subtitle: null,
|
||||
}),
|
||||
client: createFakeClient({
|
||||
addNote: async () => {
|
||||
currentPath = 'Show - 03.mkv';
|
||||
return 42;
|
||||
},
|
||||
}),
|
||||
onNoteCreated: (_noteId, provenance) => captured.push(provenance),
|
||||
}),
|
||||
);
|
||||
assert.equal(await service.createSentenceCard('テスト', 1, 2), true);
|
||||
assert.equal(captured[0]?.media?.path, 'Show - 02.mkv');
|
||||
assert.deepEqual(captured[0]?.subtitle, { text: 'テスト', startTime: 1, endTime: 2 });
|
||||
});
|
||||
|
||||
test('CardCreationService counts locally created sentence cards', async () => {
|
||||
const minedCards: Array<{ count: number; noteIds?: number[] }> = [];
|
||||
const service = new CardCreationService(
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type WordCardKind,
|
||||
} from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import type { PluginMiningProvenance } from '../plugin-api';
|
||||
import type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
import { AiConfig } from '../types/integrations';
|
||||
@@ -101,6 +102,8 @@ interface CardCreationMediaGenerator {
|
||||
}
|
||||
|
||||
interface CardCreationDeps {
|
||||
captureMiningProvenance?: () => PluginMiningProvenance | null;
|
||||
onNoteCreated?: (noteId: number, provenance: PluginMiningProvenance | null) => void;
|
||||
getConfig: () => AnkiConnectConfig;
|
||||
getAiConfig: () => AiConfig;
|
||||
getTimingTracker: () => SubtitleTimingTracker;
|
||||
@@ -602,6 +605,11 @@ export class CardCreationService {
|
||||
}
|
||||
|
||||
const maxMediaDuration = this.deps.getConfig().media?.maxMediaDuration ?? 30;
|
||||
const captured = this.deps.captureMiningProvenance?.() ?? null;
|
||||
const provenance = captured && {
|
||||
...captured,
|
||||
subtitle: { text: sentence, startTime, endTime },
|
||||
};
|
||||
const cappedEndTime = clampMediaEndTime(startTime, endTime, maxMediaDuration);
|
||||
if (cappedEndTime !== endTime) {
|
||||
log.warn(
|
||||
@@ -724,6 +732,7 @@ export class CardCreationService {
|
||||
this.getConfiguredAnkiTags(),
|
||||
);
|
||||
createdNoteId = noteId;
|
||||
this.deps.onNoteCreated?.(noteId, provenance);
|
||||
log.info('Created sentence card:', noteId);
|
||||
} catch (error) {
|
||||
log.error('Failed to create sentence card:', (error as Error).message);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MpvSubtitleRenderMetrics } from '../../types';
|
||||
import { sanitizeMediaTitle } from '../../shared/media-identity';
|
||||
import { widenHalfwidthKana } from '../utils/halfwidth-kana';
|
||||
import type { PluginMpvEventMap } from '../../plugin-api';
|
||||
|
||||
export type MpvMessage = {
|
||||
event?: string;
|
||||
@@ -15,14 +16,10 @@ export type MpvMessage = {
|
||||
};
|
||||
|
||||
const MPV_END_FILE_REASONS = ['eof', 'stop', 'quit', 'error', 'redirect'] as const;
|
||||
export type MpvEndFileReason = (typeof MPV_END_FILE_REASONS)[number] | 'unknown';
|
||||
export type MpvEndFileReason = PluginMpvEventMap['end-file']['reason'];
|
||||
|
||||
/** mpv's `end-file` event: why the current file stopped, and mpv's error text when it failed. */
|
||||
export interface MpvEndFileEvent {
|
||||
reason: MpvEndFileReason;
|
||||
playlistEntryId: number | null;
|
||||
error: string | null;
|
||||
}
|
||||
export type MpvEndFileEvent = PluginMpvEventMap['end-file'];
|
||||
|
||||
function parseEndFileEvent(msg: MpvMessage): MpvEndFileEvent {
|
||||
const reason = MPV_END_FILE_REASONS.find((known) => known === msg.reason) ?? 'unknown';
|
||||
@@ -123,6 +120,7 @@ export interface MpvProtocolHandleMessageDeps {
|
||||
requestAppQuit: () => void;
|
||||
emitClientMessage?: (payload: { args: string[] }) => void;
|
||||
emitEndFile?: (payload: MpvEndFileEvent) => void;
|
||||
emitFileLoaded?: () => void;
|
||||
}
|
||||
|
||||
type SubtitleTrackCandidate = {
|
||||
@@ -442,6 +440,8 @@ export async function dispatchMpvProtocolMessage(
|
||||
if (args.length > 0) {
|
||||
deps.emitClientMessage?.({ args });
|
||||
}
|
||||
} else if (msg.event === 'file-loaded') {
|
||||
deps.emitFileLoaded?.();
|
||||
} else if (msg.event === 'end-file') {
|
||||
deps.emitEndFile?.(parseEndFileEvent(msg));
|
||||
} else if (msg.event === 'shutdown') {
|
||||
|
||||
+11
-19
@@ -1,10 +1,10 @@
|
||||
import type { PluginMpvEventMap } from '../../plugin-api';
|
||||
import { EventEmitter } from 'events';
|
||||
import { Config, MpvClient, MpvSubtitleRenderMetrics } from '../../types';
|
||||
import {
|
||||
dispatchMpvProtocolMessage,
|
||||
MPV_REQUEST_ID_TRACK_LIST_AUDIO,
|
||||
MPV_REQUEST_ID_TRACK_LIST_SECONDARY,
|
||||
type MpvEndFileEvent,
|
||||
MpvMessage,
|
||||
MpvProtocolHandleMessageDeps,
|
||||
splitMpvMessagesFromBuffer,
|
||||
@@ -121,25 +121,8 @@ export interface MpvIpcClientProtocolDeps {
|
||||
|
||||
export interface MpvIpcClientDeps extends MpvIpcClientProtocolDeps {}
|
||||
|
||||
export interface MpvIpcClientEventMap {
|
||||
'connection-change': { connected: boolean };
|
||||
'subtitle-change': { text: string; isOverlayVisible: boolean };
|
||||
'subtitle-timing': { text: string; start: number; end: number };
|
||||
'time-pos-change': { time: number };
|
||||
'duration-change': { duration: number };
|
||||
'pause-change': { paused: boolean };
|
||||
'fullscreen-change': { fullscreen: boolean };
|
||||
'secondary-subtitle-change': { text: string };
|
||||
'subtitle-track-change': { sid: number | null };
|
||||
'secondary-subtitle-track-change': { sid: number | null };
|
||||
'secondary-subtitle-delay-change': { delay: number };
|
||||
'subtitle-track-list-change': { trackList: unknown[] | null };
|
||||
'media-path-change': { path: string };
|
||||
'media-title-change': { title: string | null };
|
||||
export interface MpvIpcClientEventMap extends PluginMpvEventMap {
|
||||
'subtitle-metrics-change': { patch: Partial<MpvSubtitleRenderMetrics> };
|
||||
'secondary-subtitle-visibility': { visible: boolean };
|
||||
'client-message': { args: string[] };
|
||||
'end-file': MpvEndFileEvent;
|
||||
}
|
||||
|
||||
type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
|
||||
@@ -524,6 +507,7 @@ export class MpvIpcClient implements MpvClient {
|
||||
emitEndFile: (payload) => {
|
||||
this.emit('end-file', payload);
|
||||
},
|
||||
emitFileLoaded: () => this.emit('file-loaded', undefined),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -603,6 +587,14 @@ export class MpvIpcClient implements MpvClient {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async requestCommand(command: (string | number | boolean)[]): Promise<unknown> {
|
||||
const response = await this.request(command);
|
||||
if (response.error && response.error !== 'success') {
|
||||
throw new Error(`MPV command failed: ${response.error}`);
|
||||
}
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private failPendingRequests(): void {
|
||||
for (const [requestId, resolve] of this.pendingRequests.entries()) {
|
||||
resolve({ request_id: requestId, error: 'disconnected' });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from 'node:path';
|
||||
|
||||
import type { PluginManifest } from './types';
|
||||
import { PLUGIN_API_VERSION, type PluginManifest } from './types';
|
||||
|
||||
const PLUGIN_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i;
|
||||
|
||||
@@ -27,10 +27,20 @@ export function parsePluginManifest(
|
||||
if (value.version !== undefined && typeof value.version !== 'string') {
|
||||
return { error: 'plugin.json "version" must be a string.' };
|
||||
}
|
||||
const apiVersion = value.apiVersion === undefined ? 1 : value.apiVersion;
|
||||
if (typeof apiVersion !== 'number' || !Number.isInteger(apiVersion) || apiVersion < 1) {
|
||||
return { error: 'plugin.json "apiVersion" must be a positive integer.' };
|
||||
}
|
||||
if (apiVersion !== PLUGIN_API_VERSION) {
|
||||
return {
|
||||
error: `Plugin requires API ${apiVersion}; this SubMiner supports API ${PLUGIN_API_VERSION}.`,
|
||||
};
|
||||
}
|
||||
return {
|
||||
manifest: {
|
||||
name: typeof value.name === 'string' && value.name.trim() ? value.name.trim() : fallbackName,
|
||||
version: typeof value.version === 'string' ? value.version : null,
|
||||
apiVersion,
|
||||
main: value.main.trim(),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { PluginMediaSnapshot } from './types';
|
||||
|
||||
/** Keeps plugin snapshots coherent without asynchronous property reads crossing file changes. */
|
||||
export function createPluginMediaState(onChange: (media: PluginMediaSnapshot | null) => void) {
|
||||
let nextId = 0;
|
||||
let current: PluginMediaSnapshot | null = null;
|
||||
let waitingForLoad = false;
|
||||
|
||||
function publish(media: PluginMediaSnapshot | null): void {
|
||||
current = media && Object.freeze(media);
|
||||
onChange(current);
|
||||
}
|
||||
|
||||
function start(path: string, title: string | null = null): void {
|
||||
publish({ playbackId: ++nextId, path, title });
|
||||
}
|
||||
|
||||
return {
|
||||
current: () => current,
|
||||
reset(path = '', title: string | null = null): void {
|
||||
waitingForLoad = false;
|
||||
if (path) start(path, title);
|
||||
else if (current) publish(null);
|
||||
},
|
||||
pathChanged(path: string): void {
|
||||
if (!path) {
|
||||
this.reset();
|
||||
} else if (path !== current?.path) {
|
||||
waitingForLoad = true;
|
||||
start(path);
|
||||
}
|
||||
},
|
||||
titleChanged(title: string | null): void {
|
||||
if (current && title !== current.title) publish({ ...current, title });
|
||||
},
|
||||
fileLoaded(path: string, title: string | null): void {
|
||||
if (!path) return;
|
||||
if (waitingForLoad && current?.path === path) {
|
||||
this.titleChanged(title);
|
||||
} else {
|
||||
start(path, title);
|
||||
}
|
||||
waitingForLoad = false;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { BrowserWindow } from 'electron';
|
||||
import type { SessionActionDispatchRequest } from '../../../types/runtime';
|
||||
import { execPluginCommand } from './exec';
|
||||
import { isValidPluginName, parsePluginManifest, resolvePluginFilePath } from './manifest';
|
||||
import { createPluginMediaState } from './media-state';
|
||||
import {
|
||||
PLUGIN_API_VERSION,
|
||||
PLUGIN_MPV_EVENT_NAMES,
|
||||
type PluginAnkiEventMap,
|
||||
type PluginAnkiEventName,
|
||||
@@ -17,6 +19,7 @@ import {
|
||||
type PluginLogger,
|
||||
type PluginModule,
|
||||
type PluginMpvEventName,
|
||||
type PluginMpvEventMap,
|
||||
type PluginNotifyVariant,
|
||||
type PluginPanelContent,
|
||||
type PluginPanelHandle,
|
||||
@@ -33,10 +36,19 @@ const DEFAULT_WINDOW_HEIGHT = 720;
|
||||
* The forced-exit fallback in main adds its own budget on top of this.
|
||||
*/
|
||||
export const PLUGIN_DEACTIVATE_TIMEOUT_MS = 2_000;
|
||||
export const PLUGIN_ACTIVATE_TIMEOUT_MS = 10_000;
|
||||
|
||||
interface MpvClientEvents {
|
||||
on: (event: PluginMpvEventName, listener: (payload: unknown) => void) => void;
|
||||
off: (event: PluginMpvEventName, listener: (payload: unknown) => void) => void;
|
||||
currentVideoPath?: string;
|
||||
currentMediaTitle?: string | null;
|
||||
on: <E extends PluginMpvEventName>(
|
||||
event: E,
|
||||
listener: (payload: PluginMpvEventMap[E]) => void,
|
||||
) => void;
|
||||
off: <E extends PluginMpvEventName>(
|
||||
event: E,
|
||||
listener: (payload: PluginMpvEventMap[E]) => void,
|
||||
) => void;
|
||||
}
|
||||
|
||||
export interface PluginHostDeps<TUnstable> {
|
||||
@@ -48,6 +60,7 @@ export interface PluginHostDeps<TUnstable> {
|
||||
isMpvConnected: () => boolean;
|
||||
sendMpvCommand: (command: (string | number)[]) => void;
|
||||
requestMpvProperty: (name: string) => Promise<unknown>;
|
||||
requestMpvCommand: (command: (string | number | boolean)[]) => Promise<unknown>;
|
||||
ensureMpvRunning: () => Promise<boolean>;
|
||||
invokeAnkiConnect: (
|
||||
action: string,
|
||||
@@ -67,21 +80,25 @@ export interface PluginHostDeps<TUnstable> {
|
||||
exec?: typeof execPluginCommand;
|
||||
/** How long unload waits, in total, for loading and deactivate before disposing anyway. */
|
||||
deactivateTimeoutMs?: number;
|
||||
/** Async activation is bounded; synchronous plugin code still shares the main thread. */
|
||||
activateTimeoutMs?: number;
|
||||
}
|
||||
|
||||
interface LoadedPlugin {
|
||||
id: string;
|
||||
disposers: Set<() => void>;
|
||||
deactivate: (() => unknown) | null;
|
||||
deactivation: Promise<void> | null;
|
||||
disposed: boolean;
|
||||
/** Aborted on unload so in-flight `exec` children die with the plugin. */
|
||||
lifetime: AbortController;
|
||||
}
|
||||
|
||||
/** Resolves when `promise` settles, or rejects once `timeoutMs` passes first. */
|
||||
async function settleWithin(promise: Promise<unknown>, timeoutMs: number): Promise<void> {
|
||||
async function settleWithin<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
return await Promise.race([
|
||||
promise,
|
||||
new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => reject(new Error(`timed out after ${timeoutMs} ms`)), timeoutMs);
|
||||
@@ -124,17 +141,17 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
mpvEvents.setMaxListeners(0);
|
||||
const ankiEvents = new EventEmitter();
|
||||
ankiEvents.setMaxListeners(0);
|
||||
const mediaEvents = new EventEmitter();
|
||||
mediaEvents.setMaxListeners(0);
|
||||
const media = createPluginMediaState((snapshot) => mediaEvents.emit('change', snapshot));
|
||||
const plugins = new Map<string, LoadedPlugin>();
|
||||
const actions = new Map<string, { pluginId: string; handler: () => unknown }>();
|
||||
const loadModule = deps.loadModule ?? ((entryPath: string) => require(entryPath) as PluginModule);
|
||||
const exec = deps.exec ?? execPluginCommand;
|
||||
let attachedClient: {
|
||||
client: MpvClientEvents;
|
||||
forwarders: Map<string, (p: unknown) => void>;
|
||||
} | null = null;
|
||||
let detachClient: (() => void) | null = null;
|
||||
let loadPromise: Promise<void> | null = null;
|
||||
// Set once unloadAll has picked the plugins to unload; anything that activates later cleans up itself.
|
||||
let unloaded = false;
|
||||
const pluginLoads = new Map<string, Promise<void>>();
|
||||
let unloading = false;
|
||||
|
||||
function runGuarded(log: PluginLogger, label: string, callback: () => unknown): void {
|
||||
try {
|
||||
@@ -156,6 +173,9 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
entry: PluginConfigEntry,
|
||||
log: PluginLogger,
|
||||
): PluginContext<TUnstable> {
|
||||
const assertActive = (): void => {
|
||||
if (plugin.disposed) throw new Error(`Plugin "${plugin.id}" has been disposed.`);
|
||||
};
|
||||
const track = (dispose: () => void): (() => void) => {
|
||||
let disposed = false;
|
||||
const disposer = (): void => {
|
||||
@@ -164,12 +184,16 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
plugin.disposers.delete(disposer);
|
||||
dispose();
|
||||
};
|
||||
plugin.disposers.add(disposer);
|
||||
if (plugin.lifetime.signal.aborted) disposer();
|
||||
else plugin.disposers.add(disposer);
|
||||
return disposer;
|
||||
};
|
||||
|
||||
return {
|
||||
apiVersion: PLUGIN_API_VERSION,
|
||||
id: plugin.id,
|
||||
signal: plugin.lifetime.signal,
|
||||
onDispose: (dispose) => track(() => runGuarded(log, 'Cleanup', dispose)),
|
||||
dir,
|
||||
settings: Object.freeze({ ...entry.settings }),
|
||||
dataDir,
|
||||
@@ -204,9 +228,36 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
mpvEvents.on(event, wrapped);
|
||||
return track(() => mpvEvents.off(event, wrapped));
|
||||
},
|
||||
command: (command) => deps.sendMpvCommand(command),
|
||||
getProperty: (name) => deps.requestMpvProperty(name),
|
||||
ensureRunning: () => deps.ensureMpvRunning(),
|
||||
command: (command) => {
|
||||
assertActive();
|
||||
deps.sendMpvCommand(command);
|
||||
},
|
||||
request: async (command) => {
|
||||
assertActive();
|
||||
return deps.requestMpvCommand(command);
|
||||
},
|
||||
getProperty: async (name) => {
|
||||
assertActive();
|
||||
return deps.requestMpvProperty(name);
|
||||
},
|
||||
ensureRunning: async () => {
|
||||
assertActive();
|
||||
return deps.ensureMpvRunning();
|
||||
},
|
||||
},
|
||||
media: {
|
||||
current: () => media.current(),
|
||||
on: (event, listener, options) => {
|
||||
if (event !== 'change') throw new Error(`Unknown media event "${event}".`);
|
||||
const wrapped = (snapshot: ReturnType<typeof media.current>): void =>
|
||||
runGuarded(log, 'media change listener', () => listener(snapshot));
|
||||
mediaEvents.on('change', wrapped);
|
||||
const dispose = track(() => mediaEvents.off('change', wrapped));
|
||||
if (!plugin.lifetime.signal.aborted && options?.emitCurrent !== false) {
|
||||
wrapped(media.current());
|
||||
}
|
||||
return dispose;
|
||||
},
|
||||
},
|
||||
anki: {
|
||||
on: (event, listener) => {
|
||||
@@ -217,8 +268,10 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
ankiEvents.on(event, wrapped);
|
||||
return track(() => ankiEvents.off(event, wrapped));
|
||||
},
|
||||
invoke: (action, params = {}, options = {}) =>
|
||||
deps.invokeAnkiConnect(action, params, options),
|
||||
invoke: async (action, params = {}, options = {}) => {
|
||||
assertActive();
|
||||
return deps.invokeAnkiConnect(action, params, options);
|
||||
},
|
||||
},
|
||||
subtitles: {
|
||||
current: () => deps.getCurrentSubtitle(),
|
||||
@@ -236,12 +289,20 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
actions.set(actionId, { pluginId: plugin.id, handler });
|
||||
return track(() => actions.delete(actionId));
|
||||
},
|
||||
run: (actionId, payload) =>
|
||||
deps.dispatchSessionAction(payload === undefined ? { actionId } : { actionId, payload }),
|
||||
run: async (actionId, payload) => {
|
||||
assertActive();
|
||||
return deps.dispatchSessionAction(
|
||||
payload === undefined ? { actionId } : { actionId, payload },
|
||||
);
|
||||
},
|
||||
},
|
||||
notify: (message, options) => {
|
||||
assertActive();
|
||||
deps.notify(message, options?.variant ?? 'info');
|
||||
},
|
||||
notify: (message, options) => deps.notify(message, options?.variant ?? 'info'),
|
||||
ui: {
|
||||
panel: (content) => {
|
||||
plugin.lifetime.signal.throwIfAborted();
|
||||
const handle = deps.openPanel({
|
||||
title: content.title ?? plugin.id,
|
||||
markdown: content.markdown,
|
||||
@@ -251,6 +312,7 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
return handle;
|
||||
},
|
||||
openWindow: (options) => {
|
||||
plugin.lifetime.signal.throwIfAborted();
|
||||
const resolveFile = (label: string, file: string): string => {
|
||||
const resolved = resolvePluginFilePath(dir, file);
|
||||
if (!resolved) throw new Error(`Window ${label} "${file}" must stay inside ${dir}.`);
|
||||
@@ -291,6 +353,8 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
}
|
||||
|
||||
function disposePlugin(plugin: LoadedPlugin): void {
|
||||
if (plugin.disposed) return;
|
||||
plugin.disposed = true;
|
||||
plugin.lifetime.abort();
|
||||
for (const dispose of [...plugin.disposers]) {
|
||||
try {
|
||||
@@ -302,6 +366,31 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
plugins.delete(plugin.id);
|
||||
}
|
||||
|
||||
/** Shares one bounded deactivate call between unload and an activation that finishes late. */
|
||||
function finishPlugin(
|
||||
plugin: LoadedPlugin,
|
||||
timeoutMs = deps.deactivateTimeoutMs ?? PLUGIN_DEACTIVATE_TIMEOUT_MS,
|
||||
): Promise<void> {
|
||||
if (plugin.deactivation) return plugin.deactivation;
|
||||
if (!plugin.deactivate) {
|
||||
disposePlugin(plugin);
|
||||
return Promise.resolve();
|
||||
}
|
||||
plugin.deactivation = (async () => {
|
||||
try {
|
||||
await settleWithin(
|
||||
Promise.resolve().then(() => plugin.deactivate?.()),
|
||||
timeoutMs,
|
||||
);
|
||||
} catch (error) {
|
||||
deps.createLogger(`plugin:${plugin.id}`).error(`deactivate failed: ${errorMessage(error)}`);
|
||||
} finally {
|
||||
disposePlugin(plugin);
|
||||
}
|
||||
})();
|
||||
return plugin.deactivation;
|
||||
}
|
||||
|
||||
async function loadPlugin(id: string, entry: PluginConfigEntry): Promise<void> {
|
||||
const log = deps.createLogger(`plugin:${id}`);
|
||||
if (!isValidPluginName(id)) {
|
||||
@@ -336,13 +425,15 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (unloading) return;
|
||||
const plugin: LoadedPlugin = {
|
||||
id,
|
||||
disposers: new Set(),
|
||||
deactivate: null,
|
||||
deactivation: null,
|
||||
disposed: false,
|
||||
lifetime: new AbortController(),
|
||||
};
|
||||
if (unloaded) return;
|
||||
plugins.set(id, plugin);
|
||||
try {
|
||||
const mod = loadModule(entryPath);
|
||||
@@ -350,20 +441,18 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
if (!activate) {
|
||||
throw new Error(`${parsed.manifest.main} does not export an activate(ctx) function.`);
|
||||
}
|
||||
const returned = await activate(
|
||||
createContext(plugin, dir, dataDir, entry, log) as PluginContext,
|
||||
);
|
||||
plugin.deactivate =
|
||||
typeof returned === 'function'
|
||||
? (returned as () => unknown)
|
||||
: pickExport(mod, 'deactivate');
|
||||
if (unloaded) {
|
||||
// activate outlasted unloadAll's wait, so unloadAll skipped or only half-disposed it.
|
||||
log.warn('Activated after unload started; unloading it now.');
|
||||
runGuarded(log, 'deactivate', () => plugin.deactivate?.());
|
||||
disposePlugin(plugin);
|
||||
return;
|
||||
}
|
||||
const activation = Promise.resolve(
|
||||
activate(createContext(plugin, dir, dataDir, entry, log)),
|
||||
).then((returned) => {
|
||||
plugin.deactivate =
|
||||
typeof returned === 'function'
|
||||
? (returned as () => unknown)
|
||||
: pickExport(mod, 'deactivate');
|
||||
// Unload owns cleanup while it is waiting for activation. A timed-out plugin owns it later.
|
||||
if (plugin.disposed) void finishPlugin(plugin);
|
||||
});
|
||||
await settleWithin(activation, deps.activateTimeoutMs ?? PLUGIN_ACTIVATE_TIMEOUT_MS);
|
||||
if (plugin.lifetime.signal.aborted) return;
|
||||
const version = parsed.manifest.version ? ` v${parsed.manifest.version}` : '';
|
||||
log.info(`Loaded ${parsed.manifest.name}${version}.`);
|
||||
} catch (error) {
|
||||
@@ -373,22 +462,41 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
}
|
||||
|
||||
return {
|
||||
getMediaSnapshot: () => media.current(),
|
||||
/** Forwards the client's events to plugin listeners, replacing any previously attached client. */
|
||||
attachMpvClient(client: MpvClientEvents): void {
|
||||
if (attachedClient) {
|
||||
for (const [event, forward] of attachedClient.forwarders) {
|
||||
attachedClient.client.off(event as PluginMpvEventName, forward);
|
||||
}
|
||||
}
|
||||
const forwarders = new Map<string, (payload: unknown) => void>();
|
||||
detachClient?.();
|
||||
const disposers: Array<() => void> = [];
|
||||
const listen = <E extends PluginMpvEventName>(
|
||||
event: E,
|
||||
listener: (payload: PluginMpvEventMap[E]) => void,
|
||||
): void => {
|
||||
client.on(event, listener);
|
||||
disposers.push(() => client.off(event, listener));
|
||||
};
|
||||
listen('media-path-change', ({ path }) => media.pathChanged(path));
|
||||
listen('media-title-change', ({ title }) => media.titleChanged(title));
|
||||
listen('file-loaded', () =>
|
||||
media.fileLoaded(
|
||||
client.currentVideoPath ?? media.current()?.path ?? '',
|
||||
client.currentMediaTitle === undefined
|
||||
? (media.current()?.title ?? null)
|
||||
: client.currentMediaTitle,
|
||||
),
|
||||
);
|
||||
listen('end-file', () => media.reset());
|
||||
listen('connection-change', ({ connected }) => {
|
||||
if (!connected) media.reset();
|
||||
});
|
||||
for (const event of PLUGIN_MPV_EVENT_NAMES) {
|
||||
const forward = (payload: unknown): void => {
|
||||
listen(event, (payload) => {
|
||||
mpvEvents.emit(event, payload);
|
||||
};
|
||||
client.on(event, forward);
|
||||
forwarders.set(event, forward);
|
||||
});
|
||||
}
|
||||
attachedClient = { client, forwarders };
|
||||
detachClient = () => {
|
||||
for (const dispose of disposers) dispose();
|
||||
};
|
||||
media.reset(client.currentVideoPath, client.currentMediaTitle);
|
||||
},
|
||||
|
||||
/** Delivers an Anki event to every plugin listening for it. */
|
||||
@@ -398,15 +506,22 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
|
||||
/** Loads every enabled plugin once; later calls return the first load. */
|
||||
loadAll(): Promise<void> {
|
||||
if (unloading) return Promise.resolve();
|
||||
loadPromise ??= Promise.all(
|
||||
Object.entries(deps.getPluginConfig())
|
||||
.filter(([, entry]) => entry.enabled)
|
||||
.map(([id, entry]) => loadPlugin(id, entry)),
|
||||
.map(([id, entry]) => {
|
||||
const loading = loadPlugin(id, entry);
|
||||
pluginLoads.set(id, loading);
|
||||
return loading;
|
||||
}),
|
||||
).then(() => undefined);
|
||||
return loadPromise;
|
||||
},
|
||||
|
||||
async unloadAll(): Promise<void> {
|
||||
unloading = true;
|
||||
for (const plugin of plugins.values()) plugin.lifetime.abort();
|
||||
// One budget covers the load wait and every deactivate, so quit is bounded by it.
|
||||
const timeoutMs = deps.deactivateTimeoutMs ?? PLUGIN_DEACTIVATE_TIMEOUT_MS;
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
@@ -421,21 +536,10 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
}
|
||||
}
|
||||
loadPromise = null;
|
||||
unloaded = true;
|
||||
await Promise.all(
|
||||
[...plugins.values()].map(async (plugin) => {
|
||||
try {
|
||||
await settleWithin(
|
||||
Promise.resolve().then(() => plugin.deactivate?.()),
|
||||
Math.max(0, deadline - Date.now()),
|
||||
);
|
||||
} catch (error) {
|
||||
deps
|
||||
.createLogger(`plugin:${plugin.id}`)
|
||||
.error(`deactivate failed: ${errorMessage(error)}`);
|
||||
}
|
||||
disposePlugin(plugin);
|
||||
}),
|
||||
[...plugins.values()].map((plugin) =>
|
||||
finishPlugin(plugin, Math.max(0, deadline - Date.now())),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -444,7 +548,11 @@ export function createPluginHost<TUnstable>(deps: PluginHostDeps<TUnstable>) {
|
||||
* command sent at startup is not lost. Resolves false when no plugin registered the action.
|
||||
*/
|
||||
async runAction(actionId: string): Promise<boolean> {
|
||||
await loadPromise;
|
||||
const separator = actionId.indexOf('/');
|
||||
if (separator < 1) return false;
|
||||
const pluginId = actionId.slice(0, separator);
|
||||
await pluginLoads.get(pluginId);
|
||||
if (unloading) return false;
|
||||
const action = actions.get(actionId);
|
||||
if (!action) return false;
|
||||
runGuarded(
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import type { PluginMpvEventName } from '../../../plugin-api';
|
||||
export type * from '../../../plugin-api';
|
||||
|
||||
import type { SessionActionId, SessionActionPayload } from '../../../types/session-bindings';
|
||||
import type { MpvIpcClientEventMap } from '../mpv';
|
||||
export const PLUGIN_API_VERSION = 1;
|
||||
|
||||
/** `plugin.json` at the root of a plugin directory. */
|
||||
export interface PluginManifest {
|
||||
/** Display name; defaults to the directory name. */
|
||||
name: string;
|
||||
version: string | null;
|
||||
/** Omitted by older plugins, which use API v1. */
|
||||
apiVersion: number;
|
||||
/** Entry module, relative to the plugin directory. */
|
||||
main: string;
|
||||
}
|
||||
@@ -18,83 +20,28 @@ export interface PluginConfigEntry {
|
||||
settings: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** mpv events plugins may observe. Render metrics are internal and stay hidden. */
|
||||
export type PluginMpvEventMap = Omit<MpvIpcClientEventMap, 'subtitle-metrics-change'>;
|
||||
export type PluginMpvEventName = keyof PluginMpvEventMap;
|
||||
const pluginMpvEvents = {
|
||||
'connection-change': true,
|
||||
'subtitle-change': true,
|
||||
'subtitle-timing': true,
|
||||
'time-pos-change': true,
|
||||
'duration-change': true,
|
||||
'pause-change': true,
|
||||
'fullscreen-change': true,
|
||||
'secondary-subtitle-change': true,
|
||||
'subtitle-track-change': true,
|
||||
'secondary-subtitle-track-change': true,
|
||||
'secondary-subtitle-delay-change': true,
|
||||
'subtitle-track-list-change': true,
|
||||
'media-path-change': true,
|
||||
'media-title-change': true,
|
||||
'secondary-subtitle-visibility': true,
|
||||
'client-message': true,
|
||||
'file-loaded': true,
|
||||
'end-file': true,
|
||||
} satisfies Record<PluginMpvEventName, true>;
|
||||
|
||||
export const PLUGIN_MPV_EVENT_NAMES = [
|
||||
'connection-change',
|
||||
'subtitle-change',
|
||||
'subtitle-timing',
|
||||
'time-pos-change',
|
||||
'duration-change',
|
||||
'pause-change',
|
||||
'fullscreen-change',
|
||||
'secondary-subtitle-change',
|
||||
'subtitle-track-change',
|
||||
'secondary-subtitle-track-change',
|
||||
'secondary-subtitle-delay-change',
|
||||
'subtitle-track-list-change',
|
||||
'media-path-change',
|
||||
'media-title-change',
|
||||
'secondary-subtitle-visibility',
|
||||
'client-message',
|
||||
'end-file',
|
||||
] as const satisfies readonly PluginMpvEventName[];
|
||||
|
||||
/** Anki events plugins may observe. */
|
||||
export interface PluginAnkiEventMap {
|
||||
/**
|
||||
* SubMiner finished writing a mined card, or polling saw it while auto-update is off.
|
||||
* Field grouping may have merged the note into another one by the time this fires.
|
||||
*/
|
||||
'card-mined': { noteIds: number[] };
|
||||
}
|
||||
export type PluginAnkiEventName = keyof PluginAnkiEventMap;
|
||||
|
||||
export interface PluginAnkiInvokeOptions {
|
||||
/** Request timeout; AnkiConnect's default is 10 seconds. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PluginSubtitleLine {
|
||||
text: string;
|
||||
/** Seconds on the subtitle file's timeline; null when mpv did not report timing. */
|
||||
startTime: number | null;
|
||||
endTime: number | null;
|
||||
}
|
||||
|
||||
export interface PluginSubtitleContext {
|
||||
current: PluginSubtitleLine | null;
|
||||
/** Oldest first. */
|
||||
before: PluginSubtitleLine[];
|
||||
after: PluginSubtitleLine[];
|
||||
}
|
||||
|
||||
export interface PluginPanelContent {
|
||||
title: string;
|
||||
/** Rendered as a safe markdown subset; raw HTML is shown as text. */
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
export interface PluginPanelHandle {
|
||||
/** Aborts when the user closes the panel or another panel replaces it. */
|
||||
readonly signal: AbortSignal;
|
||||
readonly closed: boolean;
|
||||
update: (content: Partial<PluginPanelContent>) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface PluginWindowOptions {
|
||||
/** HTML file to load, relative to the plugin directory. */
|
||||
html: string;
|
||||
/** Preload script, relative to the plugin directory. Runs with context isolation. */
|
||||
preload?: string;
|
||||
/** Window title; Hyprland matches it to float the window above the overlay. */
|
||||
title?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
export const PLUGIN_MPV_EVENT_NAMES = Object.keys(pluginMpvEvents) as PluginMpvEventName[];
|
||||
|
||||
/** What the host needs to show a plugin window; paths are absolute and already validated. */
|
||||
export interface PluginWindowRequest {
|
||||
@@ -106,105 +53,3 @@ export interface PluginWindowRequest {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface PluginExecOptions {
|
||||
/** Written to the child's stdin, which is then closed. */
|
||||
stdin?: string;
|
||||
cwd?: string;
|
||||
/** Merged over the parent environment. */
|
||||
env?: Record<string, string>;
|
||||
/** Kills the child and rejects after this many milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Kills the child and rejects when aborted. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface PluginExecResult {
|
||||
/** Null when the child was terminated by a signal. */
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export type PluginNotifyVariant = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface PluginLogger {
|
||||
debug: (message: string, ...meta: unknown[]) => void;
|
||||
info: (message: string, ...meta: unknown[]) => void;
|
||||
warn: (message: string, ...meta: unknown[]) => void;
|
||||
error: (message: string, ...meta: unknown[]) => void;
|
||||
}
|
||||
|
||||
type Disposer = () => void;
|
||||
|
||||
/** The API object passed to a plugin's `activate(ctx)`. */
|
||||
export interface PluginContext<TUnstable = unknown> {
|
||||
readonly id: string;
|
||||
/** Absolute path of the plugin directory. */
|
||||
readonly dir: string;
|
||||
/** `plugins.<id>.settings` from config. */
|
||||
readonly settings: Readonly<Record<string, unknown>>;
|
||||
/**
|
||||
* `<config dir>/plugin-data/<id>/` for caches and state. Kept apart from `dir`, so replacing the
|
||||
* plugin folder keeps its data. Created before `activate`.
|
||||
*/
|
||||
readonly dataDir: string;
|
||||
readonly log: PluginLogger;
|
||||
/** Repeating timer; cleared automatically when the plugin unloads. */
|
||||
every: (intervalMs: number, callback: () => unknown) => Disposer;
|
||||
/** One-shot timer; cleared automatically when the plugin unloads. */
|
||||
after: (delayMs: number, callback: () => unknown) => Disposer;
|
||||
readonly mpv: {
|
||||
readonly connected: boolean;
|
||||
on: <E extends PluginMpvEventName>(
|
||||
event: E,
|
||||
listener: (payload: PluginMpvEventMap[E]) => unknown,
|
||||
) => Disposer;
|
||||
command: (command: (string | number)[]) => void;
|
||||
getProperty: (name: string) => Promise<unknown>;
|
||||
/** Connects to mpv, launching an idle mpv if none is running. Resolves false when that fails. */
|
||||
ensureRunning: () => Promise<boolean>;
|
||||
};
|
||||
readonly anki: {
|
||||
on: <E extends PluginAnkiEventName>(
|
||||
event: E,
|
||||
listener: (payload: PluginAnkiEventMap[E]) => unknown,
|
||||
) => Disposer;
|
||||
/** Sends one AnkiConnect action to the configured `ankiConnect.url`. Rejects on an Anki error. */
|
||||
invoke: (
|
||||
action: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: PluginAnkiInvokeOptions,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
readonly subtitles: {
|
||||
current: () => PluginSubtitleLine | null;
|
||||
context: (options?: { before?: number; after?: number }) => PluginSubtitleContext;
|
||||
};
|
||||
readonly actions: {
|
||||
/** Bind with `["__plugin-action:<plugin id>/<name>"]` in `keybindings`. */
|
||||
register: (name: string, handler: () => unknown) => Disposer;
|
||||
/** Runs a built-in session action, the same ones keybindings use (e.g. `openJimaku`). */
|
||||
run: (actionId: SessionActionId, payload?: SessionActionPayload) => Promise<void>;
|
||||
};
|
||||
notify: (message: string, options?: { variant?: PluginNotifyVariant }) => void;
|
||||
readonly ui: {
|
||||
/** Opens the shared plugin panel, replacing any panel that is already open. */
|
||||
panel: (content: Partial<PluginPanelContent> & { markdown: string }) => PluginPanelHandle;
|
||||
/**
|
||||
* Opens a window for this plugin, or focuses it if the same `html` is already open.
|
||||
* The window is destroyed when the plugin unloads.
|
||||
*/
|
||||
openWindow: (options: PluginWindowOptions) => BrowserWindow;
|
||||
};
|
||||
/** Runs a binary without a shell, with the AppImage library path removed from its environment. */
|
||||
exec: (command: string, args: string[], options?: PluginExecOptions) => Promise<PluginExecResult>;
|
||||
/** Raw app internals. No stability guarantee across releases. */
|
||||
readonly unstable: TUnstable;
|
||||
}
|
||||
|
||||
export interface PluginModule {
|
||||
activate?: (ctx: PluginContext) => unknown;
|
||||
deactivate?: () => unknown;
|
||||
default?: { activate?: (ctx: PluginContext) => unknown; deactivate?: () => unknown };
|
||||
}
|
||||
+11
-2
@@ -3150,6 +3150,10 @@ const pluginHost = createPluginHost({
|
||||
if (!appState.mpvClient) throw new Error('mpv is not connected.');
|
||||
return await appState.mpvClient.requestProperty(name);
|
||||
},
|
||||
requestMpvCommand: async (command) => {
|
||||
if (!appState.mpvClient) throw new Error('mpv is not connected.');
|
||||
return await appState.mpvClient.requestCommand(command);
|
||||
},
|
||||
ensureMpvRunning: () => ensureMpvConnectedForPlayback(),
|
||||
invokeAnkiConnect: (action, params, options) => {
|
||||
const ankiConfig = configService.getConfig().ankiConnect;
|
||||
@@ -5515,8 +5519,13 @@ function initializeOverlayRuntime(): void {
|
||||
overlayModalRuntime.primeModalWindow();
|
||||
}
|
||||
appState.ankiIntegration?.setRecordCardsMinedCallback(recordTrackedCardsMined);
|
||||
appState.ankiIntegration?.setCardsReadyCallback((noteIds) =>
|
||||
pluginHost.emitAnkiEvent('card-mined', { noteIds }),
|
||||
appState.ankiIntegration?.setMiningProvenanceProvider(() => ({
|
||||
minedAt: Date.now(),
|
||||
media: pluginHost.getMediaSnapshot(),
|
||||
subtitle: getPluginCurrentSubtitle(),
|
||||
}));
|
||||
appState.ankiIntegration?.setCardsReadyCallback((noteIds, notes) =>
|
||||
pluginHost.emitAnkiEvent('card-mined', { noteIds, notes }),
|
||||
);
|
||||
appState.ankiIntegration?.setKnownWordCacheUpdatedCallback(refreshCurrentSubtitleAnnotations);
|
||||
appState.ankiIntegration?.setSubtitleMiningContextConsumer(consumePendingSubtitleMiningContext);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { copyFileSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
test('plugin authors can use the copied declaration without internal SubMiner sources', () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'subminer-plugin-types-'));
|
||||
try {
|
||||
copyFileSync(path.join(__dirname, 'plugin-api.ts'), path.join(root, 'subminer.d.ts'));
|
||||
symlinkSync(
|
||||
path.resolve(__dirname, '../node_modules'),
|
||||
path.join(root, 'node_modules'),
|
||||
'junction',
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(root, 'tsconfig.json'),
|
||||
JSON.stringify({
|
||||
compilerOptions: {
|
||||
strict: true,
|
||||
noEmit: true,
|
||||
module: 'Node16',
|
||||
target: 'ES2022',
|
||||
types: ['node'],
|
||||
},
|
||||
files: ['plugin.ts', 'subminer.d.ts'],
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(root, 'plugin.ts'),
|
||||
`
|
||||
import type { PluginContext, PluginMediaSnapshot } from './subminer';
|
||||
export function activate(ctx: PluginContext) {
|
||||
const version: 1 = ctx.apiVersion;
|
||||
const initial: PluginMediaSnapshot | null = ctx.media.current();
|
||||
ctx.onDispose(ctx.media.on('change', media => ctx.log.info(String(media?.playbackId))));
|
||||
ctx.anki.on('card-mined', ({ notes }) => {
|
||||
for (const { originalNoteId, noteId, provenance } of notes) {
|
||||
ctx.log.info(String({ originalNoteId, noteId, source: provenance?.media?.path }));
|
||||
}
|
||||
});
|
||||
ctx.actions.register('pause', async () => {
|
||||
await ctx.mpv.request(['set_property', 'pause', true]);
|
||||
await ctx.actions.run('openJimaku');
|
||||
});
|
||||
ctx.log.info(String({ version, initial, aborted: ctx.signal.aborted }));
|
||||
}
|
||||
`,
|
||||
);
|
||||
const output = execFileSync(
|
||||
process.execPath,
|
||||
[require.resolve('typescript/bin/tsc'), '-p', root],
|
||||
{
|
||||
encoding: 'utf8',
|
||||
},
|
||||
);
|
||||
assert.equal(output, '');
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
/** Public plugin API v1. This file can also be copied into a plugin as subminer.d.ts. */
|
||||
import type { BrowserWindow } from 'electron';
|
||||
|
||||
export type SessionActionId =
|
||||
| 'toggleStatsOverlay'
|
||||
| 'markWatched'
|
||||
| 'toggleVisibleOverlay'
|
||||
| 'copySubtitle'
|
||||
| 'copySubtitleMultiple'
|
||||
| 'updateLastCardFromClipboard'
|
||||
| 'triggerFieldGrouping'
|
||||
| 'triggerSubsync'
|
||||
| 'mineSentence'
|
||||
| 'mineSentenceMultiple'
|
||||
| 'toggleSecondarySub'
|
||||
| 'toggleSubtitleSidebar'
|
||||
| 'toggleNotificationHistory'
|
||||
| 'appendClipboardVideoToQueue'
|
||||
| 'markAudioCard'
|
||||
| 'openRuntimeOptions'
|
||||
| 'openSessionHelp'
|
||||
| 'openCharacterDictionaryManager'
|
||||
| 'openControllerSelect'
|
||||
| 'openControllerDebug'
|
||||
| 'openJimaku'
|
||||
| 'openTsukihime'
|
||||
| 'openSubtitleSelection'
|
||||
| 'openSubtitleGeneration'
|
||||
| 'openYoutubePicker'
|
||||
| 'openPlaylistBrowser'
|
||||
| 'replayCurrentSubtitle'
|
||||
| 'playNextSubtitle'
|
||||
| 'cycleRuntimeOption'
|
||||
| 'runPluginAction';
|
||||
|
||||
export interface SessionActionPayload {
|
||||
count?: number;
|
||||
runtimeOptionId?: string;
|
||||
direction?: 1 | -1;
|
||||
/** `<plugin id>/<action name>` for runPluginAction. */
|
||||
pluginActionId?: string;
|
||||
}
|
||||
|
||||
export interface PluginMpvEventMap {
|
||||
'connection-change': { connected: boolean };
|
||||
'subtitle-change': { text: string; isOverlayVisible: boolean };
|
||||
'subtitle-timing': { text: string; start: number; end: number };
|
||||
'time-pos-change': { time: number };
|
||||
'duration-change': { duration: number };
|
||||
'pause-change': { paused: boolean };
|
||||
'fullscreen-change': { fullscreen: boolean };
|
||||
'secondary-subtitle-change': { text: string };
|
||||
'subtitle-track-change': { sid: number | null };
|
||||
'secondary-subtitle-track-change': { sid: number | null };
|
||||
'secondary-subtitle-delay-change': { delay: number };
|
||||
'subtitle-track-list-change': { trackList: unknown[] | null };
|
||||
'media-path-change': { path: string };
|
||||
'media-title-change': { title: string | null };
|
||||
'secondary-subtitle-visibility': { visible: boolean };
|
||||
'client-message': { args: string[] };
|
||||
'file-loaded': undefined;
|
||||
'end-file': {
|
||||
reason: 'eof' | 'stop' | 'quit' | 'error' | 'redirect' | 'unknown';
|
||||
playlistEntryId: number | null;
|
||||
error: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export type PluginMpvEventName = keyof PluginMpvEventMap;
|
||||
|
||||
/** A cached playback snapshot. A new load gets a new id, including reopening the same path. */
|
||||
export interface PluginMediaSnapshot {
|
||||
readonly playbackId: number;
|
||||
readonly path: string;
|
||||
readonly title: string | null;
|
||||
}
|
||||
|
||||
/** Captured when mining starts. Null provenance means the source could not be established. */
|
||||
export interface PluginMiningProvenance {
|
||||
readonly minedAt: number;
|
||||
readonly media: PluginMediaSnapshot | null;
|
||||
readonly subtitle: PluginSubtitleLine | null;
|
||||
}
|
||||
|
||||
export interface PluginMinedNote {
|
||||
readonly originalNoteId: number;
|
||||
/** The note that kept the content after any field grouping. */
|
||||
readonly noteId: number;
|
||||
readonly provenance: PluginMiningProvenance | null;
|
||||
}
|
||||
|
||||
/** Anki events plugins may observe. */
|
||||
export interface PluginAnkiEventMap {
|
||||
/**
|
||||
* SubMiner finished writing a mined card, or polling saw it while auto-update is off.
|
||||
* Field grouping may have merged the note into another one by the time this fires.
|
||||
*/
|
||||
'card-mined': { noteIds: number[]; notes: PluginMinedNote[] };
|
||||
}
|
||||
export type PluginAnkiEventName = keyof PluginAnkiEventMap;
|
||||
|
||||
export interface PluginAnkiInvokeOptions {
|
||||
/** Request timeout; AnkiConnect's default is 10 seconds. */
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
export interface PluginSubtitleLine {
|
||||
text: string;
|
||||
/** Seconds on the subtitle file's timeline; null when mpv did not report timing. */
|
||||
startTime: number | null;
|
||||
endTime: number | null;
|
||||
}
|
||||
|
||||
export interface PluginSubtitleContext {
|
||||
current: PluginSubtitleLine | null;
|
||||
/** Oldest first. */
|
||||
before: PluginSubtitleLine[];
|
||||
after: PluginSubtitleLine[];
|
||||
}
|
||||
|
||||
export interface PluginPanelContent {
|
||||
title: string;
|
||||
/** Rendered as a safe markdown subset; raw HTML is shown as text. */
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
export interface PluginPanelHandle {
|
||||
/** Aborts when the user closes the panel or another panel replaces it. */
|
||||
readonly signal: AbortSignal;
|
||||
readonly closed: boolean;
|
||||
update: (content: Partial<PluginPanelContent>) => void;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
export interface PluginWindowOptions {
|
||||
/** HTML file to load, relative to the plugin directory. */
|
||||
html: string;
|
||||
/** Preload script, relative to the plugin directory. Runs with context isolation. */
|
||||
preload?: string;
|
||||
/** Window title; Hyprland matches it to float the window above the overlay. */
|
||||
title?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface PluginExecOptions {
|
||||
/** Written to the child's stdin, which is then closed. */
|
||||
stdin?: string;
|
||||
cwd?: string;
|
||||
/** Merged over the parent environment. */
|
||||
env?: Record<string, string>;
|
||||
/** Kills the child and rejects after this many milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Kills the child and rejects when aborted. */
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface PluginExecResult {
|
||||
/** Null when the child was terminated by a signal. */
|
||||
exitCode: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export type PluginNotifyVariant = 'info' | 'warning' | 'error';
|
||||
|
||||
export interface PluginLogger {
|
||||
debug: (message: string, ...meta: unknown[]) => void;
|
||||
info: (message: string, ...meta: unknown[]) => void;
|
||||
warn: (message: string, ...meta: unknown[]) => void;
|
||||
error: (message: string, ...meta: unknown[]) => void;
|
||||
}
|
||||
|
||||
type Disposer = () => void;
|
||||
|
||||
/** The API object passed to a plugin's `activate(ctx)`. */
|
||||
export interface PluginContext<TUnstable = unknown> {
|
||||
readonly apiVersion: 1;
|
||||
readonly id: string;
|
||||
/** Aborts as unloading starts, before deactivate runs. */
|
||||
readonly signal: AbortSignal;
|
||||
/** Registers cleanup; runs immediately if unloading has started. The disposer runs it once. */
|
||||
onDispose: (dispose: () => void) => Disposer;
|
||||
/** Absolute path of the plugin directory. */
|
||||
readonly dir: string;
|
||||
/** `plugins.<id>.settings` from config. */
|
||||
readonly settings: Readonly<Record<string, unknown>>;
|
||||
/**
|
||||
* `<config dir>/plugin-data/<id>/` for caches and state. Kept apart from `dir`, so replacing the
|
||||
* plugin folder keeps its data. Created before `activate`.
|
||||
*/
|
||||
readonly dataDir: string;
|
||||
readonly log: PluginLogger;
|
||||
/** Repeating timer; cleared automatically when the plugin unloads. */
|
||||
every: (intervalMs: number, callback: () => unknown) => Disposer;
|
||||
/** One-shot timer; cleared automatically when the plugin unloads. */
|
||||
after: (delayMs: number, callback: () => unknown) => Disposer;
|
||||
readonly mpv: {
|
||||
readonly connected: boolean;
|
||||
on: <E extends PluginMpvEventName>(
|
||||
event: E,
|
||||
listener: (payload: PluginMpvEventMap[E]) => unknown,
|
||||
) => Disposer;
|
||||
command: (command: (string | number)[]) => void;
|
||||
/** Resolves the mpv result; rejects on command error, disconnect, or a 4-second timeout. */
|
||||
request: (command: (string | number | boolean)[]) => Promise<unknown>;
|
||||
getProperty: (name: string) => Promise<unknown>;
|
||||
/** Connects to mpv, launching an idle mpv if none is running. Resolves false when that fails. */
|
||||
ensureRunning: () => Promise<boolean>;
|
||||
};
|
||||
readonly media: {
|
||||
current: () => PluginMediaSnapshot | null;
|
||||
/** Immediately delivers the current snapshot unless emitCurrent is false. */
|
||||
on: (
|
||||
event: 'change',
|
||||
listener: (media: PluginMediaSnapshot | null) => unknown,
|
||||
options?: { emitCurrent?: boolean },
|
||||
) => Disposer;
|
||||
};
|
||||
readonly anki: {
|
||||
on: <E extends PluginAnkiEventName>(
|
||||
event: E,
|
||||
listener: (payload: PluginAnkiEventMap[E]) => unknown,
|
||||
) => Disposer;
|
||||
/** Sends one AnkiConnect action to the configured `ankiConnect.url`. Rejects on an Anki error. */
|
||||
invoke: (
|
||||
action: string,
|
||||
params?: Record<string, unknown>,
|
||||
options?: PluginAnkiInvokeOptions,
|
||||
) => Promise<unknown>;
|
||||
};
|
||||
readonly subtitles: {
|
||||
current: () => PluginSubtitleLine | null;
|
||||
context: (options?: { before?: number; after?: number }) => PluginSubtitleContext;
|
||||
};
|
||||
readonly actions: {
|
||||
/** Bind with `["__plugin-action:<plugin id>/<name>"]` in `keybindings`. */
|
||||
register: (name: string, handler: () => unknown) => Disposer;
|
||||
/** Runs a built-in session action, the same ones keybindings use (e.g. `openJimaku`). */
|
||||
run: (actionId: SessionActionId, payload?: SessionActionPayload) => Promise<void>;
|
||||
};
|
||||
notify: (message: string, options?: { variant?: PluginNotifyVariant }) => void;
|
||||
readonly ui: {
|
||||
/** Opens the shared plugin panel, replacing any panel that is already open. */
|
||||
panel: (content: Partial<PluginPanelContent> & { markdown: string }) => PluginPanelHandle;
|
||||
/**
|
||||
* Opens a window for this plugin, or focuses it if the same `html` is already open.
|
||||
* The window is destroyed when the plugin unloads.
|
||||
*/
|
||||
openWindow: (options: PluginWindowOptions) => BrowserWindow;
|
||||
};
|
||||
/** Runs a binary without a shell, with the AppImage library path removed from its environment. */
|
||||
exec: (command: string, args: string[], options?: PluginExecOptions) => Promise<PluginExecResult>;
|
||||
/** Raw app internals. No stability guarantee across releases. */
|
||||
readonly unstable: TUnstable;
|
||||
}
|
||||
|
||||
export interface PluginModule {
|
||||
activate?: (ctx: PluginContext) => unknown;
|
||||
deactivate?: () => unknown;
|
||||
default?: { activate?: (ctx: PluginContext) => unknown; deactivate?: () => unknown };
|
||||
}
|
||||
@@ -1,36 +1,7 @@
|
||||
export type SessionKeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta';
|
||||
import type { SessionActionId, SessionActionPayload } from '../plugin-api';
|
||||
export type { SessionActionId, SessionActionPayload } from '../plugin-api';
|
||||
|
||||
export type SessionActionId =
|
||||
| 'toggleStatsOverlay'
|
||||
| 'markWatched'
|
||||
| 'toggleVisibleOverlay'
|
||||
| 'copySubtitle'
|
||||
| 'copySubtitleMultiple'
|
||||
| 'updateLastCardFromClipboard'
|
||||
| 'triggerFieldGrouping'
|
||||
| 'triggerSubsync'
|
||||
| 'mineSentence'
|
||||
| 'mineSentenceMultiple'
|
||||
| 'toggleSecondarySub'
|
||||
| 'toggleSubtitleSidebar'
|
||||
| 'toggleNotificationHistory'
|
||||
| 'appendClipboardVideoToQueue'
|
||||
| 'markAudioCard'
|
||||
| 'openRuntimeOptions'
|
||||
| 'openSessionHelp'
|
||||
| 'openCharacterDictionaryManager'
|
||||
| 'openControllerSelect'
|
||||
| 'openControllerDebug'
|
||||
| 'openJimaku'
|
||||
| 'openTsukihime'
|
||||
| 'openSubtitleSelection'
|
||||
| 'openSubtitleGeneration'
|
||||
| 'openYoutubePicker'
|
||||
| 'openPlaylistBrowser'
|
||||
| 'replayCurrentSubtitle'
|
||||
| 'playNextSubtitle'
|
||||
| 'cycleRuntimeOption'
|
||||
| 'runPluginAction';
|
||||
export type SessionKeyModifier = 'ctrl' | 'alt' | 'shift' | 'meta';
|
||||
|
||||
export interface SessionKeySpec {
|
||||
code: string;
|
||||
@@ -50,14 +21,6 @@ export interface SessionBindingWarning {
|
||||
conflictingPaths?: string[];
|
||||
}
|
||||
|
||||
export interface SessionActionPayload {
|
||||
count?: number;
|
||||
runtimeOptionId?: string;
|
||||
direction?: 1 | -1;
|
||||
/** `<plugin id>/<action name>` for runPluginAction. */
|
||||
pluginActionId?: string;
|
||||
}
|
||||
|
||||
type CompiledSessionBindingBase = {
|
||||
sourcePath: string;
|
||||
originalKey: string;
|
||||
|
||||
Reference in new issue
Block a user