From 15f32b10c70e717342071d77629d24fef5acf7fd Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 27 Oct 2026 22:31:34 -0700 Subject: [PATCH] feat(plugins): add episode-tags and auto-jimaku example plugins - episode-tags: tag mined cards with show/episode parsed from the file name or stream title - auto-jimaku: open Jimaku search when a file starts without a Japanese subtitle track - Hold card-mined for notes merged into a note still waiting on queued YouTube media - Route YouTube media settle reports through reportCardsReady - Document both plugins in docs-site and changelog fragment --- assets/plugins/auto-jimaku/main.js | 52 +++++++++++ assets/plugins/auto-jimaku/plugin.json | 5 ++ assets/plugins/episode-tags/main.js | 110 ++++++++++++++++++++++++ assets/plugins/episode-tags/plugin.json | 5 ++ changes/plugin-system.md | 3 + docs-site/plugins.md | 34 ++++++++ docs/architecture/README.md | 2 +- docs/architecture/domains.md | 2 +- src/anki-integration.test.ts | 4 +- src/anki-integration.ts | 11 ++- 10 files changed, 222 insertions(+), 6 deletions(-) create mode 100644 assets/plugins/auto-jimaku/main.js create mode 100644 assets/plugins/auto-jimaku/plugin.json create mode 100644 assets/plugins/episode-tags/main.js create mode 100644 assets/plugins/episode-tags/plugin.json diff --git a/assets/plugins/auto-jimaku/main.js b/assets/plugins/auto-jimaku/main.js new file mode 100644 index 00000000..80762e24 --- /dev/null +++ b/assets/plugins/auto-jimaku/main.js @@ -0,0 +1,52 @@ +// Opens the Jimaku subtitle search when a file starts without a Japanese subtitle track. +// It waits a few seconds after the file loads so external subtitles mpv or SubMiner add count. + +const DEFAULT_SETTINGS = { + // Built-in action to run, e.g. `openTsukihime` or `openSubtitleSelection`. + action: 'openJimaku', + // Track `lang` codes that count as Japanese. Track titles with "Japanese" or 日本語 count too. + languages: ['ja', 'jp', 'jpn'], + delayMs: 3_000, + // Also check http(s) streams; Jimaku can only search by name, which streams rarely have. + streams: false, +}; + +const JAPANESE_TITLE = /japanese|日本語/i; + +function activate(ctx) { + const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; + const languages = new Set(settings.languages.map((lang) => lang.toLowerCase())); + let cancelCheck = null; + + function hasJapaneseSubtitle(trackList) { + return trackList.some( + (track) => + track?.type === 'sub' && + (languages.has(String(track.lang ?? '').toLowerCase()) || + JAPANESE_TITLE.test(String(track.title ?? ''))), + ); + } + + async function check(mediaPath) { + cancelCheck = null; + const [currentPath, trackList] = await Promise.all([ + ctx.mpv.getProperty('path'), + ctx.mpv.getProperty('track-list'), + ]); + // Another file started while we waited; its own check is already scheduled. + if (currentPath !== mediaPath || !Array.isArray(trackList)) return; + if (hasJapaneseSubtitle(trackList)) return; + ctx.log.info(`No Japanese subtitles for ${mediaPath}; running ${settings.action}.`); + await ctx.actions.run(settings.action); + } + + ctx.mpv.on('media-path-change', ({ path: mediaPath }) => { + cancelCheck?.(); + cancelCheck = null; + if (!mediaPath) return; + if (!settings.streams && /^[a-z]+:\/\//i.test(mediaPath)) return; + cancelCheck = ctx.after(settings.delayMs, () => check(mediaPath)); + }); +} + +module.exports = { activate }; diff --git a/assets/plugins/auto-jimaku/plugin.json b/assets/plugins/auto-jimaku/plugin.json new file mode 100644 index 00000000..94ca3ef3 --- /dev/null +++ b/assets/plugins/auto-jimaku/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "Auto Jimaku", + "version": "1.0.0", + "main": "main.js" +} diff --git a/assets/plugins/episode-tags/main.js b/assets/plugins/episode-tags/main.js new file mode 100644 index 00000000..775c03d9 --- /dev/null +++ b/assets/plugins/episode-tags/main.js @@ -0,0 +1,110 @@ +// Tags each mined card with the show and episode it came from, e.g. `show::Frieren::S01E07`. +// The show and episode are parsed from the file name, or from mpv's media title for streams. +const path = require('node:path'); + +const DEFAULT_SETTINGS = { + // Placeholders: {show}, {season}, {episode}, and {ep} (S01E07, or E07 without a season). + // A tag stops before the first `::` level whose placeholder has no value. + tags: ['show::{show}::{ep}'], + notify: false, +}; + +// Tried in order; the first match wins. Runs on names with brackets and dots already removed. +const EPISODE_PATTERNS = [ + /^(?.*?)\s*S(?\d{1,2})\s?E(?\d{1,4})\b/i, + /^(?.*?)\s*(?:Season\s?(?\d{1,2})\s*)?(?:Episode|Ep)\s?(?\d{1,4})\b/i, + /^(?.*?)\s+-\s+(?\d{1,4})(?:v\d)?\b/, + /^(?.*?)\s+(?\d{1,4})(?:v\d)?$/, + /^(?.*?)\s*第(?\d{1,4})話/, +]; + +// `[Group] Show Name - 07 (1080p).mkv` -> { show: 'Show Name', season: null, episode: '07' } +function parseMediaName(name) { + const cleaned = name + .replace(/\.[a-z0-9]{2,4}$/i, '') + .replace(/\[[^\]]*\]|\([^)]*\)|【[^】]*】/g, ' ') + .replace(/[._]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + for (const pattern of EPISODE_PATTERNS) { + const groups = cleaned.match(pattern)?.groups; + const show = groups?.show.replace(/[\s-]+$/, ''); + if (!show) continue; + return { show, season: pad(groups.season), episode: pad(groups.episode) }; + } + return { show: cleaned || null, season: null, episode: null }; +} + +function pad(number) { + return number ? String(Number(number)).padStart(2, '0') : null; +} + +// Anki tags can't hold spaces, and a `::` in a value would add a hierarchy level. +function tagValue(value) { + return value.replace(/::?/g, ' ').trim().replace(/\s+/g, '_').replace(/"/g, ''); +} + +function renderTag(template, values) { + const levels = []; + for (const level of template.split('::')) { + let missing = false; + const rendered = level.replace(/\{(\w+)\}/g, (_, key) => { + const value = values[key]; + if (!value) missing = true; + return value ? tagValue(value) : ''; + }); + if (missing) break; + levels.push(rendered); + } + return levels.join('::'); +} + +async function currentMediaName(ctx) { + const [mediaPath, title] = await Promise.all([ + ctx.mpv.getProperty('path'), + ctx.mpv.getProperty('media-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)) { + return path.basename(mediaPath); + } + return typeof title === 'string' && title ? title : null; +} + +function activate(ctx) { + const settings = { ...DEFAULT_SETTINGS, ...ctx.settings }; + + ctx.anki.on('card-mined', async ({ noteIds }) => { + if (noteIds.length === 0) return; + let name; + try { + name = await currentMediaName(ctx); + } catch (error) { + ctx.log.warn(`Could not read the playing media: ${error.message}`); + return; + } + if (!name) return; + + const { show, season, episode } = parseMediaName(name); + const ep = episode ? `${season ? `S${season}` : ''}E${episode}` : null; + const values = { show, season, episode, ep }; + const tags = [...new Set(settings.tags.map((template) => renderTag(template, values)))].filter( + Boolean, + ); + if (tags.length === 0) { + ctx.log.debug(`No tags for "${name}".`); + return; + } + + try { + await ctx.anki.invoke('addTags', { notes: noteIds, tags: tags.join(' ') }); + ctx.log.info(`Tagged ${noteIds.length} note(s): ${tags.join(' ')}`); + if (settings.notify) ctx.notify(`Tagged ${tags.join(' ')}`); + } catch (error) { + ctx.log.warn(`Tagging failed: ${error.message}`); + ctx.notify(`Episode tagging failed: ${error.message}`, { variant: 'error' }); + } + }); +} + +module.exports = { activate }; diff --git a/assets/plugins/episode-tags/plugin.json b/assets/plugins/episode-tags/plugin.json new file mode 100644 index 00000000..10714998 --- /dev/null +++ b/assets/plugins/episode-tags/plugin.json @@ -0,0 +1,5 @@ +{ + "name": "Episode Tags", + "version": "1.0.0", + "main": "main.js" +} diff --git a/changes/plugin-system.md b/changes/plugin-system.md index 09d724af..d067235e 100644 --- a/changes/plugin-system.md +++ b/changes/plugin-system.md @@ -4,3 +4,6 @@ area: plugins - Added JavaScript plugins loaded at startup from `/plugins//` and enabled under `plugins` in the config. Plugins can listen to mpv events, run timers, register actions bound with `__plugin-action:/` keybindings or run with `--plugin-action`, run built-in actions, react to mined Anki cards and call AnkiConnect, start mpv, run programs, keep state in a per-plugin data folder, and show results in an overlay panel or their own window. - Added an example `ai-explain` plugin that sends the current line and its neighbors to `codex exec` and shows a translation or grammar explanation. - 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. diff --git a/docs-site/plugins.md b/docs-site/plugins.md index 2b9a1ac8..b1b3a174 100644 --- a/docs-site/plugins.md +++ b/docs-site/plugins.md @@ -72,6 +72,40 @@ Press `Esc` to close the panel. Closing it, or starting another request, stops t Anki must be running and logged in to AnkiWeb. Cards mined from the stats dashboard don't trigger a sync. +## Example: tag cards with the episode + +[`assets/plugins/episode-tags`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/episode-tags) tags each mined card with the show and episode it came from, such as `show::Sousou_no_Frieren::E07`. It reads them from the file name (`[Group] Show - 07 (1080p).mkv`, `Show.S01E07.mkv`, `Show 第7話.mkv`), or from the title mpv shows for streams. Spaces become `_`. + +| Setting | Default | What it does | +| -------- | ------------------------ | ----------------------------------------------- | +| `tags` | `["show::{show}::{ep}"]` | Tags to add, one template each | +| `notify` | `false` | Show a notification with the tags for each card | + +Templates can use `{show}`, `{season}`, `{episode}`, and `{ep}` (`S01E07`, or `E07` when there is no season). When a placeholder has no value, the tag stops before that `::` level, so a movie gets `show::`. The parsing is a guess from the name: a title ending in a number, like `Mob Psycho 100`, reads that number as the episode. + +## Example: open Jimaku when a file has no Japanese subtitles + +[`assets/plugins/auto-jimaku`](https://github.com/ksyasuda/SubMiner/tree/main/assets/plugins/auto-jimaku) opens the Jimaku search a few seconds after a file starts if none of its subtitle tracks is Japanese. A track counts as Japanese when its language code is in `languages`, or its title contains "Japanese" or 日本語. + +| Setting | Default | What it does | +| ----------- | --------------------- | --------------------------------------------------------------- | +| `action` | `"openJimaku"` | Built-in action to run, e.g. `openTsukihime` | +| `languages` | `["ja", "jp", "jpn"]` | Track language codes that count as Japanese | +| `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`: diff --git a/docs/architecture/README.md b/docs/architecture/README.md index adc22293..bad5a2c3 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -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, 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. 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/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. diff --git a/docs/architecture/domains.md b/docs/architecture/domains.md index be08cd20..bc29ba91 100644 --- a/docs/architecture/domains.md +++ b/docs/architecture/domains.md @@ -34,7 +34,7 @@ Read when: you need to find the owner module for a behavior or test surface - AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/` - TMDB live-action metadata: `src/core/services/tmdb/` (client + exact-title resolver), `src/core/services/immersion-tracker/live-action-link.ts` (links an entry to a TMDB title and merges other holders of the same title). The AniList cover-art fetcher calls the resolver as its fallback; `imm_anime.media_kind` marks the result and keeps the entry out of AniList season repair. - Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*` -- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), `src/renderer/modals/plugin-panel*.ts`; example in `assets/plugins/` +- User plugins: `src/core/services/plugins/` (host, `ctx`, `exec`), `src/main/runtime/plugin-panel-runtime.ts` (panel modal), `src/main/runtime/plugin-window-runtime.ts` (plugin windows), `src/renderer/modals/plugin-panel*.ts`; examples in `assets/plugins/` - Window trackers: `src/window-trackers/` - Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support in `src/core/services/stats-server/` diff --git a/src/anki-integration.test.ts b/src/anki-integration.test.ts index 90665a1a..012fea75 100644 --- a/src/anki-integration.test.ts +++ b/src/anki-integration.test.ts @@ -458,8 +458,10 @@ test('AnkiIntegration reports a card with queued YouTube media ready once the me integration.setCardsReadyCallback((noteIds) => events.push(`ready ${noteIds.join(',')}`)); const internals = internalsOf(integration); internals.queuePendingYoutubeMediaUpdate(youtubeJob({ noteId: 101 })); + // Note 7 was merged into 101, so it waits on 101's media too. + internals.rememberMergedNoteIds(7, 101); - internals.reportCardsReady([101, 5]); + internals.reportCardsReady([101, 7, 5]); assert.deepEqual(events, ['ready 5']); await integration.handleYoutubeMediaCacheReady('https://youtu.be/abc123', '/tmp/media.mkv'); diff --git a/src/anki-integration.ts b/src/anki-integration.ts index fcf6c65a..afcb94a6 100644 --- a/src/anki-integration.ts +++ b/src/anki-integration.ts @@ -344,9 +344,14 @@ export class AnkiIntegration { this.heldReadyNoteIds.push(...noteIds); return; } - // Notes still waiting on queued YouTube media are reported by the queue once it settles. + // Notes still waiting on queued YouTube media, directly or through the note they were + // merged into, are reported by the queue once it settles. + const queue = this.pendingYoutubeMediaQueue; this.emitCardsReady( - noteIds.filter((noteId) => !this.pendingYoutubeMediaQueue.hasPendingNote(noteId)), + noteIds.filter( + (noteId) => + !queue.hasPendingNote(noteId) && !queue.hasPendingNote(this.resolveCurrentNoteId(noteId)), + ), ); } @@ -439,7 +444,7 @@ export class AnkiIntegration { showStatusNotification: (message) => this.showStatusNotification(message), showNotification: (noteId, label, errorSuffix) => this.showNotification(noteId, label, errorSuffix), - onUpdateSettled: (noteId) => this.emitCardsReady([noteId]), + onUpdateSettled: (noteId) => this.reportCardsReady([noteId]), logInfo: (...args) => log.info(args[0] as string, ...args.slice(1)), logWarn: (...args) => log.warn(args[0] as string, ...args.slice(1)), logError: (...args) => log.error(args[0] as string, ...args.slice(1)),