Compare commits

..

57 Commits

Author SHA1 Message Date
sudacode c60a87e422 fix(anime): clean up discarded queued subtitles in background
- Let queued episode discard return before subtitle preparation finishes
- Remove the redundant queued subtitle loading command
2026-08-15 00:16:00 -07:00
sudacode 9bff3f6b56 feat(anime): append queued episodes to mpv playlist
- Resolve queued streams immediately and cache subtitles in the background
- Attach prepared tracks as mpv advances through the playlist
2026-08-14 23:45:45 -07:00
sudacode 49271ba491 fix(macos): bring app forward when opening settings/sync/anime windows
- add activateMacOSApp to steal app focus before window.focus(), since show()/focus() only reorder windows within an already-active app
- wire activateApp through config-settings-window/runtime into the settings, sync, and anime browser window handlers
- restore the anime browser's Dock icon before showing the window instead of after, since an accessory process cannot become frontmost
2026-08-14 22:42:22 -07:00
sudacode 318b342b0d feat(anime): queue episodes to play next across anime
- Episode rows gain Play/Queue actions (and matching context-menu items); queued rows show their place in line, with a queue count and Clear queue in the episode header
- Queue lives in the main process (`anime-browser-queue.ts`) so it survives the browser window closing and advances on mpv's end-file even when nobody is watching; streams resolve at play time so a signed URL cannot expire while queued
- Holds mpv's keep-open off while the queue waits and restores it once empty; queueing with nothing playing just plays immediately
- Adds anime-browser-queue and episode-queue unit tests, IPC channels/contracts, and doc updates
2026-08-14 22:42:22 -07:00
sudacode 14330af303 fix(subsync): decode file:// tracks mpv reports for dropped subtitles
- Sync a subtitle dropped onto mpv without hitting "Protocol file: not supported"; decode file:// external-filename/path back to a real path for target, reference, and video
- Stream strip proxy: destroy the connection instead of retrying/502 once a response is handed off, and cap buffered playlist bodies
- Bridge installer: cap downloaded bundle size to guard against a lying/missing content-length
- Anime browser playback: bump generation on dispose so a stale in-flight playEpisode cleans up its own subtitle cache
- Fix a flaky sidecar-process test by binding to an OS-assigned port instead of pre-allocating one
2026-08-14 22:42:22 -07:00
sudacode aaa34c0e7f feat(anime): show extension and repo icons in extensions panel
- Add extension-icons module (buildIconIndex, isSafeIconUrl, repoFaviconUrl, iconMonogram) with tests
- Render each row's icon with a monogram fallback while loading or missing
- Installed extensions borrow their icon from the catalogue; repo rows use the index host's favicon
- Restrict icon loads to https URLs
- Thread iconUrl through AvailableExtension and the runtime, document the feature
2026-08-14 22:42:22 -07:00
sudacode 0e56af04bc fix(anime): isolate mark-watched requests and batch marks atomically
- Track mark writes with their own LatestRequest so a concurrent watch-state
  refresh can't supersede them, and re-read the store after a write to settle
  stale repaints
- Wrap batch markEpisodesWatched in a single transaction so partial rows and
  their lifetime summary rebuild can't land separately; defer clearing the
  active session's markedWatched flag until after commit
2026-08-14 22:42:22 -07:00
sudacode 1c9b2426d9 feat(anime): filter episodes and mark them watched by hand
- Add a filter box above the episode list for a number, range, or name substring, with a "N of M" counter
- Read watch marks from the immersion tracker stats and show them per episode, with a watched count in the header, refreshed on window focus
- Add a right-click menu to mark one episode or a whole catch-up span (this and everything below) watched/unwatched
- Add setWatched/getWatchState IPC plumbing so a manual mark creates the stats row for an episode that was never played
2026-08-14 22:42:11 -07:00
sudacode 2c5c8b91b8 fix(anime): stop superseded playEpisode calls from touching mpv
- Extend the generation guard from subtitleCacheDir to the whole
  playback: a stale playEpisode call now bails out before sending mpv
  commands, showing the overlay, or reading a playback outcome that
  belongs to a newer episode
- Add a regression test where a slow-resolving first call must not
  drive mpv after a second call for a newer episode has already
  started playing
2026-08-14 22:41:48 -07:00
sudacode 409327de0b fix(anime): guard playback races and harden install/extract tests
- Tag subtitle cache writes with a generation counter so overlapping playEpisode calls can't clobber the shared cache dir, and don't fail playback when track setup errors
- Ignore stale episode clicks in the detail panel via a LatestRequest guard on playback
- Extract a resetGrid helper in animeui to dedupe grid-clearing logic
- Assert reader cancellation and extracted-file writes actually happen in installer/subsync tests
2026-08-14 22:41:48 -07:00
sudacode 593656d54f fix(anime): scope preferences, paginate sources, harden installs
- Preference store keys entries by extension package + bridge source id; legacy unscoped entries are discarded once instead of being handed to whichever extension asks first
- Source picker's "Load more" appends the next page without duplicating streamed results
- Repository index fetches and subtitle/APK downloads now time out and are size-bounded instead of hanging or growing unbounded
- APK installs are staged to a temp file and renamed into place
- Stream metadata lookup matches the requested path, not only the currently playing one
- Reworked animeui into browse-state/detail-panel/panels.css modules
- Reverted premature CHANGELOG unreleased entries; refreshed anime-browser docs
2026-08-14 22:41:48 -07:00
sudacode 77328f61ee docs: catch up docs for anilist, anime browser, and subsync fixes
- Add changelog entries for the split-cour AniList season resolution fix and the subsync stream-track fixes
- Document anime browser language filter, stream subtitle download-to-disk behavior, and series/season/episode metadata threading
- Update domain ownership and knowledge-base catalog for the anime browser modules
- Trim the credits table and clarify the M-Extension-Server licensing note in README
2026-08-14 22:40:39 -07:00
sudacode 44956f8382 fix(anime): thread episode metadata through stream playback and fix seas
- Split anime browser stream titles into series/season/episode fields (episode-metadata.ts) instead of one joined string, so stats grouping, the mpv title, and the Jimaku/TsukiHime modals all get real values instead of a filename-derived "m3u8"
- Fix AniList season resolver to walk through split-cour sequels and specials without counting them as a season, and to surface rate-limit/network failures instead of falling through to a wrong air-order guess
- Add a Season field to the TsukiHime modal and include later seasons in its search query
- Rewrite WebVTT subtitle tracks as SRT before handing them to alass, since alass has no VTT support and streams serve VTT
- Update CHANGELOG and troubleshooting docs
2026-08-14 22:40:23 -07:00
sudacode 93301e4a22 fix(subsync): resolve ffmpeg only when a track needs extraction
Stream subtitles arrive as URLs and are downloaded, never piped through ffmpeg, so demanding an ffmpeg install up front broke stream syncs (and CI, which has no ffmpeg). Resolution is now lazy, scoped to embedded tracks.
2026-08-14 22:38:56 -07:00
sudacode a6755f45d0 fix(subsync): sync subtitles from stream URLs, auto-discover tool paths
- Download URL-loaded subtitle tracks (Aniyomi extension streams, Jellyfin) to a temp file first, reusing mpv's own request headers, instead of rejecting them with "Subtitle file not found"
- Pass mpv's headers through to ffmpeg for internal track extraction from streams too
- Auto-discover alass/ffsubsync/ffmpeg on PATH and common install prefixes when the config path is empty, instead of a hard-coded /usr/bin fallback that never exists on macOS
- Log subsync failures to the app log, not just a toast that vanishes in seconds
- Update docs-site and README credits for the new behavior
2026-08-14 22:38:56 -07:00
sudacode 42cced87b9 fix(anime): cap outcome polling and upstream stalls by wall clock
- watchPlaybackOutcome now tracks a real deadline so slow property reads eat the timeout budget instead of extending it, and a zero probe interval still terminates
- stream strip proxy aborts upstream GETs that go silent for 15s instead of hanging mpv on that segment forever
- anime browser runtime drops a stream proxy that finishes starting after its bridge already died, instead of leaking a listener pointed at nothing
2026-08-14 22:38:31 -07:00
sudacode 8bca9c31ff feat(anime): cache stream subtitles to disk for the alass reference
- Download a stream's subtitle tracks to a temp dir and load them into mpv as files instead of streaming from the source URL, so Subsync can use them as the alass reference (mirrors the Jellyfin subtitle preload)
- Detect format from content first, falling back to the URL extension
- A track that fails to download falls back to its URL so the episode still plays
- Remove the temp dir when the next episode loads or the runtime disposes
2026-08-14 22:38:31 -07:00
sudacode 412b481987 fix(anime): report a failed bridge kill instead of a generic timeout
- Track kill errors separately from exit/spawn so a signal failure (e.g. EPERM) surfaces its own message instead of a bare "did not exit after SIGKILL"
- Attach stop() failures as the cause on the readiness-timeout error so a surviving child's kill failure isn't dropped
- Skip stop() entirely when spawn itself failed, and stop polling once the ready deadline has passed
- Add stopTimeoutMs test hook and a test covering a kill that fails without the child exiting
2026-08-14 22:38:31 -07:00
sudacode 2ac7a06e86 style(animeui): tighten source picker and cue rail layout
- Lay source picker label + input inline instead of stacked
- Let detail cover keep natural aspect ratio instead of forced 2:3 crop
- Widen cue rail gutter to match new cue number column width
2026-08-14 22:38:31 -07:00
sudacode 65a280fe60 fix(overlay): fall back to did-finish-load when ready-to-show never fires
A hidden overlay window stops producing frames once it is explicitly
hidden and then reloaded (the Yomitan content-script reload right after
startup), so ready-to-show never fires and the content-ready gate keeps
the window hidden forever behind the Overlay loading spinner. Mark
content ready from did-finish-load after a 1.5s grace period so the
overlay always becomes showable; ready-to-show still wins when it fires.
2026-08-14 22:38:31 -07:00
sudacode f32dc6c49e fix(anime): restart the bridge on the next request after it dies
The reported 'APK crash' turned out not to exist: repeated full scans of
all installed extensions load cleanly. The real incident was the app's
quit sequence stopping the bridge (its /stop endpoint) while requests
were in flight - and it exposed that a bridge dying for any reason left
the anime browser failing every call with a raw 'fetch failed' until
the whole app was restarted.

SidecarHandle now reports the child's exit (including to late
subscribers), and the runtime detects a death it did not order: it
drops the dead handle, closes the strip proxy, tells the UI, and the
next request transparently starts a fresh bridge. requireBridge became
an async bridge() that starts on demand, which also covers first use.

Verified live: search -> SIGKILL the JVM -> search recovers.
2026-08-14 22:38:31 -07:00
sudacode f759eb38f1 fix(anime): never forward Range to the bridge so 206 replies cannot skip the strip
ffmpeg opens every HLS segment with 'Range: bytes=0-'. The bridge
answers some of those 206 (depending on its cache state), and the proxy
only rewrites full 200 bodies, so the PNG disguise passed through
untouched and whether an episode played was a coin flip: first attempts
died with "no audio or video data played", the same URL played fine
once the bridge had the segments cached.

Reproduced end to end against a private bridge + mpv driven with the
app's exact commands; with Range dropped every segment is stripped and
playback runs.
2026-08-14 22:38:31 -07:00
sudacode d37dda6dde fix(anime): retry cold upstream fetches in the stream strip proxy
Right after an episode resolves, the bridge (or the host behind it) can
error on the very first segment fetches and be fine a moment later; mpv
sweeps the playlist of failing segments in ~2s and gives up with "no
audio or video data played" while a manual reload of the same URL plays.
Retry a failed GET once after 400ms and log upstream error statuses so
the next failure names the real cause in the app log.
2026-08-14 22:38:31 -07:00
sudacode cebf52a5bb fix(anime): strip disguised HLS segments and report real playback errors
Some hosts prepend a fake image header (a real 1x1 PNG) to every HLS
segment; ffmpeg probes the segment as a picture and mpv drops back to
idle with no window while the browser claims the episode is playing.

- Route bridge-served m3u8 streams through a local strip proxy that
  scans each segment for the first genuine MPEG-TS packet run and drops
  the junk in front of it; playlists get absolute origins rewritten so
  segment requests come back through the proxy. Non-TS bodies pass
  through untouched.
- Only report ok from playEpisode once mpv configures a video output;
  an end-file with reason error surfaces mpv's own message (e.g. "no
  audio or video data played") in the browser status bar instead of
  "Playing". New end-file event plumbed through the mpv IPC client.
2026-08-14 22:38:31 -07:00
sudacode af8d50a621 fix(anime): pin linux-x64 bridge checksum and fetch release by tag
- Add PINNED_BUNDLE_SHA256 entry for linux-x64-bundle.zip so Linux no longer refuses to start with "No pinned checksum"
- Fetch the bridge release by its pinned tag (releases/tags/<tag>) instead of listing newest releases, so an upstream publish can't swap in an unverified asset
- Update docs and changelog to describe the pin/tag behavior
2026-08-14 22:38:31 -07:00
sudacode 891a7f2880 feat(anime): add language filter to extensions available list
- Add a language chip row above the Available list; pick one or more languages to narrow the repo index, or "All" to clear
- Show language display names (e.g. "Japanese") instead of raw tags, and an "N of M" count on the Available heading
- Add language-filter.ts with collectLanguages/toggleLanguage/pruneSelection/filterByLanguage/languageLabel, plus tests
2026-08-14 22:38:31 -07:00
sudacode 381ae1ca2e fix(anime): honor the sidecar readiness deadline and confirm shutdown
The capabilities probe used a fixed 5s timeout, so a readiness budget
shorter than that could be overrun by one stalled request. Pass the
remaining deadline down instead.

stop() also resolved after the SIGKILL wait even when the child had not
exited, letting a restart race a process still holding the port. Throw
in that case, and keep a failed shutdown from masking the readiness
error at startup.
2026-08-14 22:38:31 -07:00
sudacode 87106c9143 fix(anime): harden the extension bridge against untrusted repos and hangs
Addresses CodeRabbit review feedback on the Anime Browser:

- reject repository package/apk names that are not plain identifiers, and
  verify the install target resolves inside the extensions directory
- count mpv's %n% option escape in UTF-8 bytes, and escape backslashes in
  header values so a trailing one cannot eat the list separator
- key the bridge extension-id cache by APK content, so an in-place upgrade
  re-uploads instead of running the previous build
- bound every bridge, release-listing, and download request with a timeout
- enforce the APK size limit while streaming rather than after buffering
- read APK bytes on demand instead of holding a base64 copy per extension
  for the lifetime of the browser
- serialize preference mutations and write the file atomically
- handle the sidecar spawn error event, and wait for the child to exit in
  stop() before returning
- report a failed Anime Browser bootstrap instead of showing the starting
  banner forever
- keep the preferences panel's save confirmation and in-flight multi-select
  edits by re-rendering only on a structural schema change
2026-08-14 22:38:31 -07:00
sudacode 2512e0f3af feat(anime): add anime browser powered by Aniyomi extensions
- Add `subminer anime` / `--anime` and a tray entry to open a browser that searches installed Aniyomi extension sources, shows cover art and episodes, and plays into mpv with overlay/mining attached
- Add an Extensions tab to add repos and install/update/remove sources, and per-source settings for sources needing config
- Support searching all sources at once with streaming, per-source results and status
- Prefer Japanese audio/subtitle tracks from the source and keep the primary subtitle slot reserved for Japanese
- Fix window/tray/Dock handling so the browser and mpv can be switched between without quitting the app or losing the Dock icon
- Add anime.repos, anime.extensionsDir, anime.preferredQuality config keys (no bundled repos or discovery)
2026-08-14 22:38:31 -07:00
sudacode 7d729cb60c fix(notifications): replace Linux progress updates in place (#198) 2026-08-14 22:20:33 -07:00
sudacode d963def1a9 fix(stats): subtract lifetime totals incrementally on delete (#196) 2026-08-14 22:13:59 -07:00
sudacode 6fb3d2eb13 feat(stats): add library entry merge and episode move (#190) 2026-08-14 21:54:17 -07:00
sudacode c5a77ac067 fix(anki): snapshot mining media clip timing (#197) 2026-08-14 21:50:32 -07:00
sudacode 8ddc151435 docs: hide unfinished feature demos from sidebar 2026-08-14 21:50:08 -07:00
sudacode b98d4d65c7 fix(stats): stop counting duplicate typeset subtitle lines (#191) 2026-08-14 00:12:56 -07:00
sudacode 046e74ea91 chore(workflow): simplify agent tooling and docs 2026-08-13 23:34:04 -07:00
sudacode 8bf847503d fix(media): tolerate slow MKV audio extraction (#195) 2026-08-13 23:19:22 -07:00
sudacode 47b5903392 chore(release): prepare v0.19.3 2026-08-13 23:02:57 -07:00
sudacode bf85554d1e fix(stats): batch deletes off the main thread (#194) 2026-08-13 22:31:52 -07:00
sudacode d74c7e1235 chore: add Claude instructions symlink and update js-yaml 2026-08-11 22:31:26 -07:00
sudacode 57ddd19953 fix(overlay): handle X11 display scaling across monitors (#193) 2026-08-11 22:24:01 -07:00
sudacode ee25536d90 fix(dictionary): stop large character dictionaries from timing out (#189) 2026-08-11 18:40:18 -07:00
sudacode 7b0fbdf254 fix(subtitles): collapse duplicate ASS events and decode text once (#186) 2026-08-10 22:21:44 -07:00
sudacode 2fefc83e3f fix(playback): stop forcing legacy OpenGL renderer on X11 mpv backend (#188) 2026-08-06 23:52:35 -07:00
sudacode dbdf578c68 perf(tokenizer): single-pass Yomitan scan with cross-line caching and prefetch fixes (#185) 2026-08-06 21:44:09 -07:00
sudacode 441ecf3c04 feat(overlay): add in-app changelog modal (#187) 2026-08-05 22:19:13 -07:00
sudacode a0dde4ee3e chore(release): v0.19.2 2026-08-04 18:51:15 -07:00
sudacode fe4dacc1e7 fix(overlay): show plain subtitle line immediately on tokenization cache miss (#184) 2026-08-04 01:55:52 -07:00
sudacode b08cd0db35 fix(streaming): keep subtitle tokenization prefetch warm for full episodes (#183) 2026-08-03 21:22:18 -07:00
sudacode bffb1c5982 fix(logging): surface subtitle processing debug/warn logs (#182) 2026-08-03 20:44:39 -07:00
sudacode 5b8848518a feat(subsync): add reference and target subtitle track picker (#181) 2026-08-03 01:00:14 -07:00
sudacode 176edd67f1 chore(release): v0.19.1 2026-08-01 23:59:20 -07:00
sudacode 4d65dec340 fix(youtube): prevent playlist URLs from stalling yt-dlp probes (#180) 2026-08-01 22:56:28 -07:00
sudacode 6607c333bc fix(overlay): strip spinner frame from subsync overlay card
- Add overlayBody override to ConfiguredStatusNotificationOptions so overlay/OSD/desktop can diverge
- Extract getSubsyncStatusNotificationOptions() to strip the ASCII spinner frame from the overlay card (OSD keeps it since it renders the raw spinner)
- Add tests for spinner stripping and subsync result notifications
2026-07-31 18:03:26 -07:00
sudacode b2bbf1ae12 chore: regenerate config example artifacts 2026-07-31 17:49:02 -07:00
sudacode b204d4dd6e feat(anki): add configurable word card type for Kiku/Lapis (#175) 2026-07-31 17:17:29 -07:00
sudacode 89ed675935 fix(overlay): keep Yomitan popup interactive on macOS/Windows (#177) 2026-07-30 19:47:08 -07:00
464 changed files with 41386 additions and 8363 deletions
-20
View File
@@ -1,20 +0,0 @@
{
"name": "subminer-local",
"interface": {
"displayName": "SubMiner Local"
},
"plugins": [
{
"name": "subminer-workflow",
"source": {
"source": "local",
"path": "./plugins/subminer-workflow"
},
"policy": {
"installation": "AVAILABLE",
"authentication": "ON_INSTALL"
},
"category": "Productivity"
}
]
}
@@ -1,22 +1,45 @@
---
name: 'subminer-change-verification'
description: 'Compatibility shim. Canonical SubMiner change verification workflow now lives in the repo-local subminer-workflow plugin.'
name: subminer-change-verification
description: Verify SubMiner changes with repo-native cheap-first test lanes. Use after code, config, launcher, plugin, runtime, stats, documentation, or workflow changes; do not use for read-only questions.
---
# Compatibility Shim
# SubMiner Change Verification
Canonical source:
Verify the behavior claimed by a change without running unrelated expensive checks by default.
- `plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md`
## Workflow
Canonical helper scripts:
1. Inspect the requested scope and changed paths with `git status --short` and `git diff`.
2. Read `docs/workflow/verification.md` as the source of truth for maintained lanes.
3. Run the cheapest lane or lanes that cover the changed behavior.
4. Escalate to the full handoff gate only for substantial or cross-boundary changes.
5. Report exact commands, results, skipped checks, blockers, and remaining risk.
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh`
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
Do not use hidden wrapper commands. Verification commands are owned by `package.json` and the workflow documentation.
When this shim is invoked:
## Lane Selection
1. Read the canonical plugin-owned skill.
2. Follow the plugin-owned skill as the source of truth.
3. Use the wrapper scripts in this shim directory only for compatibility with existing commands and docs.
4. Do not duplicate workflow changes here; update the plugin-owned skill and scripts instead.
- Internal docs, `AGENTS.md`, or `.agents/skills/**`: `bun run test:docs:kb`
- User-facing `docs-site/**`: `bun run docs:test`, then `bun run docs:build`
- Config/schema/defaults: `bun run test:config`
- If defaults or templates changed, also run `bun run generate:config-example` and `bun run verify:config-example`.
- General TypeScript source: `bun run typecheck`, then `bun run test:fast`
- Launcher or mpv plugin: `bun run test:launcher` or `bun run test:env`, based on the behavior changed
- Runtime compatibility or dist-sensitive wiring: `bun run test:runtime:compat`
- Stats dashboard: `bun run test:stats`
- Build/release scripts: `bun run test:scripts`
For substantial changes, use the full gate documented in `AGENTS.md` and `docs/workflow/verification.md`.
## Runtime Escalation
Real runtime checks are required when the claim depends on actual Electron, mpv, overlay, focus, window tracking, launch, or socket behavior. Run the relevant application flow when the environment supports it. Otherwise, report the missing runtime dependency and do not present cheaper checks as authoritative runtime validation.
## Pre-Handoff Checks
Before handoff, reconcile both questions:
1. Do behavior, defaults, flags, shortcuts, ports, APIs, architecture, or workflow changes require documentation updates?
2. Does the change require a current-outcome fragment under `changes/` according to `changes/README.md`?
Complete required updates before handoff or report the blocker.
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/../../../.." && pwd)
TARGET="$REPO_ROOT/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh"
if [[ ! -x "$TARGET" ]]; then
echo "Missing canonical script: $TARGET" >&2
exit 1
fi
exec "$TARGET" "$@"
@@ -1,13 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
REPO_ROOT=$(cd "$SCRIPT_DIR/../../../.." && pwd)
TARGET="$REPO_ROOT/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh"
if [[ ! -x "$TARGET" ]]; then
echo "Missing canonical script: $TARGET" >&2
exit 1
fi
exec "$TARGET" "$@"
@@ -1,18 +0,0 @@
---
name: 'subminer-scrum-master'
description: 'Compatibility shim. Canonical SubMiner scrum-master workflow now lives in the repo-local subminer-workflow plugin.'
---
# Compatibility Shim
Canonical source:
- `plugins/subminer-workflow/skills/subminer-scrum-master/SKILL.md`
When this shim is invoked:
1. Read the canonical plugin-owned skill.
2. Follow the plugin-owned skill as the source of truth.
3. Do not duplicate workflow changes here; update the plugin-owned skill instead.
This shim exists so existing repo references and prompts keep resolving during the migration to the repo-local plugin workflow.
+12 -15
View File
@@ -1,4 +1,4 @@
# AGENTS.MD
# AGENTS.md
## Internal Docs
@@ -13,7 +13,7 @@ Start here, then leave this file.
`docs-site/` is user-facing. Do not treat it as the canonical internal source of truth.
`CLAUDE.md` is a symlink to this file there is one project instruction file, not two.
`CLAUDE.md` is a symlink to this file; there is one project instruction file, not two.
## Quick Start
@@ -25,8 +25,9 @@ Start here, then leave this file.
## Build / Test
- Runtime/package manager: Bun (`packageManager: bun@1.3.5`)
- Default handoff gate:
- Runtime/package manager: Bun; use the version pinned by `package.json`.
- Follow [`docs/workflow/verification.md`](./docs/workflow/verification.md) and start with the cheapest sufficient lane.
- Full handoff gate for substantial changes:
`bun run typecheck`
`bun run test:fast`
`bun run test:env`
@@ -44,13 +45,15 @@ Start here, then leave this file.
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
- Stats dashboard UI (`stats/`): `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts`
- Docs-only: `bun run docs:test`, then `bun run docs:build`
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
- macOS mpv window helper: `bun test scripts/get-mpv-window-macos.test.ts`
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
## Docs Upkeep
- Docs ship with the change, not after. If a change alters behavior, defaults, flags, shortcuts, ports, or APIs, update the matching docs in the same PR. Touching code without reconciling its docs is an incomplete change.
- Source of truth for config defaults is the generated `config.example.jsonc`. Never write a default value into prose you didn't read from it and don't restate the same default across multiple docs; cite/link to one place so there's a single thing to update.
- Source of truth for config defaults is the generated `config.example.jsonc`. Never write a default value into prose you didn't read from it, and don't restate the same default across multiple docs; cite/link to one place so there's a single thing to update.
- Trigger map (touch left → update right):
- `src/config/definitions/**` (schema/defaults/template) → `bun run generate:config-example`, then reconcile `docs-site/configuration.md` + any feature doc that cites that default
- shortcuts/keybindings (`shortcuts.*`, `keybindings`, `stats.*Key`, `subtitleSidebar.toggleKey`, controller bindings) → `docs-site/shortcuts.md`
@@ -71,16 +74,10 @@ Start here, then leave this file.
## Release / PR Notes
- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out)
- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md`. Format and rules live in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out).
- User-visible docs changes get a `type: docs` fragment
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
- PR review helpers:
- `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'`
- `gh pr view --json number,title --jq '"PR #\\(.number): \\(.title)"'`
- `gh api repos/:owner/:repo/pulls/<num>/comments --paginate`
## Runtime Notes
- Use Codex background for long jobs; tmux only when persistence/interaction is required
- CI red: `gh run list/view`, rerun, fix, repeat until green
- TypeScript: keep files small; follow existing patterns
- Only Swift is the `scripts/get-mpv-window-macos.swift` helper (macOS mpv window detection); validate via `bun test scripts/get-mpv-window-macos.test.ts`
- For CI debugging, inspect runs with `gh run list/view`; rerun or fix only within the requested scope.
+51
View File
@@ -1,5 +1,56 @@
# Changelog
## v0.19.3 (2026-08-13)
### Added
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
### Changed
- Subtitle Tokenization Performance: Reworks subtitle dictionary lookups to cut per-line work roughly in half, cache repeated lookups across lines, and stop tokenization from competing with on-screen subtitle prefetching. Also fixes several accuracy issues along the way: dropped readings on trailing kana, character names being skipped after a dictionary sync, annotations not refreshing after mining a card, and halfwidth katakana character names losing their reading or being swallowed by other words.
### Fixed
- Character Dictionary Large Imports: Large character dictionaries (e.g. One Piece) no longer fail to install from a fixed timeout budget; the import now scales its time budget to dictionary size and reports detailed progress (page/character counts, image download progress, elapsed time) instead of one static message.
- Stats Delete Responsiveness: Deleting sessions, episodes, or library entries no longer freezes the stats page or an active video player; deletes are now batched into a single transaction.
- Styled Subtitle Cue Parsing: Heavily typeset subtitles (karaoke, signs) no longer flood the subtitle sidebar with garbage; vector drawing commands are no longer shown as text, and duplicate/animation-burst cues now collapse into one.
- X11 mpv Renderer: Fixes an mpv crash on the first fullscreen toggle for X11/XWayland users with `gpu-next` shaders (e.g. ArtCNN), which was caused by X11 mode forcing the legacy OpenGL renderer.
- X11 Overlay Display Scaling: Fixes the overlay appearing oversized and offset from mpv on X11/XWayland under fractional or mixed-monitor display scaling.
<details>
<summary>Internal changes</summary>
### Internal
- Subtitle text is now decoded from ASS exactly once at ingest, so the renderer, timing tracker, and tokenizer all share one decoded value instead of each re-deriving it.
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
</details>
## v0.19.2 (2026-08-04)
### Changed
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
### Fixed
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
<details>
<summary>Internal changes</summary>
### Internal
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
</details>
## v0.19.1 (2026-08-01)
### Added
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
### Fixed
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
## v0.19.0 (2026-07-29)
### Added
Symlink
+1
View File
@@ -0,0 +1 @@
AGENTS.md
+14 -1
View File
@@ -86,6 +86,10 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
<td><b>Jellyfin</b></td>
<td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
</tr>
<tr>
<td><b>Anime Browser</b></td>
<td>Search anime sources you supply as <a href="https://github.com/aniyomiorg/aniyomi">Aniyomi</a> extension APKs and play an episode in mpv with the overlay attached (<code>subminer anime</code>); SubMiner ships no repositories and bundles no sources</td>
</tr>
<tr>
<td><b>Jimaku</b></td>
<td>Search and download Japanese subtitles</td>
@@ -257,14 +261,17 @@ Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more
SubMiner builds on the work of these open-source projects:
| Project | Role |
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps |
| [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
| [Aniyomi](https://github.com/aniyomiorg/aniyomi) | Anime extension API and data model the anime browser targets |
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server) | Runs Aniyomi extension APKs off Android; the bridge the anime browser drives |
| [Mangatan](https://github.com/1Selxo/Mangatan) | Reference client for the bridge protocol the anime browser speaks |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary |
@@ -272,3 +279,9 @@ SubMiner builds on the work of these open-source projects:
## License
[GNU General Public License v3.0](LICENSE)
The anime browser drives [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server),
downloaded from its upstream releases at runtime rather than
bundled or redistributed here; its bundles carry their own dependencies, including a JRE and
GPL-3.0 NewPipe Extractor. SubMiner includes none of them and talks to the bridge
over its own HTTP protocol. It ships no extensions or repositories by default.
+9 -11
View File
@@ -26,7 +26,7 @@
"eslint": "^10.8.0",
"prettier": "^3.8.1",
"typescript": "^5.9.3",
"undici": "7.28.0",
"undici": "7.29.0",
},
},
},
@@ -36,16 +36,18 @@
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.5",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
"js-yaml": "4.3.1",
"lodash": "4.18.0",
"minimatch": "10.2.5",
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7",
"undici": "7.29.0",
},
"packages": {
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
@@ -266,7 +268,7 @@
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
@@ -404,7 +406,7 @@
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
@@ -496,7 +498,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
@@ -714,7 +716,7 @@
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
@@ -754,8 +756,6 @@
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
"@discordjs/rest/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
@@ -808,8 +808,6 @@
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: anilist
- Resolving "season N" against AniList no longer lands one season short for franchises whose broadcast seasons are listed as several entries. AniList records the back half of a split cour ("… Season 2 Part 2", "… Cour 2", "第2クール") as its own sequel, and the resolver counted each as a season — so Mushoku Tensei season 3 resolved to the season 2 entry and Re:ZERO season 4 to season 3, which then drove the character dictionary and AniList progress updates to the wrong series. A sequel carrying a part or cour marker that matches the season it continues is now followed without advancing the season count, in both the relation walk and the air-order fallback. Titles are compared ignoring punctuation, and the marker is looked for in every title and synonym rather than just the display title. A wrong match cached before this fix is remembered in `character-dictionaries/anilist-resolution-cache.json` and needs removing (or overriding from the character dictionary picker) for the affected series.
- A special or OVA sitting in a franchise's sequel chain is walked through without counting as a season. Dr. STONE links STONE WARS to New World through a one-episode special, which made New World resolve as season 4 and every later season shift with it.
- An AniList rate limit or network failure while walking sequel relations is reported so the caller can retry, instead of falling through to the air-date fallback. That fallback is for a franchise with missing relation edges; running it after a failed lookup turned a transient 429 into a confidently wrong season.
- The shared title parser recognizes spelled-out episode labels (`Episode 4`, `第4話`) and a season named at the end of the title (`… Season 3`, `… 3rd Season`, `… S3`), which now fill the Season field instead of being searched for as part of the series name. The TsukiHime modal gained a Season field to match Jimaku, and seasons after the first are included in its search query so a later season's releases are actually found.
+31
View File
@@ -0,0 +1,31 @@
type: added
area: anime
- Added an anime browser window that searches Aniyomi extension sources, shows cover art and episode lists, and plays an episode in mpv so the overlay and mining tools attach as usual.
- Added `subminer anime` and the `--anime` flag to open the browser, plus a "Browse Anime" tray entry.
- Anime extensions are read from `<userData>/anime-extensions`; drop Aniyomi `.apk` files there to add sources.
- Added a source settings tab so extensions that need configuration (server address, credentials, quality) can be set up from the browser; values persist per extension and source, and updated extension schemas replace stale saved field definitions without losing values. Older unscoped preferences are discarded once because their package ownership cannot be proven safely.
- Added an Extensions tab for adding repository URLs and installing, updating, or removing extensions in place; extensions that fail to load are listed with the reason. Repository requests time out instead of hanging, and APK updates are staged before replacing the installed copy.
- Browse, Extensions, and Source settings are tabs, so each one gets the full window instead of sharing it with the search results.
- Repository URLs only need to be an https URL to a `.json` index; the file name is not restricted to `index.min.json`.
- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers, with per-source progress in the status bar. Results are tagged with their source, failures do not blank the grid, and **Load more** appends later pages without duplicating streamed entries.
- The Extensions tab opens with an Installed section listing every extension on disk with Remove — including ones added by hand or whose repository has since been removed — and Update where a configured repository still carries it.
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English``en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
- HLS streams pass through a local strip proxy that removes fake image headers some hosts glue onto their video segments, so streams ffmpeg would otherwise probe as "a PNG" and abandon now play in mpv.
- The strip proxy retries a failed segment fetch once after a short pause and logs upstream error statuses; a host that errors on the very first fetches right after an episode resolves no longer kills the whole playback.
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared.
- The Linux x64 bridge bundle is verified and pinned, so the anime browser starts on Linux instead of refusing with "No pinned checksum for linux-x64-bundle.zip".
- The bridge bundle is fetched from the pinned release tag rather than whatever release is newest, so an upstream publish no longer breaks every install with a checksum mismatch.
- The Extensions tab's available list has a language chip row: pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter. Rows name the language ("Japanese" instead of `ja`) and the Available heading counts what the filter leaves.
- A stream's subtitle tracks are downloaded to a temp directory and loaded into mpv as files rather than streamed from the source URL, so they can serve as the alass reference in Subsync the way Jellyfin subtitles do. The format is detected from the file's own content, a track that fails to download falls back to its URL so the episode still plays, and the directory is removed when the next episode starts or the app exits.
- An episode launched from the browser carries its series, season and episode number through the app instead of a single joined string: stats group by series (every stream previously landed in one entry named `m3u8`), rewatching reuses the existing entry, the Jimaku and TsukiHime modals prefill Title/Season/Episode from the source's own listing, AniList updates use those fields directly, and the mpv title reads `Series S03E04 - Episode Name`.
- Opening the browser shows a tray icon on every platform and — on macOS — puts the app in the Cmd+Tab switcher and Dock, so you can switch between it and mpv; the Dock icon is released again when the window closes during playback. Launching a video starts a regular SubMiner session, and in `subminer anime` standalone mode closing the window during playback no longer quits the app and kills the stream.
- The episode list has a filter box: type an episode number (`12`), a range (`12-18`), or part of an episode name to narrow a long list, with a `6 of 25` counter while it is applied. Escape inside the box clears the filter instead of leaving the detail page.
- Episodes already watched are dimmed and marked in the episode list, with a watched count in the header. The marks come from the stats history playback already writes (an episode is marked once a session passes the completion threshold), so they match the stats window and survive the stream URL changing between playbacks. They refresh when the browser window comes back to the front, and stay empty when immersion tracking is disabled.
- Right-clicking an episode opens a menu for marking it watched or unwatched by hand, plus "Mark this and N below watched/unwatched" for the episode and every episode listed under it. Sources list newest first, so a span covers the back catalogue, which is how a series watched elsewhere gets caught up. A filter never narrows what a span covers, and the status bar reports how many episodes were touched.
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
@@ -0,0 +1,4 @@
type: fixed
area: Anki media
- Fixed sentence-audio generation timing out on slow network-mounted MKV files with many subtitle and font-attachment streams. Selected audio tracks now use bounded FFmpeg probing and a two-minute extraction budget, and missing output reports a clear FFmpeg error instead of raw `ENOENT`.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: stats
- Typeset subtitles no longer flood the stats. Karaoke openings and animated signs are authored as one subtitle event per animation frame, and immersion tracking counted every frame, which was enough to put an OP lyric at the top of "Top Repeated Words" for good. Lines are now collapsed on the way in using the same rules the subtitle sidebar already applies: matching parsed timings record exactly the cues the sidebar shows, while shifted, changing, or unparsed sources use a strict fallback where identical, contiguous, sub-0.1s lines stop counting after a few frames. Ordinary repeated dialogue and rewatches are unaffected.
- Added a cleanup for stats already affected. The Vocabulary tab has a **Duplicates** button that scans a chosen window (7 days through all time), shows the bursts it found and the word and kanji counts they added, and collapses each run to one line once confirmed. `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days <n>`. Only subtitle lines and the vocabulary counts they feed are touched; watch time and lines-seen totals are left as recorded.
+4
View File
@@ -0,0 +1,4 @@
type: docs
area: documentation
- Hid the unfinished feature demos page from the documentation sidebar while keeping its direct URL available.
+6
View File
@@ -0,0 +1,6 @@
type: added
area: stats
- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, the emptied entries disappear, and remembered title aliases keep future episodes on the merged card.
- Library: episodes can be reassigned to another library entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (e.g. an episode name parsed as the series). Manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair. Local episodes in the same directory reuse a uniquely corrected destination unless they parse to a title that already has its own library entry, while conflicting seasons or manual destinations are not forced together. Emptying an entry this way removes it and returns to the grid.
- Library: exact AniList title matches with compatible seasons fold duplicate cards automatically. Fuzzy same-AniList matches appear as dismissible "Possible duplicate" reviews instead of changing the library without confirmation; conflicting explicit seasons are left alone.
@@ -0,0 +1,4 @@
type: fixed
area: notifications
- Character dictionary progress notifications on Linux now update in place instead of flickering off and reappearing on every status change.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed the overlay never loading (stuck on the "Overlay loading" OSD spinner) when the Yomitan content-script reload raced overlay window creation at startup, most visible when playing from the anime browser on Linux/Wayland: a hidden window stops painting after that reload, so the ready-to-show signal that gates showing the overlay never fired. Content-ready now falls back to did-finish-load after a short grace period.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: macos
- `subminer anime` (and `--settings` / `--sync`) now bring their window to the front on macOS. `show()`/`focus()` only reorder windows inside the app that is already active, so the window opened behind the terminal that launched it; SubMiner now activates itself when opening one. The anime browser also restores its Dock icon before showing rather than after, because the overlay's fullscreen transform leaves the app as an accessory process that macOS refuses to bring forward at all.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Mined audio and animated AVIF clips now capture the subtitle line that was actually mined. The clip range is snapshotted once at Yomitan lookup time (and reused for both audio and image), instead of each generator reading the live mpv subtitle when it starts — which clipped whatever line was on screen after slow audio extraction finished, producing too-short or misaligned AVIF clips.
+9
View File
@@ -0,0 +1,9 @@
type: fixed
area: stats
- Stats deletes no longer freeze the stats dashboard: the delete worker module now resolves when running from source, so deletes actually run off the serving thread instead of silently falling back to it.
- Deletes now subtract their exact contribution from lifetime summaries instead of rebuilding them from retained sessions, making delete cost proportional to what is deleted and preserving lifetime totals older than the session retention window.
- If the delete worker crashes, the delete now retries on the current thread instead of failing.
- Library merges, video moves, AniList reassignments, and `subminer stats cleanup -l` also stopped rebuilding lifetime summaries from retained sessions; they now recompute from per-episode history, so those operations are faster and no longer erase lifetime totals older than the session retention window.
- Deleting content that contains very common words no longer rescans every occurrence of those words across the whole library; first/last-seen dates are refreshed with index seeks instead.
- Session deletes on large databases dropped from minutes to milliseconds: an index on the subtitle-line event reference now prevents each deleted session event from scanning the whole subtitle-line table for foreign-key enforcement.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: subsync
- Subsync no longer fails with `Protocol "file:" not supported` on a subtitle that was dropped onto mpv. mpv reports such a track as a percent-encoded `file://` URL, which subsync read as a stream and tried to fetch over HTTP; the URL is now decoded back to its path, so both the retimed target and an alass reference work. A `file://` video path is treated as local too, which restores the video reference and ffsubsync for a dropped file.
+7
View File
@@ -0,0 +1,7 @@
type: fixed
area: subsync
- Subsync now works when mpv loaded a subtitle track from a URL, which is how Aniyomi extension streams and Jellyfin add theirs. The track is downloaded to a temporary file first — reusing mpv's own request headers, so authenticated and referer-gated hosts stay reachable — instead of being rejected with "Subtitle file not found: https://…". This applies to both the sync target and the alass reference, so a Jimaku or TsukiHime download can be retimed against a stream's own subtitles. Internal tracks of a stream also pass mpv's headers through to `ffmpeg`.
- Alass now works on tracks that arrive as WebVTT, which is what Aniyomi extension streams serve. Alass picks its parser from the file extension and has no WebVTT support, so it treated a `.vtt` reference as a video file and failed with "no audio stream in file". Both the target and the reference are rewritten as SRT for alass, keeping the cue text as-is, and the originals are left untouched.
- Leaving `subsync.alass_path`, `ffsubsync_path`, or `ffmpeg_path` empty now actually auto-discovers the binary, as the config help has always claimed. Previously it fell back to a hard-coded `/usr/bin/<tool>`, which does not exist on macOS and broke subsync for every default-config install there. Discovery searches `PATH` plus the usual install prefixes (a GUI launch inherits a minimal `PATH`) and accepts `alass-cli` as well as `alass`. An explicitly configured path is still used verbatim and never silently substituted.
- Subsync failures are written to the application log. Previously the only trace was an OSD toast that vanished after a few seconds, leaving nothing to diagnose from.
+17 -2
View File
@@ -523,7 +523,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -605,9 +605,24 @@
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting.
}, // Is kiku setting.
"lapisKiku": {
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
} // Lapis kiku setting.
}, // Automatic Anki updates and media generation options.
// ==========================================
// Anime Browser
// Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
// add a repository index URL here (or drop .apk files in the extensions directory) to have any.
// Hot-reload: anime changes apply the next time the anime browser opens.
// ==========================================
"anime": {
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
"preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
// ==========================================
// Jimaku
// Jimaku API configuration and defaults.
+1 -1
View File
@@ -306,7 +306,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Usage', link: '/usage' },
{ text: 'Mining Workflow', link: '/mining-workflow' },
{ text: 'Launcher Script', link: '/launcher-script' },
{ text: 'Feature Demos', link: '/demos' },
],
},
{
@@ -327,6 +326,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Anki', link: '/anki-integration' },
{ text: 'Jellyfin', link: '/jellyfin-integration' },
{ text: 'YouTube', link: '/youtube-integration' },
{ text: 'Anime Browser', link: '/anime-browser' },
{ text: 'Jimaku', link: '/jimaku-integration' },
{ text: 'TsukiHime', link: '/tsukihime-integration' },
{ text: 'AniList', link: '/anilist-integration' },
+317
View File
@@ -0,0 +1,317 @@
# Anime Browser
Search anime sources, pick an episode, and play it in mpv with SubMiner's overlay
and mining tools attached — the same way a local file or a Jellyfin stream works.
Open it with `subminer anime`, with `SubMiner.AppImage --anime`, or from
**Browse Anime** in the tray menu. The window stays open while you watch, so you
can queue the next episode without reopening it.
While the window is open, SubMiner shows a tray icon and — on macOS — appears
in the Cmd+Tab switcher and the Dock (macOS ties the two together), so you can
flip between the browser and mpv. SubMiner normally hides itself from the Dock
because the subtitle overlay needs that to float above fullscreen video; it
hides again when the window closes during playback.
Launching an episode starts a full SubMiner playback session, the same as
playing a local file: the overlay and mining tools attach, and the tray icon
stays available. In standalone `subminer anime` mode, closing the window while
a video is playing leaves playback running — reopen the browser from the tray
(**Browse Anime**). The app only exits with the window when nothing is playing.
## How it works
SubMiner does not implement any anime source itself. It runs **Aniyomi extension
APKs** through a bundled JVM sidecar ([M-Extension-Server][mes]), asks the
selected extension to resolve an episode, and hands the resulting URL to mpv.
```
extension APK → bridge (JVM) → { url, headers } → mpv → SubMiner overlay
```
Because the extension resolves the stream, whichever sources you install decide
what is available. SubMiner only hosts them.
## Installing extensions
**SubMiner ships no extension repositories and bundles no sources.** There is no
default repository, no suggested list, and no discovery. Until you add one, the
browser has nothing to search — that is deliberate, and it is what keeps SubMiner
a neutral host rather than a distributor.
There are two ways to add extensions.
### From a repository
The window has three tabs — **Browse**, **Extensions**, and **Source settings**
and each one fills the window, so a long extension list is not squeezed in above
the search results.
Open the **Extensions** tab, paste a repository index URL, and choose
**Add repository**. The URL must be `https` and point at a `.json` index file —
`index.min.json` is the common Aniyomi name, but repositories are free to publish
under another one (for example `video.min.json`). Anything else is rejected
immediately rather than failing later. Everything before the file name is treated
as the repository root, so `.apk` and icon URLs are resolved relative to it.
Extensions your repositories offer but you do not have appear under
**Available**, each with **Install**. Repositories are stored in config under
`anime.repos`, so you can also manage them there and keep them in a dotfile.
Every row carries the extension's icon, as the repository publishes it, so a
site is recognisable before you read the name. A repository row shows its host's
favicon instead. Icons are the only part of a row that is fetched from the
network, and a row whose icon is missing falls back to the first letter of its
name rather than an empty box.
A repository index lists every language it knows about, which is far more than
any one person reads, so the **Available** list has a language chip row above
it. Pick one or more languages to narrow it, or **All** to clear the filter;
picking a language replaces **All** rather than sitting beside it. Rows name the
language in full ("Japanese" rather than `ja`), extensions whose sources span
languages are grouped under **Multi-language**, and the Available heading counts
how many of the offered extensions the filter leaves.
### Managing what is installed
The Extensions tab opens with an **Installed** section listing everything in the
extensions directory, with the sources each one provides and a **Remove**
button. It is built from the directory rather than from a repository, so an
extension you dropped in by hand — or one whose repository you have since
removed — is still listed and still removable. An installed extension borrows
its icon from the catalogue, so one no repository carries shows its monogram.
**Update** appears next to an extension a configured repository still carries;
it downloads the current version over the existing APK.
### From a file
Drop Aniyomi `.apk` files into the extensions directory, shown at the top of the
Extensions tab. It defaults to `<userData>/anime-extensions` — on macOS,
`~/Library/Application Support/SubMiner/anime-extensions` — and can be moved with
`anime.extensionsDir`.
A single APK may provide several sources; each appears separately in the
**Source** picker. Extensions that fail to load are listed in the Installed
section with the reason, so a broken APK is visible rather than silently
missing.
An extension that fails to load is skipped rather than blocking the others, so
one bad APK will not hide the rest.
## Searching every source at once
With more than one source installed, the **Source** picker gains an
**All sources** entry. Searching with it selected runs the query against every
installed source at once, and each source's results appear the moment that
source answers — a fast source is on screen while a slow one is still
resolving. The status bar counts sources as they finish
(`Searching… 3/5 sources · 42 results`).
Each cover is labelled with the source it came from, and opening one always
queries that source, whatever the picker says afterwards.
A source that errors is named in the status bar and the rest still show their
results; one extension that needs a login cannot blank the grid. If every
source fails, the first error is shown in full.
Typing a new search while one is still running simply starts over: results
from the superseded search are discarded, even if its sources answer late.
When a source reports another page, **Load more** appears below the covers.
It appends the next page without duplicating entries that already arrived in
the live result stream. A failed next-page request remains available to retry.
Source settings belong to a single extension, so the **Source settings** tab
asks you to pick one while **All sources** is selected.
## Finding an episode, and what you have watched
An episode list can run to hundreds of entries, so the episode header carries a
filter box:
- A number, `12`, keeps that episode. Sources that report no numbers at all
are still searched by name, so `12` also matches `Episode 12` in a title.
- A range, `12-18`, keeps the episodes between the two, in either order
(`18-12` reads the same). Episodes the source gave no number are left out of
a range.
- Anything else is a case-insensitive substring of the episode name, so `beach`
finds `OVA: Beach Special`.
The counter next to **Episodes** reads `6 of 25` while a filter is applied.
Pressing Escape in the filter box clears it; pressing it anywhere else goes back
to the results grid.
Episodes you have already watched are dimmed and marked `✓ watched`, with a
count in the header. This is not a separate list the browser keeps: it reads the
same stats history the rest of SubMiner writes to, where an episode is marked
watched once a session runs past the completion threshold. Streams are recorded
under a stable per-episode identity, so the mark survives the stream URL
changing between playbacks, and it is the same mark the stats window and
`--mark-watched` use.
Because playback marks an episode partway through the session, the marks
refresh when the browser window comes back to the front: finish an episode in
mpv, switch back, and it is marked. With
[immersion tracking](configuration.md) disabled there is no history to read, so
no episode is marked.
### Marking by hand, and catching up
Right-click an episode for:
- **Mark watched** / **Mark unwatched**: the single episode, whichever way it
is not already.
- **Mark this and N below watched** / **... unwatched**: that episode and every
episode listed below it. Sources list newest first, so "below" is the back
catalogue: right-click the last episode you saw and mark everything down to
the start, which is how you catch up a series you watched somewhere else.
A filter narrows what you are looking at, not what you mark: a span always
covers the full episode list, and the status bar says how many episodes it
touched. The oldest episode has nothing below it, so it only offers the single
entry. Escape closes the menu.
Marking an episode you have never played creates its stats row so the mark has
somewhere to live. That row carries the same series, season and episode fields
playback would have recorded, and both stats library views join the lifetime
tables, so a manually marked episode does not appear there as watch time you
never spent. Clearing a mark never creates anything.
Marks are written to the stats history, so with immersion tracking disabled
there is nowhere to write them and the status bar says so.
## Settings
| Key | Purpose |
| ------------------------ | -------------------------------------------------------------------- |
| `anime.repos` | Repository index URLs. Empty by default. |
| `anime.extensionsDir` | Where APKs are read from. Empty uses `<userData>/anime-extensions`. |
| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
## Source settings
Most extensions need configuration before they return anything — a server
address and credentials, a preferred quality, a language filter. Open the
**Source settings** tab to edit them. Changes save as you make them and
persist across restarts in `<userData>/anime-source-preferences.json`.
Each save is handed back to the extension, so it can react: the Jellyfin source
logs in when the address and password land, then fills in its media-library
picker. Password-like fields are masked. Because that file can hold
credentials, it is written with owner-only permissions. Values are scoped to
the exact extension package and source, so two extensions that reuse the same
internal source ID cannot read each other's settings. Preferences saved by an
older build without package ownership are discarded; re-enter those source
settings once after upgrading.
## The bridge
The first launch downloads a platform bundle (~130 MB) containing the server and
a matching Java runtime, so no system JDK is required. SubMiner pins one
upstream release tag and the SHA-256 of each asset it has verified; the download
is checked against that hash before anything runs, then unpacked into
`<userData>/anime-bridge` and reused after that. Progress appears in the banner
at the top of the window.
The bridge stays running while the window is open. Resolved video URLs point at
its own loopback proxy so the extension's cookies and headers apply, which means
those URLs stop working once it exits — the window keeps it alive for the whole
session.
If the bridge dies anyway (killed by hand, crashed, or stopped mid-operation),
the exit is detected and named in the status bar, and the next request starts a
new one. Playback already in flight still ends when its stream URL dies, but the
browser recovers without an app restart.
Two known limits:
- There is no Android WebView, so extensions that need one (typically for
Cloudflare challenges) will fail with an error from the source.
- Bundles are published for macOS (arm64, x64), Linux (x64), and Windows (x64),
but only the ones we have hashed ourselves will run: currently macOS arm64 and
Linux x64. The rest stop with "No pinned checksum for …" until a maintainer
verifies them. Other platforms are unsupported outright.
## Playback
Selecting an episode resolves the best available stream, applies the source's
required headers as mpv `http-header-fields`, and loads it. The headers are
readable back off mpv, so Anki card audio and screenshots fetch correctly too.
HLS streams are routed through a small local proxy before mpv sees them. Some
hosts disguise their video segments by prepending a fake image header (a real
1x1 PNG) so scrapers back off; Aniyomi's own player strips this, but ffmpeg
probes the segment as a picture and playback dies with "no audio or video data
played". The proxy scans each segment for the first genuine MPEG-TS packet run
and drops whatever junk sits in front of it. Segments that are not TS (fMP4,
subtitles, encryption keys) pass through untouched, and direct-file streams
skip the proxy entirely.
"Playing" in the status bar means playing: after handing mpv the stream,
SubMiner waits until mpv actually configures a video output before reporting
success. If mpv gives up instead — a dead host, an undecodable stream — the
browser shows mpv's error rather than pretending playback started (a failed
load leaves no mpv window, because the player idles windowless).
Choosing **Queue** resolves the episode and appends the playable stream to mpv's
own playlist immediately, while its subtitle tracks cache in the background.
That makes mpv's next command available at once and lets the next episode begin
automatically when the current one ends, without waiting for another source
request. Resolved stream URLs can be short-lived, so a very long queue can still
outlive what its source issued; dequeue and queue that episode again to refresh
it.
### Japanese audio, and switching tracks
Sources often return a dub and the original audio as two separate entries — or
as two audio tracks of one stream — and the dub is frequently listed first.
SubMiner always aims at the Japanese audio:
- Entries labelled as a dub are skipped as long as another entry exists. This
outranks `anime.preferredQuality`: a 1080p dub is the wrong file, not a better
one. If every entry is a dub, it still plays.
- mpv's `alang` is set to `ja,jpn,jp,japanese` before the file loads, so a
stream carrying several audio tracks starts on the Japanese one. With no
Japanese track, mpv falls back to the first one as usual.
- Any audio or subtitle tracks the extension supplies separately are added to
mpv with `audio-add` / `sub-add`, tagged with their language, and the
Japanese one is selected.
The primary subtitle slot is reserved for Japanese — it is what the overlay
mines. A source that only carries, say, English subtitles does not get them
promoted to primary; instead the track is added with a normalized language tag
(`English``en`), and the regular [dual-subtitle settings](configuration.md)
apply: with `secondarySub.autoLoadSecondarySub` enabled and the language listed
in `secondarySub.secondarySubLanguages`, it is picked up as the secondary
subtitle, exactly as it would be for a local file.
Every track is added, including the ones that are not selected, so all of them
appear in mpv's track menu and can be switched by hand while watching
(`#` cycles audio, `j` cycles subtitles by default).
The extension hands over subtitle tracks as URLs, but SubMiner downloads each
one to a temporary directory and gives mpv the local file. mpv is happy either
way; [Subsync](/troubleshooting#subtitle-sync-subsync) is not, because alass
needs a file on disk to use as the timing reference. A track that fails to
download falls back to its URL so the episode still plays, the format is
detected from the file's own content rather than its URL, and the directory is
removed when the next episode starts or the app exits.
### Series, season, and episode
The episode's identity travels with it instead of being guessed back out of the
stream URL, which carries nothing but a proxy path and a file extension. The
title and episode label from the source's own listing are split into series,
season, and episode number once, at launch, and everything downstream reads
those fields:
- mpv's title reads `Series S03E04 - Episode Name`.
- Stats group by series, and rewatching an episode reuses its entry instead of
creating a new one.
- The [Jimaku](/jimaku-integration) and [TsukiHime](/tsukihime-integration)
modals open with Title, Season, and Episode already filled in, so a subtitle
search is one keypress rather than a retype.
- [AniList](/anilist-integration) progress updates use those fields directly.
[mes]: https://github.com/1Selxo/M-Extension-Server
+16 -1
View File
@@ -289,6 +289,21 @@ Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is c
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (19) to select how many recent lines to combine.
## Word Card Type (Kiku/Lapis)
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
```jsonc
"ankiConnect": {
"isKiku": { "enabled": true },
"lapisKiku": {
"wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
}
}
```
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
## Field Grouping (Kiku)
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
@@ -314,7 +329,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
### What Gets Merged
| Field | Merge behavior |
| -------- | ---------------------------------------- |
| -------- | --------------------------------------------- |
| Sentence | Both cards' sentences kept as grouped entries |
| Audio | Both cards' `[sound:...]` entries kept |
| Image | Both cards' images kept |
+2 -2
View File
@@ -75,8 +75,8 @@ src/
renderer/ # Overlay renderer (modularized UI/runtime)
handlers/ # Keyboard/mouse/gamepad interaction modules
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
# character dictionary, playlist browser, subtitle sidebar,
# YouTube track picker, controller config/debug/select)
# changelog, character dictionary, playlist browser, subtitle
# sidebar, YouTube track picker, controller config/debug/select)
positioning/ # Subtitle position controller (drag-to-reposition)
settings/ # Settings window UI (model, controls, markup)
types/ # Domain type modules (anki, config, integrations, ...)
+51
View File
@@ -1,5 +1,56 @@
# Changelog
## v0.19.3 (2026-08-13)
**Added**
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
**Changed**
- Subtitle Tokenization Performance: Reworks subtitle dictionary lookups to cut per-line work roughly in half, cache repeated lookups across lines, and stop tokenization from competing with on-screen subtitle prefetching. Also fixes several accuracy issues along the way: dropped readings on trailing kana, character names being skipped after a dictionary sync, annotations not refreshing after mining a card, and halfwidth katakana character names losing their reading or being swallowed by other words.
**Fixed**
- Character Dictionary Large Imports: Large character dictionaries (e.g. One Piece) no longer fail to install from a fixed timeout budget; the import now scales its time budget to dictionary size and reports detailed progress (page/character counts, image download progress, elapsed time) instead of one static message.
- Stats Delete Responsiveness: Deleting sessions, episodes, or library entries no longer freezes the stats page or an active video player; deletes are now batched into a single transaction.
- Styled Subtitle Cue Parsing: Heavily typeset subtitles (karaoke, signs) no longer flood the subtitle sidebar with garbage; vector drawing commands are no longer shown as text, and duplicate/animation-burst cues now collapse into one.
- X11 mpv Renderer: Fixes an mpv crash on the first fullscreen toggle for X11/XWayland users with `gpu-next` shaders (e.g. ArtCNN), which was caused by X11 mode forcing the legacy OpenGL renderer.
- X11 Overlay Display Scaling: Fixes the overlay appearing oversized and offset from mpv on X11/XWayland under fractional or mixed-monitor display scaling.
<details>
<summary>Internal changes</summary>
**Internal**
- Subtitle text is now decoded from ASS exactly once at ingest, so the renderer, timing tracker, and tokenizer all share one decoded value instead of each re-deriving it.
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
</details>
## v0.19.2 (2026-08-04)
**Changed**
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
**Fixed**
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
<details>
<summary>Internal changes</summary>
**Internal**
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
</details>
## v0.19.1 (2026-08-01)
**Added**
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
**Fixed**
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
## v0.19.0 (2026-07-29)
**Added**
+57 -10
View File
@@ -154,6 +154,7 @@ The configuration file includes several main sections:
**External Integrations**
- [**Anime Browser**](#anime-browser) - Extension repositories and stream preferences for the anime browser
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
@@ -399,7 +400,7 @@ See `config.example.jsonc` for detailed configuration options.
```
| Option | Values | Description |
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
@@ -556,7 +557,7 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
```
| Option | Values | Description |
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
@@ -1069,6 +1070,9 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
"enabled": true,
"fieldGrouping": "manual",
"deleteDuplicateInAuto": true
},
"lapisKiku": {
"wordCardKind": "word-and-sentence"
}
}
```
@@ -1077,6 +1081,21 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
### Word Card Type
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
| Value | Flag set |
| ----------------------------- | ----------------------- |
| `word-and-sentence` (default) | `IsWordAndSentenceCard` |
| `click` | `IsClickCard` |
| `sentence` | `IsSentenceCard` |
| `audio` | `IsAudioCard` |
| `none` | none; flags left as-is |
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
### N+1 Word Highlighting
@@ -1136,6 +1155,30 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
## External Integrations
### Anime Browser
Sources for the [anime browser](/anime-browser). SubMiner ships no extension repositories and bundles no sources, so these are empty until you add one:
```json
{
"anime": {
"extensionsDir": "",
"repos": [],
"preferredQuality": ""
}
}
```
| Option | Type | Default | Description |
| ------------------------ | ---------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. |
| `anime.repos` | `string[]` | `[]` | Extension repository index URLs. Any `https` URL ending in `.json` works; `index.min.json` is only the common name. |
| `anime.preferredQuality` | `string` | `""` | Preferred stream quality label, matched as a substring (for example `1080`). Empty keeps the source's own order. A Japanese-audio entry always outranks a higher-quality dub. |
Repositories added from the browser's Extensions tab are written back to `anime.repos`, so the list can also be kept in a dotfile. Changes apply the next time the anime browser opens.
Per-source settings (server addresses, credentials, per-extension quality or language options) are not part of `config.jsonc`. They belong to the extension, are edited in the browser's **Source settings** tab, and persist in `<userData>/anime-source-preferences.json` with owner-only permissions.
### Jimaku
Configure Jimaku API access and defaults:
@@ -1168,7 +1211,7 @@ TsukiHime subtitle search works out of the box and needs no account or API key.
```
| Option | Values | Description |
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
| ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
@@ -1178,9 +1221,9 @@ See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, lang
### Subtitle Sync
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using a secondary subtitle as reference
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using another subtitle as reference; it can also take the local video file as reference (alass extracts the audio itself)
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
```json
@@ -1196,11 +1239,15 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
| Option | Values | Description |
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `alass_path` | string path | Path to `alass` executable. Empty falls back to `/usr/bin/alass`. `alass` must be installed separately. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty falls back to `/usr/bin/ffsubsync`. `ffsubsync` must be installed separately. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
| `alass_path` | string path | Path to `alass` executable. Empty auto-discovers `alass` or `alass-cli`. `alass` must be installed separately. |
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty auto-discovers `ffsubsync`. `ffsubsync` must be installed separately. |
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` auto-discovers `ffmpeg`. |
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
Auto-discovery searches `PATH`, then the usual install prefixes (`/opt/homebrew/bin`, `/usr/local/bin`, `/opt/local/bin`, `/usr/bin`, `/bin`) — a GUI launch inherits a minimal `PATH` that often omits the first two. Set the option explicitly if your binary lives elsewhere.
Subtitle tracks that mpv loaded from a URL (Aniyomi extension streams, Jellyfin) are downloaded to a temporary file first, reusing mpv's own request headers, so they can be used as either the sync target or the alass reference.
Stats dashboard sentence mining also uses `alass_path` when available to align a local English sidecar against the local Japanese sidecar before filling the card translation field. This stats-only retime writes a temporary cached copy and never edits the original subtitle files.
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
@@ -1229,7 +1276,7 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
```
| Option | Values | Description |
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
@@ -1540,7 +1587,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
```
| Option | Values | Description |
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
+36 -1
View File
@@ -34,7 +34,7 @@ The same immersion data powers the stats dashboard.
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
- Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`).
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables. `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
### Dashboard Tabs
@@ -57,6 +57,13 @@ Jellyfin stream URLs are normalized to stable item links before stats titles are
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
Open a title and use **Delete Entry** in its header to remove a mistakenly tracked show outright. This deletes every episode of that title along with their sessions, subtitle lines, rollups and cover art, drops the words and kanji that were only seen there, and removes the card from the Library grid. Individual episodes and sessions can still be deleted on their own from the episode list and session rows. Entry deletion is refused while that title is the one currently playing.
![Stats Library](/screenshots/stats-library.png)
@@ -125,6 +132,34 @@ Secondary subtitle text (typically English translations) is stored alongside pri
The Vocabulary tab toolbar includes an **Exclusions** button for hiding words from all vocabulary views. Excluded words are stored in the immersion database, with older browser localStorage exclusions imported on first load after upgrade. They can be managed (restored or cleared) from the exclusion modal. Exclusions affect stat cards, charts, the frequency rank table, and the word list.
### Repeated Line Cleanup
Karaoke openings and animated signs are authored as one subtitle event per animation frame, all carrying the same text. Playback reports every one of those frames, so a single OP lyric could be recorded hundreds of times and dominate "Top Repeated Words".
Recording now collapses those runs as they happen, matching what the subtitle sidebar shows:
- When the active subtitle source has been parsed, its cue list has already had duplicate events and animation bursts merged. A line landing inside a surviving cue but after that cue's start is a frame the sidebar merged away, and is not recorded.
- When no parsed cue covers the live timing, including while a subtitle source is changing or shifted, the strict metadata-free rule applies: a run of identical, contiguous lines each shorter than 0.1s stops being recorded after a few frames. Runs are tracked per line of text, so dual-line karaoke (a kanji and a romaji line frame-flipped together) collapses both lines. Ordinary repeated dialogue, and lines held for a normal beat, always record.
For stats recorded before this, the Vocabulary tab toolbar has a **Duplicates** button:
- Pick how far back to look (7 days, 30 days, 90 days, 1 year, or all time). A narrower window does less work and keeps older history untouched.
- **Scan** reports the bursts found, the lines they added, and the word and kanji counts they inflated, without writing anything.
- **Clean Up** applies exactly what the scan reported: each run collapses to its first line (extended to cover the run), and the removed lines' word and kanji occurrences are subtracted from the vocabulary aggregates.
The same thing runs from the terminal:
```bash
subminer stats cleanup --duplicate-lines --dry-run --lookback-days 30
subminer stats cleanup --duplicate-lines --lookback-days 30
```
`--duplicate-lines` (short: `-d`) picks the cleanup mode, so it cannot be combined with `--vocab` or `--lifetime`, and `--dry-run` and `--lookback-days <days>` only apply to it. Omitting `--lookback-days` scans all history; the value must be at least one day.
The cleanup chains runs per line of text, so interleaved dual-line karaoke collapses each of its lines. It also removes the short residue the live rule stores before a run is long enough to recognize: a run one frame short of the usual minimum qualifies when every event is under the strict 0.1s bound.
Runs never cross a session boundary, so rewatching an episode keeps both watches. Session telemetry (watch time, lines seen, tokens seen) and the rollups derived from it are left as recorded: they are cumulative samples taken during playback, and cannot be recomputed for sessions whose raw rows have since been pruned.
## Retention Defaults
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
+10 -6
View File
@@ -24,7 +24,7 @@ If no files match the current episode filter, a "Show all files" button lets you
### Modal Keyboard Shortcuts
| Key | Action |
| --- | --- |
| ------------------------- | ---------------------------- |
| `Enter` (in text field) | Search |
| `Enter` (in list) | Select entry / download file |
| `Arrow Up` / `Arrow Down` | Navigate entries or files |
@@ -41,13 +41,13 @@ Add a `jimaku` section to your `config.jsonc`:
"apiKeyCommand": "cat ~/.jimaku_key",
"apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja",
"maxEntryResults": 10
}
"maxEntryResults": 10,
},
}
```
| Option | Type | Default | Description |
| --- | --- | --- | --- |
| --------------------------- | ---------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. |
| `jimaku.apiKeyCommand` | `string` | - | Shell command that prints the API key to stdout. Useful for secret managers (e.g., `pass jimaku/api-key`). |
| `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
@@ -59,8 +59,8 @@ The keyboard shortcut is configured separately under `shortcuts`:
```jsonc
{
"shortcuts": {
"openJimaku": "Ctrl+Shift+J"
}
"openJimaku": "Ctrl+Shift+J",
},
}
```
@@ -79,6 +79,8 @@ SubMiner extracts media info from the current video path to pre-fill the search
- **Season + episode patterns:** `S01E03`, `1x03`
- **Episode-only patterns:** `E03`, `EP03`, or dash-separated numbers like `Title - 03 -`
- **Spelled-out episode labels:** `Episode 4`, `第4話`
- **Season named at the end of the title:** `… Season 3`, `… 3rd Season`, `… S3`, `… 第3期` - the season goes in the Season field instead of being searched for as part of the series name
- **Season folders:** a parent directory named `Season 2` or `S2` fills in the season when the filename lacks one
- **Bracket tags:** `[SubGroup]`, `[1080p]`, `[HEVC]` - stripped before title extraction
- **Year tags:** `(2024)` - stripped
@@ -87,6 +89,8 @@ SubMiner extracts media info from the current video path to pre-fill the search
If the parser produces a high-confidence result (title + episode both detected), the search runs automatically when the modal opens. Otherwise, you can adjust the fields manually before searching.
Episodes launched from the [anime browser](/anime-browser) skip parsing entirely: Title, Season, and Episode come from the source's own listing, which is why the search runs immediately even though the stream URL says nothing about the episode.
## Troubleshooting
**"Jimaku API key not set"**
+1
View File
@@ -151,6 +151,7 @@ subminer stats -b # start background stats daemon
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) |
| `subminer stats -b` / `-s` | Start/reuse or stop the background stats daemon |
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows (`-v` vocab, `-l` lifetime summaries) |
| `subminer stats cleanup -d` | Collapse repeated lines from typeset subs (`--dry-run`, `--lookback-days <n>`) |
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
| `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) |
| `subminer settings` | Open the SubMiner settings window |
+5 -2
View File
@@ -21,6 +21,7 @@ function slugify(heading: string): string {
}
const EXCLUDED_PAGES = new Set(['README.md']);
const UNLISTED_ROUTES = new Set(['/demos']);
const PUBLIC_PREFIXES = ['/assets/', '/screenshots/', '/config.example.jsonc', '/favicon'];
function loadPages(): Map<string, string> {
@@ -114,7 +115,7 @@ test('slugify matches the VitePress cases these docs actually rely on', () => {
expect(slugify('2. Install SubMiner')).toBe('_2-install-subminer');
});
test('every docs page is reachable from the sidebar', async () => {
test('every docs page is reachable from the sidebar unless explicitly unlisted', async () => {
const { default: config } = await import('./.vitepress/config');
const sidebar = config.themeConfig?.sidebar as Array<{
items?: Array<{ text: string; link?: string }>;
@@ -127,6 +128,8 @@ test('every docs page is reachable from the sidebar', async () => {
}
}
const orphans = [...pages.keys()].filter((route) => !linked.has(route));
const orphans = [...pages.keys()].filter(
(route) => !linked.has(route) && !UNLISTED_ROUTES.has(route),
);
expect(orphans).toEqual([]);
});
+6 -3
View File
@@ -161,10 +161,13 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
1. Open the subsync modal from the overlay.
2. Select the sync engine (alass or ffsubsync).
3. For alass, select a reference subtitle track from the video.
4. SubMiner runs the sync and reloads the corrected subtitle.
3. For alass, pick the **reference** - the subtitle with correct timing. This defaults to the secondary subtitle track. The loaded video file can also be used as the reference (alass extracts the audio itself), but it is never the default.
4. Pick the **out-of-sync subtitle** - the track that gets retimed. This defaults to the active primary subtitle track and applies to both engines.
5. SubMiner runs the sync and reloads the corrected subtitle into the slot the out-of-sync track came from: retiming the secondary track keeps it secondary and leaves the primary track selected.
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
The reference and the out-of-sync subtitle must be different tracks; the reference list hides whichever track is selected as the target.
For remote streams, including Jellyfin playback, the modal only offers alass with a subtitle reference. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync and the video-file reference need direct access to the local media file and are unavailable for stream URLs.
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
+17 -2
View File
@@ -523,7 +523,7 @@
// ==========================================
// AnkiConnect Integration
// Automatic Anki updates and media generation options.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
// Shared AI provider transport settings are read from top-level ai and typically require restart.
// Most other AnkiConnect settings still require restart.
// ==========================================
@@ -605,9 +605,24 @@
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
} // Is kiku setting.
}, // Is kiku setting.
"lapisKiku": {
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
} // Lapis kiku setting.
}, // Automatic Anki updates and media generation options.
// ==========================================
// Anime Browser
// Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
// add a repository index URL here (or drop .apk files in the extensions directory) to have any.
// Hot-reload: anime changes apply the next time the anime browser opens.
// ==========================================
"anime": {
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
"preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
// ==========================================
// Jimaku
// Jimaku API configuration and defaults.
+48 -8
View File
@@ -171,7 +171,9 @@ Without FFmpeg, card creation still works but audio and image fields will be emp
**Audio or screenshot generation hangs**
Media generation has a 30-second timeout (60 seconds for animated AVIF). If your video file is on a slow network mount or the codec requires software decoding, generation may time out. Try:
Audio extraction has a 2-minute timeout. SubMiner also limits FFmpeg probing when mpv provides the selected audio stream, which avoids scanning unrelated subtitle and font-attachment streams in large MKV files. Screenshots retain a 30-second timeout, and animated AVIF uses 60 seconds.
If your video file is on a slow or unresponsive network mount, generation may still time out. Try:
- Using a local copy of the video file.
- Reducing `ankiConnect.media.imageQuality` or switching from `avif` to `static` image type.
@@ -207,31 +209,68 @@ Resume playback and wait for the next subtitle to appear, then try mining again.
Both **alass** and **ffsubsync** are optional external dependencies. Subtitle syncing requires at least one of them to be installed.
**"Configured alass executable not found"**
Subsync writes to the application log under the `subsync` scope, so the full command failure — exit code, stderr, resolved file paths — is recorded there as well as on the OSD.
**"Could not find alass" / "Configured alass executable not found"**
Install alass or configure the path:
- **Homebrew**: `brew install alass`
- **Arch Linux (AUR)**: `paru -S alass`
- **Cargo**: `cargo install alass-cli`
- Set the path: `subsync.alass_path` in your config.
**"Configured ffsubsync executable not found"**
Leaving the option empty searches `PATH` plus the usual install prefixes, and accepts either `alass` or `alass-cli`. Set the option explicitly when the binary lives somewhere else. The second message means the configured path itself does not exist — SubMiner never silently substitutes a different binary for one you named.
**"Could not find ffsubsync" / "Configured ffsubsync executable not found"**
Install ffsubsync or configure the path:
- **Arch Linux (AUR)**: `paru -S python-ffsubsync`
- **pip**: `pip install ffsubsync`
- Must be on `PATH` or configured via `subsync.ffsubsync_path` in your config.
- Must be discoverable, or configured via `subsync.ffsubsync_path` in your config.
**"alass synchronization failed" / "ffsubsync synchronization failed"**
If subtitle sync fails (the error message is prefixed with the engine name):
- Ensure the reference subtitle track exists in the video (alass requires a source track).
- Ensure a reference is selected (alass needs either a second subtitle track or the local video file, and it cannot be the same track that is being retimed).
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
- Try running the sync tool manually to see detailed error output.
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
**Syncing subtitles on a stream (Aniyomi extensions, Jellyfin)**
Subtitle tracks mpv loaded from a URL are downloaded to a temporary file first, reusing mpv's own request headers, so they work as either the sync target or the alass reference. Downloading a Japanese track from Jimaku or TsukiHime while a stream is playing also works — it becomes the primary track and therefore the sync target.
Internal subtitle tracks of a stream still go through `ffmpeg`, which has to reach the origin itself. If that fails, prefer an external track or a Jimaku download as the reference.
Streams usually serve WebVTT, which alass cannot parse — it picks its parser from the file extension and treats a `.vtt` file as a video, failing with "no audio stream in file". SubMiner rewrites both the target and the reference as SRT for alass, so this is handled automatically; the retimed track mpv loads is that SRT.
## Anime Browser
See the [anime browser guide](/anime-browser) for how sources and the bridge work.
**Nothing to search**
SubMiner ships no extension repositories and bundles no sources. Until you add a repository index URL in the **Extensions** tab (or drop Aniyomi `.apk` files into the extensions directory) the browser has nothing to query.
**"No pinned checksum for …"**
The bridge bundle is verified against a SHA-256 that a maintainer has checked by hand. Bundles exist for macOS (arm64, x64), Linux (x64), and Windows (x64), but only macOS arm64 and Linux x64 are pinned so far; the rest stop with this message rather than running an unverified download. Other platforms are unsupported outright.
**A source returns nothing, or asks for a login**
Most extensions need configuration first - a server address, credentials, a preferred quality. Open the **Source settings** tab and fill them in; each save is handed straight back to the extension. Sources that need an Android WebView (typically for Cloudflare challenges) cannot work here, because the bridge has none.
**"Playback failed" with an mpv error**
"Playing" is only reported once mpv actually configures a video output, so this is a real failure rather than a silent one: a dead host, an expired stream URL, or an undecodable stream. Resolve the episode again; if it keeps failing, try another source or quality entry.
**The browser starts failing every request**
A bridge that dies (killed, crashed, stopped mid-operation) is named in the status bar and restarted on the next request. Anything already playing ends with it, since stream URLs point at its loopback proxy.
## TsukiHime
**"xz binary not found"**
@@ -405,8 +444,9 @@ On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and othe
SubMiner handles this automatically:
- It launches its own window under XWayland (it sets `--ozone-platform-hint=x11`).
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11egl,x11`) is applied.
- It launches its own window under XWayland (it sets `--ozone-platform=x11`).
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11vk,x11egl,x11`) is applied. Only the window context is overridden; your `vo`/`gpu-api` and user shaders are left alone.
- Fractional and mixed-monitor display scaling is handled per screen when SubMiner maps XWayland mpv coordinates to the overlay.
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
@@ -420,7 +460,7 @@ Requirements: `xdotool`, `xprop`, and `xwininfo` must be installed. SubMiner use
This almost always means mpv came up as a **native Wayland** window that the XWayland overlay cannot cover. It happens when mpv is launched **manually** (your own command), because SubMiner can only force XWayland on the mpv processes it launches itself. Fix it one of these ways:
- Launch playback through SubMiner (the `subminer` launcher or the tray), which forces XWayland for you, or
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11egl …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11egl` in your `mpv.conf`.
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11vk,x11egl,x11 …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11vk` (Vulkan) / `gpu-context=x11egl` (OpenGL) in your `mpv.conf`.
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing).
+2 -2
View File
@@ -16,11 +16,11 @@ Unlike Jimaku, TsukiHime needs no account or API key. The only requirement is th
The integration runs through an in-overlay modal opened with `Ctrl+Shift+T` by default. The modal has two tabs that filter the subtitle tracks of the selected release by role: the first follows `secondarySub.secondarySubLanguages` (English when unset), and the second is always **Japanese**, the currently supported primary subtitle language. Tracks with no language tag stay visible on the secondary tab.
When you open the modal, SubMiner parses the current video filename to extract a title and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately.
When you open the modal, SubMiner parses the current video filename to extract a title, season, and episode number (same parser as Jimaku - `S01E03`, `1x03`, `E03`, and dash-separated numbers all work). If the filename yields a high-confidence match, SubMiner auto-searches immediately. Episodes launched from the [anime browser](/anime-browser) fill all three fields from the source's own listing instead of from a filename.
From there:
1. **Search** - SubMiner queries TsukiHime with `<title> <episode>`. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
1. **Search** - SubMiner queries TsukiHime with `<title> <season> <episode>`. Season 1 is left out on purpose: releases of a first season almost never carry `S01` in their name, so including it would match nothing. Later seasons are included, which is what makes their releases findable at all. Results appear as a list of releases (e.g. `[SubsPlease] ... - 28 (1080p)`), each showing size, file count, and the subtitle languages the release carries.
2. **Browse releases** - Select a release to list the text subtitle tracks extracted from its files. English tracks sort first; image-based tracks (PGS/VobSub) are filtered out.
3. **Download** - Selecting a track downloads the xz-compressed subtitle from TsukiHime's storage, decompresses it, saves it next to the video (or a temp directory for remote/streamed media), and loads it into mpv. Japanese tracks are selected as mpv's **primary** subtitle. Tracks from the configured secondary tab are assigned to mpv's **secondary** subtitle slot without replacing the primary. The filename carries the track's language - `<video basename>.en.<ext>` for English, `.ja` for Japanese, and so on - so mpv and media servers detect the language correctly.
+12 -2
View File
@@ -70,6 +70,7 @@ subminer https://youtu.be/... # Play a YouTube URL
subminer stats # Open the immersion stats dashboard
subminer doctor # Check dependencies, config, and the mpv socket
subminer settings # Open the SubMiner settings window
subminer anime # Open the anime browser window
subminer app --setup # Re-open first-run setup
subminer -u # Check for updates
```
@@ -95,6 +96,8 @@ subminer texthooker # Texthooker-only mode (-o also opens the brow
subminer stats -b # Start/reuse the background stats daemon
subminer stats -s # Stop the background stats daemon
subminer stats cleanup # Backfill vocabulary metadata, prune stale rows
subminer stats cleanup -d --dry-run # Preview cleanup of repeated typeset subtitle lines
subminer stats cleanup -d --lookback-days 30 # Clean only lines recorded in the last 30 days
subminer stats rebuild # Rebuild rollup data
subminer doctor --refresh-known-words # Refresh the known-word cache
subminer logs -e # Export a sanitized log ZIP and print its path
@@ -107,6 +110,8 @@ subminer app --stop # Stop the background app
subminer --version # Print the launcher's version
```
`stats cleanup` runs one mode per invocation: `-v`/`--vocab` (the default), `-l`/`--lifetime`, or `-d`/`--duplicate-lines`; explicitly selected modes cannot be combined. `--dry-run` and `--lookback-days <days>` apply to `--duplicate-lines` only and are rejected without it; `--lookback-days` must be at least one day, and leaving it off scans all history.
Jellyfin, cross-machine sync, and character-dictionary commands have their own sections: [Jellyfin](/jellyfin-integration), [Sync Between Machines](/launcher-script#sync-between-machines), and [Character Dictionary](/character-dictionary).
</details>
@@ -131,13 +136,14 @@ SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --yomitan # Open Yomitan settings
SubMiner.AppImage --settings # Open the SubMiner settings window
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
SubMiner.AppImage --anime # Open the anime browser window
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
SubMiner.AppImage --start --dev # Enable app/dev mode
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
SubMiner.AppImage --help # Show all options
```
The remaining flags are internal or scripting-only surfaces: the `--jellyfin-*` family (login, library listing, item playback, cast announce), `--sync-cli` (the app's headless sync entrypoint that `subminer sync` proxies to), `--dictionary-candidates` / `--dictionary-select`, and `--playback-feedback <text>`. Run `SubMiner.AppImage --help` for the complete list. The previous `--open-animetosho` flag is still accepted as a deprecated alias for `--open-tsukihime`.
The remaining flags are internal or scripting-only surfaces: the `--jellyfin-*` family (login, library listing, item playback, cast announce), `--sync-cli` (the app's headless sync entrypoint that `subminer sync` proxies to), the `--stats-cleanup-*` family that `subminer stats cleanup` forwards (`--stats-cleanup-vocab`, `--stats-cleanup-lifetime`, `--stats-cleanup-duplicate-lines`, and its `--stats-cleanup-dry-run` / `--stats-cleanup-lookback-days <days>` modifiers), `--dictionary-candidates` / `--dictionary-select`, and `--playback-feedback <text>`. Run `SubMiner.AppImage --help` for the complete list. The previous `--open-animetosho` flag is still accepted as a deprecated alias for `--open-tsukihime`.
</details>
@@ -145,11 +151,13 @@ The tray menu includes `Export Logs`, which creates the same sanitized local-dat
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
The tray menu also includes `View Changelog`, which opens the in-app changelog modal. It fetches the changelog from the newest published release, so you see release notes for versions newer than the one you run; if the download fails it falls back to the changelog bundled with your install and says so. Versions in the current `0.x` line are expanded by default and older lines are folded, matching this site's [Changelog](/changelog). A badge marks the version you have installed, and newer versions are tagged `New`. The same modal opens from the `What's New` button on the update-available overlay notification.
### Logging and App Mode
- `--log-level` controls logger verbosity.
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
- `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
@@ -368,6 +376,8 @@ Press `V` to cycle the primary SubMiner subtitle bar through hidden → visible
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
The changelog modal (tray > `View Changelog`) works the same way: it renders over mpv when a video is playing and in its own window otherwise. Use `J`/`K` or the arrow keys to move between versions, `Enter` to fold or unfold one, `R` to refetch, and `Esc` to close.
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
### Drag-and-Drop
+3 -1
View File
@@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as
- **Client auth:** none
- **Reconnects:** client-managed
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes.
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes. Annotation-only upgrades do not repeat the same line on this basic stream.
#### Message shape
@@ -96,6 +96,8 @@ Use the annotation websocket for custom clients that want the same structured to
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
On a tokenization cache miss, this stream first sends the cue as plain text with an empty `tokens` array, then sends the annotated replacement when tokenization finishes. Treat each message as the complete current state, replacing the previous payload.
#### Message shape
```json
+1 -1
View File
@@ -11,7 +11,7 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata.
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
@@ -68,14 +68,17 @@ A cue parser extracts both timing and text content from subtitle files for prefe
interface SubtitleCue {
startTime: number; // seconds
endTime: number; // seconds
text: string; // raw subtitle text
text: string; // plain text, decoded from the source format
}
```
**Supported formats:**
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing.
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, read the field order from the `Format:` row, and take everything after the Text field index as the text (Text can itself contain commas).
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
#### Prefetch Service Lifecycle
+11 -1
View File
@@ -3,7 +3,7 @@
# Domain Ownership
Status: active
Last verified: 2026-07-15
Last verified: 2026-08-02
Owner: Kyle Yasuda
Read when: you need to find the owner module for a behavior or test surface
@@ -25,8 +25,17 @@ Read when: you need to find the owner module for a behavior or test surface
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
- Immersion tracking: `src/core/services/immersion-tracker/`
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
`delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap; anywhere else it would collapse lifetime totals to the session retention window.
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
- Anime browser: extension bridge client, sidecar, and stream handling in `src/anime-bridge/`;
browser window UI in `src/animeui/` (preload `src/preload-animeui.ts`); runtime wiring in
`src/main/runtime/anime-browser-runtime.ts`, `src/main/runtime/anime-browser-ipc-handlers.ts`,
`src/main/runtime/anime-bridge-installer.ts`, `src/main/runtime/stream-playback-metadata.ts`.
The play queue resolves episodes on click and appends them to mpv's real playlist
(`src/main/runtime/anime-browser-queue.ts`), then observes media-path changes to
attach prepared external tracks and update the browser queue state.
- 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/`
@@ -44,6 +53,7 @@ Read when: you need to find the owner module for a behavior or test surface
- Settings UI contracts: `src/types/settings.ts`
- Session-binding contracts: `src/types/session-bindings.ts`
- Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.ts`
- Anime browser contracts: `src/types/anime-browser.ts`, bridge wire types in `src/anime-bridge/types.ts`
- Compatibility-only barrel: `src/types.ts`
## Ownership Heuristics
+34 -9
View File
@@ -3,7 +3,7 @@
# Subtitle Overlay Priming
Status: active
Last verified: 2026-06-14
Last verified: 2026-08-04
Owner: Kyle Yasuda
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
@@ -47,18 +47,43 @@ subtitles do not draw.
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
payload.
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
synchronously, then replaces it with the tokenized payload when ready.
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
`subtitleProcessingController` method. This gives the visible overlay priority over background
prefetch work and re-centers prefetch around the live playback time.
Both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchService` and then call
the matching `subtitleProcessingController` method, giving the visible overlay priority over
background prefetch work. Prefetch is not re-centered here: restarting the run per line
(`onSeek`) discarded the in-flight tokenization every time the subtitle changed, so only real
seeks restart it (see `onTimePosUpdate` in `src/main.ts`).
On an uncached autoplay prime the raw payload is emitted here and reported to the controller with
`notePlainSubtitleEmitted`, so the controller skips its own plain emit for that line and the
overlay receives one plain payload followed by the annotated one.
The pause is released by the controller's `onProcessingSettled` callback, which fires once it has
no work left. Emits do not release it: the first emit for an uncached line is the plain payload
that precedes tokenization, and a run can finish without emitting at all (a suppressed duplicate,
a failed tokenization). Both controller methods return whether processing is now pending, and the
caller resumes immediately when it is not — a repeated subtitle schedules no work, so no settle is
coming and prefetching would otherwise idle for the rest of the cue.
## Live Cue Delivery
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
live work does not contend for Yomitan state.
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
clear payload is emitted immediately. The older tokenization result is dropped before it can
replace the current cue.
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
or character images, but cue visibility does not wait for that work.
## Emitted State
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
annotated subtitle payload to overlay windows and subtitle websocket listeners.
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
websocket listeners receive both the immediate plain cue and its later annotation upgrade.
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
when text and cue timing match.
- Secondary priming reads mpv `secondary-sub-text`, stores it in
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
+5 -5
View File
@@ -3,7 +3,7 @@
# Documentation Catalog
Status: active
Last verified: 2026-05-23
Last verified: 2026-08-13
Owner: Kyle Yasuda
Read when: finding internal docs or checking verification status
@@ -11,16 +11,16 @@ Read when: finding internal docs or checking verification status
| --- | --- | --- | --- | --- |
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint |
| Architecture index | `docs/architecture/README.md` | active | 2026-05-23 | top-level runtime map |
| Domain ownership | `docs/architecture/domains.md` | active | 2026-05-23 | runtime and feature ownership |
| Domain ownership | `docs/architecture/domains.md` | active | 2026-08-02 | runtime and feature ownership |
| Layering rules | `docs/architecture/layering.md` | active | 2026-05-23 | dependency direction and smells |
| Subtitle overlay priming | `docs/architecture/subtitle-overlay-priming.md` | active | 2026-06-01 | visible-overlay subtitle startup flow |
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
| Workflow index | `docs/workflow/README.md` | active | 2026-08-13 | execution map |
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-13 | repo-local workflow skill ownership |
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
## Update Rules
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,184 +0,0 @@
# Library Summary Replaces Per-Day Trends — Design
**Status:** Draft
**Date:** 2026-04-09
**Scope:** `stats/` frontend, `src/core/services/immersion-tracker/query-trends.ts` backend
## Problem
The "Library — Per Day" section on the stats Trends tab (`stats/src/components/trends/TrendsTab.tsx:224-254`) renders six stacked-area charts — Videos, Watch Time, Cards, Words, Lookups, and Lookups/100w, each broken down per title per day.
In practice these charts are not useful:
- Most titles only have activity on one or two days in a window, so they render as isolated bumps on a noisy baseline.
- Stacking 7+ titles with mostly-zero days makes individual lines hard to follow.
- The top "Activity" and "Period Trends" sections already answer "what am I doing per day" globally.
- The "Library — Cumulative" section directly below already answers "which titles am I progressing through" with less noise.
The per-day section occupies significant vertical space without carrying its weight, and the user has confirmed it should be replaced.
## Goal
Replace the six per-day stacked charts with a single "Library — Summary" section that surfaces per-title aggregate statistics over the selected date range. The new view should make it trivially easy to answer: "For the selected window, which titles am I spending time on, how much mining output have they produced, and how efficient is my lookup rate on each?"
## Non-goals
- Changing the "Library — Cumulative" section (stays as-is).
- Changing the "Activity", "Period Trends", or "Patterns" sections.
- Adding a new API endpoint — the existing dashboard endpoint is extended in place.
- Renaming internal `anime*` data-model identifiers (`animeId`, `imm_anime`, etc.). Those stay per the convention established in `c5e778d7`; only new fields/types/user-visible strings use generic "title"/"library" wording.
- Supporting a true all-time library view on the Trends tab. If that's ever wanted, it belongs on a different tab.
## Solution Overview
Delete the "Library — Per Day" section. In its place, add "Library — Summary", composed of:
1. A horizontal-bar leaderboard chart of watch time per title (top 10, descending).
2. A sortable table of every title with activity in the selected window, with columns: Title, Watch Time, Videos, Sessions, Cards, Words, Lookups, Lookups/100w, Date Range.
Both controls are scoped to the top-of-page date range selector. The existing shared Anime Visibility filter continues to work — it now gates Summary + Cumulative instead of Per-Day + Cumulative.
## Backend
### New type
Add to `stats/src/types/stats.ts` and the backend query module:
```ts
type LibrarySummaryRow = {
title: string; // display title — anime series, YouTube video title, etc.
watchTimeMin: number; // sum(total_active_min) across the window
videos: number; // distinct video_id count
sessions: number; // session count from imm_sessions
cards: number; // sum(total_cards)
words: number; // sum(total_tokens_seen)
lookups: number; // sum(lookup_count) from imm_sessions
lookupsPerHundred: number | null; // lookups / words * 100, null when words == 0
firstWatched: number; // min(rollup_day) as epoch day, within the window
lastWatched: number; // max(rollup_day) as epoch day, within the window
};
```
### Query changes in `src/core/services/immersion-tracker/query-trends.ts`
- Add `librarySummary: LibrarySummaryRow[]` to `TrendsDashboardQueryResult`.
- Populate it from a single aggregating query over `imm_daily_rollups` joined to `imm_videos``imm_anime`, filtered by `rollup_day` within the selected window. Session count and lookup count come from `imm_sessions` aggregated by `video_id` and then grouped by the parent library entry. Use a single query (or at most two joined/unioned) — no N+1.
- `imm_anime` is the generic library-grouping table; anime series, YouTube videos, and yt-dlp imports all land there. The internal table name stays `imm_anime`; only the new field uses generic naming.
- Return rows pre-sorted by `watchTimeMin` descending so the leaderboard is zero-cost and the table default sort matches.
- Emit `lookupsPerHundred: null` when `words == 0`.
### Removed from API response
Drop the entire `animePerDay` field from `TrendsDashboardQueryResult` (both backend in `src/core/services/immersion-tracker/query-trends.ts` and frontend in `stats/src/types/stats.ts`).
Internally, the existing helpers (`buildPerAnimeFromDailyRollups`, `buildEpisodesPerAnimeFromDailyRollups`) are still used as intermediates to build `animeCumulative.*` via `buildCumulativePerAnime`. Keep those helpers — just scope their output to local variables inside `getTrendsDashboard` instead of exposing them on the response. The `buildPerAnimeFromSessions` call for lookups and the `buildLookupsPerHundredPerAnime` helper become unused and can be deleted.
Before removing `animePerDay` from the frontend type, verify no other file under `stats/src/` references it. Based on current inspection, only `TrendsTab.tsx` and `stats/src/types/stats.ts` touch it.
## Frontend
### New component: `stats/src/components/trends/LibrarySummarySection.tsx`
Owns the header, leaderboard chart, visibility-filtered data, and the table. Keeps `TrendsTab.tsx` from growing. Component props: `{ rows: LibrarySummaryRow[]; hiddenTitles: ReadonlySet<string>; windowStart: Date; windowEnd: Date }`.
Internal state: `useState<{ column: ColumnId; direction: 'asc' | 'desc' }>` for sort, defaulting to `{ column: 'watchTimeMin', direction: 'desc' }`.
### Layout
Replaces `TrendsTab.tsx:224-254`:
```
[SectionHeader: "Library — Summary"]
[AnimeVisibilityFilter — unchanged, shared with Cumulative below]
[Card, col-span-full: Leaderboard — horizontal bar chart, ~260px tall]
[Card, col-span-full: Sortable table, auto height up to ~480px with internal scroll]
```
Both cards use the existing chart/card wrapper styling.
### Leaderboard chart
- Recharts horizontal bar chart (matches the rest of the page — existing charts use `recharts`, not ECharts).
- Top 10 titles by watch time. If fewer titles have activity, render what's there.
- Y-axis: title (category), truncated with ellipsis at container width; full title visible in the Recharts tooltip.
- X-axis: minutes (number).
- Use `layout="vertical"` with `YAxis dataKey="title" type="category"` and `XAxis type="number"`.
- Single series color: `#8aadf4` (matching the existing Watch Time color).
- Reuse `CHART_DEFAULTS`, `CHART_THEME`, `TOOLTIP_CONTENT_STYLE` from `stats/src/lib/chart-theme.ts` so theming matches the rest of the dashboard.
- Chart order is fixed at watch-time desc regardless of table sort — the leaderboard's meaning is fixed.
### Table
- Plain HTML `<table>` with Tailwind classes. No new deps.
- Columns, in order:
1. **Title** — left-aligned, sticky, truncated with ellipsis, full title on hover.
2. **Watch Time** — formatted `Xh Ym` when ≥60 min, else `Xm`.
3. **Videos** — integer.
4. **Sessions** — integer.
5. **Cards** — integer.
6. **Words** — integer.
7. **Lookups** — integer.
8. **Lookups/100w** — one decimal place, `—` when null.
9. **Date Range**`Mon D → Mon D` using the title's `firstWatched` / `lastWatched` within the window.
- Click a column header to sort; click again to reverse. Visual arrow on the active column.
- Numeric columns right-aligned.
- Null `lookupsPerHundred` sorts as the lowest value in both directions (consistent with "no data").
- Row hover highlight; no row click action (read-only view).
- Empty state: "No library activity in the selected window."
### Visibility filter integration
Hiding a title via `AnimeVisibilityFilter` removes it from both the leaderboard and the table. The filter's set of available titles is built from the union of titles that appear in `librarySummary` and the existing `animeCumulative.*` arrays (matches current behavior in `buildAnimeVisibilityOptions`).
### `TrendsTab.tsx` changes
- Remove the `filteredEpisodesPerAnime`, `filteredWatchTimePerAnime`, `filteredCardsPerAnime`, `filteredWordsPerAnime`, `filteredLookupsPerAnime`, `filteredLookupsPerHundredPerAnime` locals.
- Remove the six `<StackedTrendChart>` calls in the "Library — Per Day" section.
- Remove the `<SectionHeader>Library — Per Day</SectionHeader>` and the `<AnimeVisibilityFilter>` from that position.
- Insert `<SectionHeader>Library — Summary</SectionHeader>` + `<AnimeVisibilityFilter>` + `<LibrarySummarySection>` in the same place.
- Update `buildAnimeVisibilityOptions` input to use `librarySummary` titles instead of the six dropped `animePerDay.*` arrays.
## Data flow
1. `useTrends(range, groupBy)` calls `/api/stats/trends/dashboard`.
2. Response now includes `librarySummary` (sorted by watch time desc).
3. `TrendsTab` holds the shared `hiddenAnime` set (unchanged).
4. `LibrarySummarySection` receives `librarySummary` + `hiddenAnime`, filters out hidden rows, renders the leaderboard from the top-10 slice of the filtered list, renders the table from the filtered list with local sort state applied.
5. Date-range selector changes trigger a new fetch; `groupBy` toggle does not affect the summary section (it's always window-total).
## Edge cases
- **No activity in window:** Section renders header + empty-state card. Leaderboard card hidden. Visibility filter hidden.
- **One title only:** Leaderboard renders a single bar; table renders one row. No special-casing.
- **Title with zero words but non-zero lookups:** `lookupsPerHundred` is `null`, rendered as `—`. Sort treats null as lowest.
- **Title with zero cards/lookups/words but non-zero watch time:** Normal zero rendering, still shown.
- **Very long titles:** Ellipsis in chart y-axis labels and table title column; full title in `title` attribute / ECharts tooltip.
- **Mixed sources (anime + YouTube):** No special case — both land in `imm_anime` and are grouped uniformly.
## Testing
### Backend (`query-trends.ts`)
New unit tests, following the existing pattern:
1. Empty window returns `librarySummary: []`.
2. Single title with a few rollups: all aggregates are correct; `firstWatched`/`lastWatched` match the bounding days within the window.
3. Multiple titles: rows returned sorted by watch time desc.
4. Mixed sources (anime-style + YouTube-style entries in `imm_anime`): both appear in the summary with their own aggregates.
5. Title with `words == 0`: `lookupsPerHundred` is `null`.
6. Date range excludes some rollups: excluded rollups are not counted; `firstWatched`/`lastWatched` reflect only within-window activity.
7. `sessions` and `lookups` come from `imm_sessions`, not `imm_daily_rollups`, and are correctly attributed to the parent library entry.
### Frontend
- Existing Trends tab smoke test should continue to pass after wiring.
- Optional: a targeted render test for `LibrarySummarySection` (empty state, single title, sort toggle, visibility filter interaction). Not required for merge if the smoke test exercises the happy path.
## Release / docs
- One fragment in `changes/*.md` summarizing the replacement.
- No user-facing docs (`docs-site/`) changes unless the per-day section was documented there — verify during implementation.
## Open items
None.
@@ -1,347 +0,0 @@
# Stats Dashboard Feedback Pass — Design
Date: 2026-04-09
Scope: Stats dashboard UX follow-ups from user feedback (items 17).
Delivery: **Single PR**, broken into logically scoped commits.
## Goals
Address seven concrete pieces of feedback against the Statistics menu:
1. Library — collapse episodes behind a per-series dropdown.
2. Sessions — roll up multiple sessions of the same episode within a day.
3. Trends — add a 365d range option.
4. Library — delete an episode (video) from its detail view.
5. Vocabulary — tighten spacing between word and reading in the Top 50 table.
6. Episode detail — hide cards whose Anki notes have been deleted.
7. Trend/watch charts — add gridlines, fix tick legibility, unify theming.
Out of scope for this pass: English-token ingestion cleanup and Overview stat-card drill-downs (feedback items 8 and 9). Those require a larger design decision and a migration respectively.
## Files touched (inventory)
Dashboard (`stats/src/`):
- `components/library/LibraryTab.tsx` — collapsible groups (item 1).
- `components/library/MediaDetailView.tsx`, `components/library/MediaHeader.tsx` — delete-episode action (item 4).
- `components/sessions/SessionsTab.tsx`, `components/library/MediaSessionList.tsx` — episode rollup (item 2).
- `components/trends/DateRangeSelector.tsx`, `hooks/useTrends.ts`, `lib/api-client.ts`, `lib/api-client.test.ts` — 365d (item 3).
- `components/vocabulary/FrequencyRankTable.tsx` — word/reading column collapse (item 5).
- `components/anime/EpisodeDetail.tsx` — filter deleted Anki cards (item 6).
- `components/trends/TrendChart.tsx`, `components/trends/StackedTrendChart.tsx`, `components/overview/WatchTimeChart.tsx`, `lib/chart-theme.ts` — chart clarity (item 7).
- New file: `stats/src/lib/session-grouping.ts` + `session-grouping.test.ts`.
Backend (`src/core/services/`):
- `immersion-tracker/query-trends.ts` — extend `TrendRange` and `TREND_DAY_LIMITS` (item 3).
- `immersion-tracker/__tests__/query.test.ts` — 365d coverage (item 3).
- `stats-server.ts` — passthrough if range validation lives here (check before editing).
- `__tests__/stats-server.test.ts` — 365d coverage (item 3).
## Commit plan
One PR, one feature per commit. Order picks low-risk mechanical changes first so failures in later commits don't block merging of earlier ones.
1. `feat(stats): add 365d range to trends dashboard` (item 3)
2. `fix(stats): tighten word/reading column in Top 50 table` (item 5)
3. `fix(stats): hide cards deleted from Anki in episode detail` (item 6)
4. `feat(stats): delete episode from library detail view` (item 4)
5. `feat(stats): collapsible series groups in library` (item 1)
6. `feat(stats): roll up same-episode sessions within a day` (item 2)
7. `feat(stats): gridlines and unified theme for trend charts` (item 7)
Each commit must pass `bun run typecheck`, `bun run test:fast`, and any change-specific checks listed below.
---
## Item 1 — Library collapsible series groups
### Current behavior
`LibraryTab.tsx` groups media via `groupMediaLibraryItems` and always renders the full grid of `MediaCard`s beneath each group header.
### Target behavior
Each group header becomes clickable. Groups with `items.length > 1` default to **collapsed**; single-video groups stay expanded (collapsing them would be visual noise).
### Implementation
- State: `const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(...)`. Initialize from `grouped` where `items.length > 1`.
- Toggle helper: `toggleGroup(key: string)` adds/removes from the set.
- Group header: wrap in a `<button>` with `aria-expanded` and a chevron icon (`▶`/`▼`). Keep the existing cover + title + subtitle layout inside the button.
- Children grid is conditionally rendered on `!collapsedGroups.has(group.key)`.
- Header summary (`N videos · duration · cards`) stays visible in both states so collapsed groups remain informative.
### Tests
- New `LibraryTab.test.tsx` (if not already present — check first) covering:
- Multi-video group renders collapsed on first mount.
- Single-video group renders expanded on first mount.
- Clicking the header toggles visibility.
- Header summary is visible in both states.
---
## Item 2 — Sessions episode rollup within a day
### Current behavior
`SessionsTab.tsx:10-24` groups sessions by day label only (`formatSessionDayLabel(startedAtMs)`). Multiple sessions of the same episode on the same day show as independent rows. `MediaSessionList.tsx` has the same problem inside the library detail view.
### Target behavior
Within each day, sessions with the same `videoId` collapse into one parent row showing combined totals. A chevron reveals the individual sessions. Single-session buckets render flat (no pointless nesting).
### Implementation
- New helper in `stats/src/lib/session-grouping.ts`:
```ts
export interface SessionBucket {
key: string; // videoId as string, or `s-${sessionId}` for singletons
videoId: number | null;
sessions: SessionSummary[];
totalActiveMs: number;
totalCardsMined: number;
representativeSession: SessionSummary; // most recent, for header display
}
export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[];
```
Sessions missing a `videoId` become singleton buckets.
- `SessionsTab.tsx`: after day grouping, pipe each `daySessions` through `groupSessionsByVideo`. Render each bucket:
- `sessions.length === 1`: existing `SessionRow` behavior, unchanged.
- `sessions.length >= 2`: render a **bucket row** that looks like `SessionRow` but shows combined totals and session count (e.g. `3 sessions · 1h 24m · 12 cards`). Chevron state stored in a second `Set<string>` on bucket key. Expanded buckets render the child `SessionRow`s indented (`pl-8`) beneath the header.
- `MediaSessionList.tsx`: within the media detail view, a single video's sessions are all the same `videoId` by definition — grouping here is by day only, and within a day multiple sessions render nested under a day header. Re-use the same visual pattern; factor the bucket row into a shared `SessionBucketRow` component.
### Delete semantics
- Deleting a bucket header offers "Delete all N sessions in this group" (reuse `confirmDayGroupDelete` pattern with a bucket-specific message, or add `confirmBucketDelete`).
- Deleting an individual session from inside an expanded bucket keeps the existing single-delete flow.
### Tests
- `session-grouping.test.ts`:
- Empty input → empty output.
- All unique videos → N singleton buckets.
- Two sessions same videoId → one bucket with correct totals and representative (most recent start time).
- Missing videoId → singleton bucket keyed by sessionId.
- `SessionsTab.test.tsx` (extend or add) verifying the rendered bucket rows expand/collapse and delete hooks fire with the right ID set.
---
## Item 3 — 365d trends range
### Backend
`src/core/services/immersion-tracker/query-trends.ts`:
- `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';`
- Add `'365d': 365` to `TREND_DAY_LIMITS`.
- `getTrendDayLimit` picks up the new key automatically because of the `Exclude<TrendRange, 'all'>` generic.
`src/core/services/stats-server.ts`:
- Search for any hardcoded range validation (e.g. allow-list in the trends route handler) and extend it.
### Frontend
- `hooks/useTrends.ts`: widen the `TimeRange` union.
- `components/trends/DateRangeSelector.tsx`: add `'365d'` to the options list. Display label stays as `365d`.
- `lib/api-client.ts` / `api-client.test.ts`: if the client validates ranges, add `365d`.
### Tests
- `query.test.ts`: extend the existing range table to cover `365d` returning 365 days of data.
- `stats-server.test.ts`: ensure the route accepts `range=365d`.
- `api-client.test.ts`: ensure the client emits the new range.
### Change-specific checks
- `bun run test:config` is not required here (no schema/defaults change).
- Run `bun run typecheck` + `bun run test:fast`.
---
## Item 4 — Delete episode from library detail
### Current behavior
`MediaDetailView.tsx` provides session-level delete only. The backend `deleteVideo` exists (`query-maintenance.ts:509`), the API is exposed at `stats-server.ts:559`, and `api-client.deleteVideo` is already wired (`stats/src/lib/api-client.ts:146`). `EpisodeList.tsx:46` already uses it from the anime tab.
### Target behavior
A "Delete Episode" action in `MediaHeader` (top-right, small, `text-ctp-red`), gated by `confirmEpisodeDelete(title)`. On success, call `onBack()` and make sure the parent `LibraryTab` refetches.
### Implementation
- Add an `onDeleteEpisode?: () => void` prop to `MediaHeader` and render the button only if provided.
- In `MediaDetailView`:
- New handler `handleDeleteEpisode` that calls `apiClient.deleteVideo(videoId)`, then `onBack()`.
- Reuse `confirmEpisodeDelete` from `stats/src/lib/delete-confirm.ts`.
- In `LibraryTab`:
- `useMediaLibrary` returns fresh data on mount. The simplest fix: pass a `refresh` function from the hook (extend the hook if it doesn't already expose one) and call it when the detail view signals back.
- Alternative: force a remount by incrementing a `libraryVersion` key on the library list. Prefer `refresh` for clarity.
### Tests
- Extend the existing `MediaDetailView.test.tsx`: mock `apiClient.deleteVideo`, click the new button, confirm `onBack` fires after success.
- `useMediaLibrary.test.ts`: if we add a `refresh` method, cover it.
---
## Item 5 — Vocabulary word/reading column collapse
### Current behavior
`FrequencyRankTable.tsx:110-144` uses a 5-column table: `Rank | Word | Reading | POS | Seen`. Word and Reading are auto-sized, producing a large gap.
### Target behavior
Merge Word + Reading into a single column titled "Word". Reading sits immediately after the headword in a muted, smaller style.
### Implementation
- Drop the `<th>Reading</th>` header and cell.
- Word cell becomes:
```tsx
<td className="py-1.5 pr-3">
<span className="text-ctp-text font-medium">{w.headword}</span>
{reading && (
<span className="text-ctp-subtext0 text-xs ml-1.5">
【{reading}】
</span>
)}
</td>
```
where `reading = fullReading(w.headword, w.reading)` and differs from `headword`.
- Keep `fullReading` import from `reading-utils`.
### Tests
- Extend `FrequencyRankTable.test.tsx` (if present — otherwise add a focused test) to assert:
- Headword renders.
- Reading renders when different from headword.
- Reading does not render when equal to headword.
---
## Item 6 — Hide Anki-deleted cards in Cards Mined
### Current behavior
`EpisodeDetail.tsx:109-147` iterates `cardEvents`, fetches note info via `ankiNotesInfo(allNoteIds)`, and for each `noteId` renders a row even if no matching `info` came back — the user sees an empty word with an "Open in Anki" button that leads nowhere.
### Target behavior
After `ankiNotesInfo` resolves:
- Drop `noteId`s that are not in the resolved map.
- Drop `cardEvents` whose `noteIds` list was non-empty but is now empty after filtering.
- Card events with a positive `cardsDelta` but no `noteIds` (legacy rollup path) still render as `+N cards` — we have no way to cross-reference them, so leave them alone.
### Implementation
- Compute `filteredCardEvents` as a `useMemo` depending on `data.cardEvents` and `noteInfos`.
- Iterate `filteredCardEvents` instead of `cardEvents` in the render.
- Surface a subtle note (optional, muted) "N cards hidden (deleted from Anki)" at the end of the list if any were filtered — helps the user understand why counts here diverge from session totals. Final decision on the note can be made at PR review; default: **show it**.
### Tests
- Add a test in `EpisodeDetail.test.tsx` (add the file if not present) that stubs `ankiNotesInfo` to return only a subset of notes and verifies the missing ones are not rendered.
### Other call sites
- Grep so far shows `ankiNotesInfo` is only used in `EpisodeDetail.tsx`. Re-verify before landing the commit; if another call site appears, apply the same filter.
---
## Item 7 — Trend/watch chart clarity pass
### Current behavior
`TrendChart.tsx`, `StackedTrendChart.tsx`, and `WatchTimeChart.tsx` render Recharts components with:
- No `CartesianGrid` → no horizontal reference lines.
- 9px axis ticks → borderline unreadable.
- Height 120 → cramped.
- Tooltip uses raw labels (`04/04` etc.).
- No shared theme object; each chart redefines colors and tooltip styles inline.
`stats/src/lib/chart-theme.ts` already exists and currently exports a single `CHART_THEME` constant with tick/tooltip colors and `barFill`. It will be extended, not replaced, to preserve existing consumers.
### Target behavior
All three charts share a theme, have horizontal gridlines, readable ticks, and sensible tooltips.
### Implementation
Extend `stats/src/lib/chart-theme.ts` with the additional shared defaults (keeping the existing `CHART_THEME` export intact so current consumers don't break):
```ts
export const CHART_THEME = {
tick: '#a5adcb',
tooltipBg: '#363a4f',
tooltipBorder: '#494d64',
tooltipText: '#cad3f5',
tooltipLabel: '#b8c0e0',
barFill: '#8aadf4',
grid: '#494d64',
axisLine: '#494d64',
} as const;
export const CHART_DEFAULTS = {
height: 160,
tickFontSize: 11,
margin: { top: 8, right: 8, bottom: 0, left: 0 },
grid: { strokeDasharray: '3 3', vertical: false },
} as const;
export const TOOLTIP_CONTENT_STYLE = {
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 12,
};
```
Apply to each chart:
- Import `CartesianGrid` from recharts.
- Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` inside each chart container.
- `<XAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} />` and equivalent `YAxis`.
- `YAxis` gains `axisLine={{ stroke: CHART_THEME.axisLine }}`.
- `ResponsiveContainer` height changes from 120 → `CHART_DEFAULTS.height`.
- `Tooltip` `contentStyle` uses `TOOLTIP_CONTENT_STYLE`, and charts pass a `labelFormatter` when the label is a date key (e.g. show `Fri Apr 4`).
### Unit formatters
- `TrendChart` already accepts a `formatter` prop — extend usage sites to pass unit-aware formatters where they aren't already (`formatDuration`, `formatNumber`, etc.).
### Tests
- `chart-theme.test.ts` (if present — otherwise add a trivial snapshot to keep the shape stable).
- `TrendChart` snapshot/render tests: no regression, gridline element present.
---
## Verification gate
Before requesting code review, run:
```
bun run typecheck
bun run test:fast
bun run test:env
bun run test:runtime:compat # dist-sensitive check for the charts
bun run build
bun run test:smoke:dist
```
No docs-site changes are planned in this spec; if `docs-site/` ends up touched (e.g. screenshots), also run `bun run docs:test` and `bun run docs:build`.
No config schema changes → `bun run test:config` and `bun run generate:config-example` are not required.
## Risks and open questions
- **MediaDetailView refresh**: `useMediaLibrary` may not expose a `refresh` function. If it doesn't, the simplest path is adding one; the alternative (keying a remount) works but is harder to test. Decide during implementation.
- **Session bucket delete UX**: "Delete all N sessions in this group" is powerful. The copy must make it clear the underlying sessions are being removed, not just the grouping. Reuse `confirmBucketDelete` wording from existing confirm helpers if possible.
- **Anki-deleted-cards hidden notice**: Showing a subtle "N cards hidden" footer is a call that can be made at PR review.
- **Bucket delete helper**: `confirmBucketDelete` does not currently exist in `delete-confirm.ts`. Implementation either adds it or reuses `confirmDayGroupDelete` with bucket-specific wording — decide during the session-rollup commit.
## Changelog entry
User-visible PR → needs a fragment under `changes/*.md`. Suggested title:
`Stats dashboard: collapsible series, session rollups, 365d trends, chart polish, episode delete.`
+2 -2
View File
@@ -3,7 +3,7 @@
# Workflow
Status: active
Last verified: 2026-05-23
Last verified: 2026-08-13
Owner: Kyle Yasuda
Read when: planning or executing nontrivial work in this repo
@@ -13,7 +13,7 @@ This section is the internal workflow map for contributors and agents.
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
- [Agent Plugins](./agent-plugins.md) - repo-local plugin ownership for agent workflow skills
- [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership
- [Release Guide](../RELEASING.md) - tagged release workflow
## Default Flow
-27
View File
@@ -1,27 +0,0 @@
<!-- read_when: using or modifying repo-local agent plugins -->
# Agent Plugins
Status: active
Last verified: 2026-05-23
Owner: Kyle Yasuda
Read when: packaging or migrating repo-local agent workflow skills into plugins
## SubMiner Workflow Plugin
- Canonical plugin path: `plugins/subminer-workflow/`
- Marketplace catalog: `.agents/plugins/marketplace.json`
- Canonical skill sources:
- `plugins/subminer-workflow/skills/subminer-scrum-master/`
- `plugins/subminer-workflow/skills/subminer-change-verification/`
## Migration Rule
- Plugin-owned skills are the source of truth.
- `.agents/skills/subminer-*` remain only as compatibility shims.
- Existing script entrypoints under `.agents/skills/subminer-change-verification/scripts/` stay as wrappers so historical commands do not break.
## Verification
- For plugin/docs-only changes, start with `bun run test:docs:kb`.
- Use the plugin-owned verifier when the change crosses from docs into scripts or workflow logic.
+31
View File
@@ -0,0 +1,31 @@
<!-- read_when: using or modifying repo-local agent skills -->
# Agent Skills
Status: active
Last verified: 2026-08-13
Owner: Kyle Yasuda
Read when: using, adding, or changing a repo-local agent workflow skill
## Canonical Skills
- `.agents/skills/subminer-change-verification/`
- Selects the cheapest sufficient repo-native verification lane.
- Defers command ownership to `package.json` and `docs/workflow/verification.md`.
Repo-local workflows stay as standalone skills. Do not add plugin packaging, marketplace metadata, or compatibility shims unless the workflow is intentionally being distributed beyond this repository.
## Rules
- Keep each skill focused on one repeatable repository task.
- Prefer instructions over helper scripts unless deterministic tooling provides clear value beyond existing package commands.
- Keep trigger descriptions narrow enough to avoid invoking skills for unrelated requests.
- Update this page and the documentation catalog when skill ownership changes.
## Verification
For skill or internal workflow documentation changes, run:
```bash
bun run test:docs:kb
```
+4 -4
View File
@@ -3,14 +3,14 @@
# Verification
Status: active
Last verified: 2026-07-06
Last verified: 2026-08-13
Owner: Kyle Yasuda
Read when: selecting the right verification lane for a change
## Lane Infrastructure
- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by
directory new test files join their lane automatically; never hand-list test
directory, so new test files join their lane automatically; never hand-list test
files in `package.json`.
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
(per-file isolation with a wall timeout) so a hanging test or leaked global in
@@ -43,8 +43,8 @@ bun run docs:build
## Cheap-First Lane Selection
- Docs-only boundary/content changes: `bun run docs:test`, `bun run docs:build`
- Internal KB / `AGENTS.md` changes: `bun run test:docs:kb`
- User-facing `docs-site/` changes: `bun run docs:test`, `bun run docs:build`
- Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb`
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
+11
View File
@@ -16,6 +16,10 @@ type AppCommandDeps = {
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
launchAnimeBrowserDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
};
const defaultAppCommandDeps: AppCommandDeps = {
@@ -23,6 +27,8 @@ const defaultAppCommandDeps: AppCommandDeps = {
launchSyncUiDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
launchAppBackgroundDetached,
launchAnimeBrowserDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--anime'], logLevel, 'anime'),
};
export function runAppPassthroughCommand(
@@ -37,6 +43,11 @@ export function runAppPassthroughCommand(
deps.runAppCommandWithInherit(appPath, ['--settings']);
return true;
}
if (args.animeBrowser) {
// Detached: the browser window is long-lived and owns the bridge process.
deps.launchAnimeBrowserDetached(appPath, args.logLevel);
return true;
}
if (args.syncUi) {
deps.launchSyncUiDetached(appPath, args.logLevel);
return true;
@@ -207,6 +207,7 @@ test('app command starts default macOS background app detached from launcher', (
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -227,6 +228,7 @@ test('app command starts default Linux background app detached from launcher', (
calls.push('attached');
},
launchSyncUiDetached: () => calls.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`);
},
@@ -248,6 +250,7 @@ test('app command keeps explicit passthrough args attached', () => {
forwarded.push(appArgs);
},
launchSyncUiDetached: () => detached.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: () => {
detached.push('detached');
},
@@ -266,6 +269,7 @@ test('sync UI command launches the app detached from the terminal', () => {
const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => calls.push('piped'),
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
launchAnimeBrowserDetached: () => calls.push('anime'),
launchAppBackgroundDetached: () => calls.push('detached'),
});
@@ -63,6 +63,7 @@ function createContext(): LauncherCommandContext {
logsExport: false,
version: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
+9
View File
@@ -157,6 +157,15 @@ export async function runStatsCommand(
if (args.statsCleanupLifetime) {
forwarded.push('--stats-cleanup-lifetime');
}
if (args.statsCleanupDuplicateLines) {
forwarded.push('--stats-cleanup-duplicate-lines');
}
if (args.statsCleanupDryRun) {
forwarded.push('--stats-cleanup-dry-run');
}
if (args.statsCleanupLookbackDays) {
forwarded.push('--stats-cleanup-lookback-days', String(args.statsCleanupLookbackDays));
}
if (shouldForwardLogLevel(args.logLevel)) {
forwarded.push('--log-level', args.logLevel);
}
+16
View File
@@ -120,6 +120,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
logLevel: 'warn',
},
settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -134,6 +135,9 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
@@ -171,6 +175,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
settingsInvocation: {
logLevel: undefined,
},
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -185,6 +190,9 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
@@ -215,6 +223,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
action: undefined,
},
settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -229,6 +238,9 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
@@ -257,6 +269,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
jellyfinInvocation: null,
configInvocation: null,
settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
@@ -271,6 +284,9 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null,
syncTriggered: false,
syncCliTokens: [],
+14
View File
@@ -162,12 +162,15 @@ export function createDefaultArgs(
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
update: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
@@ -258,6 +261,11 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
if (invocations.statsCleanup) parsed.statsCleanup = true;
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
if (invocations.statsCleanupDuplicateLines) parsed.statsCleanupDuplicateLines = true;
if (invocations.statsCleanupDryRun) parsed.statsCleanupDryRun = true;
if (invocations.statsCleanupLookbackDays !== null) {
parsed.statsCleanupLookbackDays = invocations.statsCleanupLookbackDays;
}
if (invocations.dictionaryTarget) {
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
} else if (
@@ -348,6 +356,12 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
);
}
if (invocations.animeInvocation) {
if (invocations.animeInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.animeInvocation.logLevel);
}
parsed.animeBrowser = true;
}
if (invocations.settingsInvocation) {
if (invocations.settingsInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel);
+61 -3
View File
@@ -23,6 +23,7 @@ export interface CliInvocations {
jellyfinInvocation: JellyfinInvocation | null;
configInvocation: CommandActionInvocation | null;
settingsInvocation: CommandActionInvocation | null;
animeInvocation: CommandActionInvocation | null;
mpvInvocation: CommandActionInvocation | null;
appInvocation: { appArgs: string[] } | null;
dictionaryTriggered: boolean;
@@ -37,6 +38,9 @@ export interface CliInvocations {
statsCleanup: boolean;
statsCleanupVocab: boolean;
statsCleanupLifetime: boolean;
statsCleanupDuplicateLines: boolean;
statsCleanupDryRun: boolean;
statsCleanupLookbackDays: number | null;
statsLogLevel: string | null;
syncTriggered: boolean;
syncCliTokens: string[];
@@ -53,6 +57,16 @@ export interface CliInvocations {
texthookerOpenBrowser: boolean;
}
/** `--lookback-days` narrows the duplicate-line cleanup; fractions are floored. */
function parseStatsLookbackDays(value: unknown): number | null {
if (typeof value !== 'string' && typeof value !== 'number') return null;
const days = Number(value);
if (!Number.isFinite(days) || days < 1) {
throw new Error('Stats --lookback-days must be at least one day.');
}
return Math.floor(days);
}
function applyRootOptions(program: Command): void {
program
.option(
@@ -102,6 +116,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'doctor',
'config',
'settings',
'anime',
'mpv',
'logs',
'dictionary',
@@ -155,6 +170,7 @@ export function parseCliPrograms(
let jellyfinInvocation: JellyfinInvocation | null = null;
let configInvocation: CommandActionInvocation | null = null;
let settingsInvocation: CommandActionInvocation | null = null;
let animeInvocation: CommandActionInvocation | null = null;
let mpvInvocation: CommandActionInvocation | null = null;
let appInvocation: { appArgs: string[] } | null = null;
let dictionaryTriggered = false;
@@ -169,6 +185,9 @@ export function parseCliPrograms(
let statsCleanup = false;
let statsCleanupVocab = false;
let statsCleanupLifetime = false;
let statsCleanupDuplicateLines = false;
let statsCleanupDryRun = false;
let statsCleanupLookbackDays: number | null = null;
let statsLogLevel: string | null = null;
let syncTriggered = false;
let syncCliTokens: string[] = [];
@@ -269,6 +288,9 @@ export function parseCliPrograms(
.option('-s, --stop', 'Stop the background stats server')
.option('-v, --vocab', 'Clean vocabulary rows in the stats database')
.option('-l, --lifetime', 'Rebuild lifetime summary rows from retained data')
.option('-d, --duplicate-lines', 'Collapse repeated subtitle lines from typeset animations')
.option('--dry-run', 'Report what a cleanup would remove without changing anything')
.option('--lookback-days <days>', 'Only clean lines recorded in the last N days')
.option('--log-level <level>', 'Log level')
.action((action: string | undefined, options: Record<string, unknown>) => {
statsTriggered = true;
@@ -289,13 +311,35 @@ export function parseCliPrograms(
if (normalizedAction && (statsBackground || statsStop)) {
throw new Error('Stats background and stop flags cannot be combined with stats actions.');
}
if (normalizedAction !== 'cleanup' && (options.vocab === true || options.lifetime === true)) {
throw new Error('Stats --vocab and --lifetime flags require the cleanup action.');
if (
normalizedAction !== 'cleanup' &&
(options.vocab === true || options.lifetime === true || options.duplicateLines === true)
) {
throw new Error(
'Stats --vocab, --lifetime and --duplicate-lines flags require the cleanup action.',
);
}
if (
options.duplicateLines !== true &&
(options.dryRun === true || options.lookbackDays !== undefined)
) {
throw new Error('Stats --dry-run and --lookback-days require --duplicate-lines.');
}
if (normalizedAction === 'cleanup') {
statsCleanup = true;
statsCleanupLifetime = options.lifetime === true;
statsCleanupVocab = statsCleanupLifetime ? false : options.vocab !== false;
statsCleanupDuplicateLines = options.duplicateLines === true;
const explicitModeCount = [options.vocab, options.lifetime, options.duplicateLines].filter(
(value) => value === true,
).length;
if (explicitModeCount > 1) {
throw new Error('Stats cleanup runs one mode at a time.');
}
// Vocabulary cleanup stays the default so `stats cleanup` keeps its old meaning.
statsCleanupVocab =
statsCleanupLifetime || statsCleanupDuplicateLines ? false : options.vocab !== false;
statsCleanupDryRun = options.dryRun === true;
statsCleanupLookbackDays = parseStatsLookbackDays(options.lookbackDays);
} else if (normalizedAction === 'rebuild' || normalizedAction === 'backfill') {
statsCleanup = true;
statsCleanupLifetime = true;
@@ -415,6 +459,16 @@ export function parseCliPrograms(
};
});
commandProgram
.command('anime')
.description('Open the anime browser window')
.option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => {
animeInvocation = {
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
};
});
commandProgram
.command('mpv')
.description('MPV helpers')
@@ -469,6 +523,7 @@ export function parseCliPrograms(
jellyfinInvocation,
configInvocation,
settingsInvocation,
animeInvocation,
mpvInvocation,
appInvocation,
dictionaryTriggered,
@@ -483,6 +538,9 @@ export function parseCliPrograms(
statsCleanup,
statsCleanupVocab,
statsCleanupLifetime,
statsCleanupDuplicateLines,
statsCleanupDryRun,
statsCleanupLookbackDays,
statsLogLevel,
syncTriggered,
syncCliTokens,
+1
View File
@@ -57,6 +57,7 @@ function createArgs(): Args {
logsExport: false,
version: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
+6 -7
View File
@@ -222,7 +222,7 @@ test('buildMpvEnv preserves native Wayland env for supported Hyprland and Sway a
});
});
test('buildMpvBackendArgs forces an explicit X11 renderer stack when backend resolves to x11', () => {
test('buildMpvBackendArgs pins the X11 window context when backend resolves to x11', () => {
withPlatform('linux', () => {
assert.deepEqual(
buildMpvBackendArgs(makeArgs({ backend: 'x11' }), {
@@ -230,12 +230,12 @@ test('buildMpvBackendArgs forces an explicit X11 renderer stack when backend res
WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland',
}),
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
['--gpu-context=x11vk,x11egl,x11'],
);
});
});
test('buildMpvBackendArgs forces the same X11 renderer stack for unsupported Wayland auto fallback', () => {
test('buildMpvBackendArgs pins the same X11 window context for unsupported Wayland auto fallback', () => {
withPlatform('linux', () => {
assert.deepEqual(
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
@@ -245,7 +245,7 @@ test('buildMpvBackendArgs forces the same X11 renderer stack for unsupported Way
XDG_CURRENT_DESKTOP: 'KDE',
XDG_SESSION_DESKTOP: 'plasma',
}),
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
['--gpu-context=x11vk,x11egl,x11'],
);
});
});
@@ -292,9 +292,7 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
'--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--vo=gpu',
'--gpu-api=opengl',
'--gpu-context=x11egl,x11',
'--gpu-context=x11vk,x11egl,x11',
'--window-maximized=yes',
],
);
@@ -629,6 +627,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
logsExport: false,
version: false,
settings: false,
animeBrowser: false,
configPath: false,
configShow: false,
mpvIdle: false,
+73 -1
View File
@@ -232,6 +232,75 @@ test('parseArgs maps lifetime stats cleanup flag', () => {
assert.equal(parsed.statsCleanupLifetime, true);
});
test('parseArgs maps duplicate-line stats cleanup flags', () => {
const parsed = parseArgs(
['stats', 'cleanup', '--duplicate-lines', '--dry-run', '--lookback-days', '30'],
'subminer',
{},
);
assert.equal(parsed.statsCleanup, true);
assert.equal(parsed.statsCleanupVocab, false);
assert.equal(parsed.statsCleanupDuplicateLines, true);
assert.equal(parsed.statsCleanupDryRun, true);
assert.equal(parsed.statsCleanupLookbackDays, 30);
const fractional = parseArgs(
['stats', 'cleanup', '--duplicate-lines', '--lookback-days', '1.5'],
'subminer',
{},
);
assert.equal(fractional.statsCleanupLookbackDays, 1);
});
test('parseArgs rejects duplicate-line flags without the duplicate-lines mode', () => {
const error = withProcessExitIntercept(() => {
parseArgs(['stats', 'cleanup', '--dry-run'], 'subminer', {});
});
assert.equal(error.code, 1);
assert.match(error.stderr, /--dry-run and --lookback-days require --duplicate-lines/);
});
test('parseArgs rejects an empty lookback value outside duplicate-line cleanup', () => {
const error = withProcessExitIntercept(() => {
parseArgs(['stats', '--lookback-days', ''], 'subminer', {});
});
assert.equal(error.code, 1);
assert.match(error.stderr, /--dry-run and --lookback-days require --duplicate-lines/);
});
test('parseArgs rejects combining explicit cleanup modes', () => {
for (const modes of [
['--lifetime', '--duplicate-lines'],
['--vocab', '--duplicate-lines'],
['--vocab', '--lifetime'],
]) {
const error = withProcessExitIntercept(() => {
parseArgs(['stats', 'cleanup', ...modes], 'subminer', {});
});
assert.equal(error.code, 1);
assert.match(error.stderr, /Stats cleanup runs one mode at a time/);
}
});
test('parseArgs rejects unusable lookback windows', () => {
for (const value of ['0', '0.5', '-5', 'soon']) {
const error = withProcessExitIntercept(() => {
parseArgs(
['stats', 'cleanup', '--duplicate-lines', '--lookback-days', value],
'subminer',
{},
);
});
assert.equal(error.code, 1);
assert.match(error.stderr, /--lookback-days must be at least one day/);
}
});
test('parseArgs rejects cleanup-only stats flags without cleanup action', () => {
const error = withProcessExitIntercept(() => {
parseArgs(['stats', '--vocab'], 'subminer', {});
@@ -239,7 +308,10 @@ test('parseArgs rejects cleanup-only stats flags without cleanup action', () =>
assert.equal(error.code, 1);
assert.match(error.message, /exit:1/);
assert.match(error.stderr, /Stats --vocab and --lifetime flags require the cleanup action/);
assert.match(
error.stderr,
/Stats --vocab, --lifetime and --duplicate-lines flags require the cleanup action/,
);
});
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
+13
View File
@@ -80,6 +80,11 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 },
],
});
withWritableDb(remotePath, (db) => {
db.prepare(
`UPDATE imm_videos SET anime_assignment_locked = 1 WHERE video_key = 'showb-e1'`,
).run();
});
const summary = mergeSnapshotIntoDb(localPath, remotePath);
assert.equal(summary.sessionsMerged, 1);
@@ -126,6 +131,14 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
`SELECT video_id FROM imm_videos WHERE video_key = 'showb-e1'`,
)?.video_id,
);
assert.equal(
queryOne<{ locked: number }>(
localPath,
'SELECT anime_assignment_locked AS locked FROM imm_videos WHERE video_id = ?',
[mergedVideoId],
)?.locked,
1,
);
assert.equal(
count(localPath, 'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE video_id = ?', [
mergedVideoId,
+2 -1
View File
@@ -1,4 +1,4 @@
// Schema-version-18 shape of the tables the sync merge touches (plus the
// Current schema shape of the tables the sync merge touches (plus the
// app's indexes), mirroring ensureSchema / ensureLifetimeSummaryTables /
// ensureStatsExcludedWordsTable in src/core/services/immersion-tracker/storage.ts.
export const IMMERSION_DB_FIXTURE_DDL = `
@@ -39,6 +39,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
parser_source TEXT,
parser_confidence REAL,
parse_metadata_json TEXT,
anime_assignment_locked INTEGER NOT NULL DEFAULT 0 CHECK(anime_assignment_locked IN (0, 1)),
watched INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
+4
View File
@@ -142,6 +142,9 @@ export interface Args {
statsCleanup?: boolean;
statsCleanupVocab?: boolean;
statsCleanupLifetime?: boolean;
statsCleanupDuplicateLines?: boolean;
statsCleanupDryRun?: boolean;
statsCleanupLookbackDays?: number;
dictionaryTarget?: string;
doctor: boolean;
doctorRefreshKnownWords: boolean;
@@ -149,6 +152,7 @@ export interface Args {
version: boolean;
update?: boolean;
settings: boolean;
animeBrowser: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
+13 -6
View File
@@ -2,7 +2,7 @@
"name": "subminer",
"productName": "SubMiner",
"desktopName": "SubMiner.desktop",
"version": "0.19.0",
"version": "0.19.3",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -21,10 +21,11 @@
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
"build:stats": "cd stats && bun run build",
"dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:launcher && bun run build:assets",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:syncui && bun run build:animeui && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
"build:syncui": "esbuild src/syncui/syncui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/syncui/syncui.js --sourcemap && esbuild src/preload-syncui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-syncui.js --sourcemap",
"build:animeui": "esbuild src/animeui/animeui.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/animeui/animeui.js --sourcemap && esbuild src/preload-animeui.ts --bundle --platform=node --format=cjs --target=node20 --external:electron --outfile=dist/preload-animeui.js --sourcemap",
"changelog:build": "bun run scripts/build-changelog.ts build-release",
"changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs",
@@ -84,16 +85,18 @@
"overrides": {
"@xmldom/xmldom": "0.8.13",
"app-builder-lib": "26.15.3",
"brace-expansion": "5.0.8",
"brace-expansion": "5.0.9",
"electron-builder-squirrel-windows": "26.15.3",
"fast-uri": "3.1.5",
"form-data": "4.0.6",
"ip-address": "10.2.0",
"js-yaml": "4.3.0",
"js-yaml": "4.3.1",
"lodash": "4.18.0",
"minimatch": "10.2.5",
"picomatch": "4.0.4",
"tar": "7.5.21",
"tmp": "0.2.7"
"tmp": "0.2.7",
"undici": "7.29.0"
},
"keywords": [
"anki",
@@ -125,7 +128,7 @@
"@types/ws": "^8.18.1",
"electron": "42.6.0",
"electron-builder": "26.15.3",
"undici": "7.28.0",
"undici": "7.29.0",
"esbuild": "^0.25.12",
"eslint": "^10.8.0",
"prettier": "^3.8.1",
@@ -258,6 +261,10 @@
{
"from": "dist/launcher/subminer",
"to": "launcher/subminer"
},
{
"from": "CHANGELOG.md",
"to": "CHANGELOG.md"
}
]
},
@@ -1,30 +0,0 @@
{
"name": "subminer-workflow",
"version": "0.1.0",
"description": "Repo-local SubMiner agent workflow plugin for orchestration and change verification.",
"author": {
"name": "Kyle Yasuda",
"email": "suda@sudacode.com",
"url": "https://github.com/sudacode"
},
"homepage": "https://github.com/sudacode/SubMiner/tree/main/plugins/subminer-workflow",
"repository": "https://github.com/sudacode/SubMiner",
"license": "GPL-3.0-or-later",
"keywords": ["subminer", "workflow", "verification", "skills"],
"skills": "./skills/",
"interface": {
"displayName": "SubMiner Workflow",
"shortDescription": "SubMiner orchestration and verification.",
"longDescription": "Canonical repo-local plugin for SubMiner agent workflow packaging. Owns the scrum-master and change-verification skills plus helper scripts used to plan, verify, and validate changes reproducibly inside this repo.",
"developerName": "Kyle Yasuda",
"category": "Productivity",
"capabilities": ["Interactive", "Write"],
"websiteURL": "https://github.com/sudacode/SubMiner",
"defaultPrompt": [
"Use SubMiner workflow to plan and ship a feature.",
"Verify a SubMiner change with the plugin-owned verifier.",
"Plan and ship this SubMiner task."
],
"brandColor": "#2F6B4F"
}
}
-40
View File
@@ -1,40 +0,0 @@
<!-- read_when: migrating or using the repo-local SubMiner workflow plugin -->
# SubMiner Workflow Plugin
Status: active
Last verified: 2026-03-26
Owner: Kyle Yasuda
Read when: using or updating the repo-local plugin that owns SubMiner agent workflow skills
This plugin is the canonical source of truth for the SubMiner agent workflow packaging.
## Contents
- `skills/subminer-scrum-master/`
- intake, planning, dispatch, and handoff workflow
- `skills/subminer-change-verification/`
- cheap-first verification workflow plus helper scripts
## Compatibility
- `.agents/skills/subminer-scrum-master/` is a compatibility shim that redirects to the plugin-owned skill.
- `.agents/skills/subminer-change-verification/` is a compatibility shim.
- `.agents/skills/subminer-change-verification/scripts/*.sh` remain as wrapper entrypoints so existing docs and shell history keep working.
## Verification
For plugin/doc/shim changes, prefer:
```bash
bun run test:docs:kb
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane docs --lane core \
plugins/subminer-workflow \
.agents/skills/subminer-scrum-master/SKILL.md \
.agents/skills/subminer-change-verification/SKILL.md \
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
.agents/plugins/marketplace.json \
docs/workflow/README.md \
docs/workflow/agent-plugins.md
```
@@ -1,143 +0,0 @@
---
name: 'subminer-change-verification'
description: 'Use when working in the SubMiner repo and you need to verify code changes actually work. Covers targeted regression checks during debugging and pre-handoff verification, with cheap-first lane selection for config, docs, launcher/plugin, runtime-compat, and optional real-runtime escalation.'
---
# SubMiner Change Verification
Canonical source: this plugin path.
Use this skill for SubMiner code changes. Default to cheap, repo-native verification first. Escalate only when the changed behavior actually depends on Electron, mpv, overlay/window tracking, or other GUI-sensitive runtime behavior.
## Scripts
- `scripts/classify_subminer_diff.sh`
- Emits suggested lanes and flags from explicit paths or current git changes.
- `scripts/verify_subminer_change.sh`
- Runs selected lanes, captures artifacts, and writes a compact summary.
If you need an explicit installed path, use the directory that contains this `SKILL.md`. The helper scripts live under:
```bash
export SUBMINER_VERIFY_SKILL="<path-to-plugin-skill>"
```
## Default workflow
1. Inspect the changed files or user-requested area.
2. Run the classifier unless you already know the right lane.
3. Run the verifier with the cheapest sufficient lane set.
4. If the classifier emits `flag:real-runtime-candidate`, do not jump straight to runtime verification. First run the non-runtime lanes.
5. Escalate to explicit `--lane real-runtime --allow-real-runtime` only when cheaper lanes cannot validate the behavior claim.
6. Return:
- verification summary
- exact commands run
- artifact paths
- skipped lanes and blockers
## Quick start
Plugin-source quick start:
```bash
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
```
Installed-skill quick start:
```bash
bash "$SUBMINER_VERIFY_SKILL/scripts/classify_subminer_diff.sh"
```
Compatibility entrypoint:
```bash
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
```
Classify explicit files:
```bash
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
launcher/main.ts \
plugin/subminer/lifecycle.lua \
src/main/runtime/mpv-client-runtime-service.ts
```
Run automatic lane selection:
```bash
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh
```
Installed-skill form:
```bash
bash "$SUBMINER_VERIFY_SKILL/scripts/verify_subminer_change.sh"
```
Compatibility entrypoint:
```bash
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
```
Run targeted lanes:
```bash
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
--lane launcher-plugin \
--lane runtime-compat
```
Dry-run to inspect planned commands and artifact layout:
```bash
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
--dry-run \
launcher/main.ts \
src/main.ts
```
## Lane guidance
- `docs`
- For `docs-site/`, `docs/`, and doc-only edits.
- `config`
- For `src/config/` and config-template-sensitive edits.
- `stats`
- For `stats/` dashboard UI edits.
- `core`
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
- `launcher-plugin`
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
- `runtime-compat`
- For `src/main*`, runtime/composer wiring, mpv/overlay services, window trackers, and dist-sensitive behavior.
- `real-runtime`
- Only after deliberate escalation.
## Real Runtime Escalation
Escalate only when the change claim depends on actual runtime behavior, for example:
- overlay appears, hides, or tracks a real mpv window
- mpv launch flags or pause-until-ready behavior
- plugin/socket/auto-start handshake under a real player
- macOS/window-tracker/focus-sensitive behavior
If the environment cannot support authoritative runtime verification, report the blocker explicitly. Do not silently downgrade a runtime-required claim to a pass.
## Artifact contract
The verifier writes under `.tmp/skill-verification/<timestamp>/`:
- `summary.json`
- `summary.txt`
- `classification.txt`
- `env.txt`
- `lanes.txt`
- `steps.tsv`
- `steps/*.stdout.log`
- `steps/*.stderr.log`
On failure, quote the exact failing command and point at the artifact directory.
@@ -1,171 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: classify_subminer_diff.sh [path ...]
Emit suggested verification lanes for explicit paths or current local git changes.
Output format:
lane:<name>
flag:<name>
reason:<text>
EOF
}
has_item() {
local needle=$1
shift || true
local item
for item in "$@"; do
if [[ "$item" == "$needle" ]]; then
return 0
fi
done
return 1
}
add_lane() {
local lane=$1
if ! has_item "$lane" "${LANES[@]:-}"; then
LANES+=("$lane")
fi
}
add_flag() {
local flag=$1
if ! has_item "$flag" "${FLAGS[@]:-}"; then
FLAGS+=("$flag")
fi
}
add_reason() {
REASONS+=("$1")
}
collect_git_paths() {
local top_level
if ! top_level=$(git rev-parse --show-toplevel 2>/dev/null); then
return 0
fi
(
cd "$top_level"
if git rev-parse --verify HEAD >/dev/null 2>&1; then
git diff --name-only --relative HEAD --
git diff --name-only --relative --cached --
else
git diff --name-only --relative --
git diff --name-only --relative --cached --
fi
git ls-files --others --exclude-standard
) | awk 'NF' | sort -u
}
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
usage
exit 0
fi
declare -a PATHS=()
declare -a LANES=()
declare -a FLAGS=()
declare -a REASONS=()
if [[ $# -gt 0 ]]; then
while [[ $# -gt 0 ]]; do
PATHS+=("$1")
shift
done
else
while IFS= read -r line; do
[[ -n "$line" ]] && PATHS+=("$line")
done < <(collect_git_paths)
fi
if [[ ${#PATHS[@]} -eq 0 ]]; then
add_lane "core"
add_reason "no changed paths detected -> default to core"
fi
for path in "${PATHS[@]}"; do
specialized=0
case "$path" in
docs-site/*|docs/*|changes/*|README.md)
add_lane "docs"
add_reason "$path -> docs"
specialized=1
;;
esac
case "$path" in
src/config/*|src/generate-config-example.ts|src/verify-config-example.ts|docs-site/public/config.example.jsonc|config.example.jsonc)
add_lane "config"
add_reason "$path -> config"
specialized=1
;;
esac
case "$path" in
stats/*)
add_lane "stats"
add_reason "$path -> stats"
specialized=1
;;
esac
case "$path" in
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
add_lane "launcher-plugin"
add_reason "$path -> launcher-plugin"
add_flag "real-runtime-candidate"
add_reason "$path -> real-runtime-candidate"
specialized=1
;;
esac
case "$path" in
src/main.ts|src/main-entry.ts|src/preload.ts|src/main/*|src/core/services/mpv*|src/core/services/overlay*|src/renderer/*|src/window-trackers/*|scripts/prepare-build-assets.mjs)
add_lane "runtime-compat"
add_reason "$path -> runtime-compat"
add_flag "real-runtime-candidate"
add_reason "$path -> real-runtime-candidate"
specialized=1
;;
esac
if [[ "$specialized" == "0" ]]; then
case "$path" in
src/*|package.json|tsconfig*.json|scripts/*|Makefile)
add_lane "core"
add_reason "$path -> core"
;;
esac
fi
case "$path" in
package.json|src/main.ts|src/main-entry.ts|src/preload.ts)
add_flag "broad-impact"
add_reason "$path -> broad-impact"
;;
esac
done
if [[ ${#LANES[@]} -eq 0 ]]; then
add_lane "core"
add_reason "no lane-specific matches -> default to core"
fi
for lane in "${LANES[@]}"; do
printf 'lane:%s\n' "$lane"
done
for flag in "${FLAGS[@]}"; do
printf 'flag:%s\n' "$flag"
done
for reason in "${REASONS[@]}"; do
printf 'reason:%s\n' "$reason"
done
@@ -1,537 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
usage() {
cat <<'EOF'
Usage: verify_subminer_change.sh [options] [path ...]
Options:
--lane <name> Force a verification lane. Repeatable.
--artifact-dir <dir> Use an explicit artifact directory.
--allow-real-runtime Allow explicit real-runtime execution.
--allow-real-gui Deprecated alias for --allow-real-runtime.
--dry-run Record planned steps without executing commands.
--help Show this help text.
If no lanes are supplied, the script classifies the provided paths. If no paths are
provided, it classifies the current local git changes.
Authoritative real-runtime verification should be requested with explicit path
arguments instead of relying on inferred local git changes.
EOF
}
timestamp() {
date +%Y%m%d-%H%M%S
}
timestamp_iso() {
date -u +%Y-%m-%dT%H:%M:%SZ
}
generate_session_id() {
local tmp_dir
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/subminer-verify-$(timestamp)-XXXXXX")
basename "$tmp_dir"
rmdir "$tmp_dir"
}
has_item() {
local needle=$1
shift || true
local item
for item in "$@"; do
if [[ "$item" == "$needle" ]]; then
return 0
fi
done
return 1
}
normalize_lane_name() {
case "$1" in
real-gui)
printf '%s' "real-runtime"
;;
*)
printf '%s' "$1"
;;
esac
}
add_lane() {
local lane
lane=$(normalize_lane_name "$1")
if ! has_item "$lane" "${SELECTED_LANES[@]:-}"; then
SELECTED_LANES+=("$lane")
fi
}
add_blocker() {
BLOCKERS+=("$1")
BLOCKED=1
}
validate_artifact_dir() {
local candidate=$1
if [[ ! "$candidate" =~ ^[A-Za-z0-9._/@:+-]+$ ]]; then
echo "Invalid characters in --artifact-dir path" >&2
exit 2
fi
}
append_step_record() {
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >>"$STEPS_TSV"
}
record_env() {
{
printf 'repo_root=%s\n' "$REPO_ROOT"
printf 'session_id=%s\n' "$SESSION_ID"
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
printf 'path_selection_mode=%s\n' "$PATH_SELECTION_MODE"
printf 'dry_run=%s\n' "$DRY_RUN"
printf 'allow_real_runtime=%s\n' "$ALLOW_REAL_RUNTIME"
printf 'session_home=%s\n' "$SESSION_HOME"
printf 'session_xdg_config_home=%s\n' "$SESSION_XDG_CONFIG_HOME"
printf 'session_mpv_dir=%s\n' "$SESSION_MPV_DIR"
printf 'session_logs_dir=%s\n' "$SESSION_LOGS_DIR"
printf 'session_mpv_log=%s\n' "$SESSION_MPV_LOG"
printf 'pwd=%s\n' "$(pwd)"
git rev-parse --short HEAD 2>/dev/null | sed 's/^/git_head=/' || true
git status --short 2>/dev/null || true
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
printf 'requested_paths=\n'
printf ' %s\n' "${PATH_ARGS[@]}"
fi
} >"$ARTIFACT_DIR/env.txt"
}
run_step() {
local lane=$1
local name=$2
local command=$3
local note=${4:-}
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
local slug=${name//[^a-zA-Z0-9_-]/-}
local step_slug="${lane_slug}--${slug}"
local stdout_rel="steps/${step_slug}.stdout.log"
local stderr_rel="steps/${step_slug}.stderr.log"
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
local status exit_code
COMMANDS_RUN+=("$command")
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${step_slug}.command.txt"
if [[ "$DRY_RUN" == "1" ]]; then
printf '[dry-run] %s\n' "$command" >"$stdout_path"
: >"$stderr_path"
status="dry-run"
exit_code=0
else
if HOME="$SESSION_HOME" \
XDG_CONFIG_HOME="$SESSION_XDG_CONFIG_HOME" \
SUBMINER_SESSION_LOGS_DIR="$SESSION_LOGS_DIR" \
SUBMINER_SESSION_MPV_LOG="$SESSION_MPV_LOG" \
bash -c "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
status="passed"
exit_code=0
EXECUTED_REAL_STEPS=1
else
exit_code=$?
status="failed"
FAILED=1
fi
fi
append_step_record "$lane" "$name" "$status" "$exit_code" "$command" "$stdout_rel" "$stderr_rel" "$note"
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
if [[ "$status" == "failed" ]]; then
FAILURE_STEP="$name"
FAILURE_COMMAND="$command"
FAILURE_STDOUT="$stdout_rel"
FAILURE_STDERR="$stderr_rel"
return "$exit_code"
fi
}
record_nonpassing_step() {
local lane=$1
local name=$2
local status=$3
local note=$4
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
local slug=${name//[^a-zA-Z0-9_-]/-}
local step_slug="${lane_slug}--${slug}"
local stdout_rel="steps/${step_slug}.stdout.log"
local stderr_rel="steps/${step_slug}.stderr.log"
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
: >"$ARTIFACT_DIR/$stderr_rel"
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
}
record_skipped_step() {
record_nonpassing_step "$1" "$2" "skipped" "$3"
}
record_blocked_step() {
add_blocker "$3"
record_nonpassing_step "$1" "$2" "blocked" "$3"
}
record_failed_step() {
FAILED=1
FAILURE_STEP=$2
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
local lane_slug=${1//[^a-zA-Z0-9_-]/-}
local step_slug=${2//[^a-zA-Z0-9_-]/-}
FAILURE_STDOUT="steps/${lane_slug}--${step_slug}.stdout.log"
FAILURE_STDERR="steps/${lane_slug}--${step_slug}.stderr.log"
add_blocker "$3"
record_nonpassing_step "$1" "$2" "failed" "$3"
}
find_real_runtime_helper() {
local candidate
for candidate in \
"$SCRIPT_DIR/run_real_runtime_smoke.sh" \
"$SCRIPT_DIR/run_real_mpv_smoke.sh"; do
if [[ -x "$candidate" ]]; then
printf '%s' "$candidate"
return 0
fi
done
return 1
}
acquire_real_runtime_lease() {
local lease_root="$REPO_ROOT/.tmp/skill-verification/locks"
local lease_dir="$lease_root/exclusive-real-runtime"
mkdir -p "$lease_root"
if mkdir "$lease_dir" 2>/dev/null; then
REAL_RUNTIME_LEASE_DIR="$lease_dir"
printf '%s\n' "$SESSION_ID" >"$lease_dir/session_id"
return 0
fi
local owner=""
if [[ -f "$lease_dir/session_id" ]]; then
owner=$(cat "$lease_dir/session_id")
fi
REAL_RUNTIME_LEASE_ERROR="real-runtime lease already held${owner:+ by $owner}"
return 1
}
release_real_runtime_lease() {
if [[ -n "$REAL_RUNTIME_LEASE_DIR" && -d "$REAL_RUNTIME_LEASE_DIR" ]]; then
if [[ -f "$REAL_RUNTIME_LEASE_DIR/session_id" ]]; then
local owner
owner=$(cat "$REAL_RUNTIME_LEASE_DIR/session_id")
if [[ "$owner" != "$SESSION_ID" ]]; then
return 0
fi
fi
rm -rf "$REAL_RUNTIME_LEASE_DIR"
fi
}
compute_final_status() {
if [[ "$FAILED" == "1" ]]; then
FINAL_STATUS="failed"
elif [[ "$BLOCKED" == "1" ]]; then
FINAL_STATUS="blocked"
elif [[ "$EXECUTED_REAL_STEPS" == "1" ]]; then
FINAL_STATUS="passed"
else
FINAL_STATUS="skipped"
fi
}
write_summary_files() {
local lane_lines
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
# bash 3.2 raises "unbound variable" under set -u when expanding an empty
# array, so guard on length (matching the idiom used elsewhere here).
if [[ ${#BLOCKERS[@]} -gt 0 ]]; then
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
else
: >"$ARTIFACT_DIR/blockers.txt"
fi
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
else
: >"$ARTIFACT_DIR/requested-paths.txt"
fi
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
SESSION_ID_ENV="$SESSION_ID" \
FINAL_STATUS_ENV="$FINAL_STATUS" \
PATH_SELECTION_MODE_ENV="$PATH_SELECTION_MODE" \
ALLOW_REAL_RUNTIME_ENV="$ALLOW_REAL_RUNTIME" \
SESSION_HOME_ENV="$SESSION_HOME" \
SESSION_XDG_CONFIG_HOME_ENV="$SESSION_XDG_CONFIG_HOME" \
SESSION_MPV_DIR_ENV="$SESSION_MPV_DIR" \
SESSION_LOGS_DIR_ENV="$SESSION_LOGS_DIR" \
SESSION_MPV_LOG_ENV="$SESSION_MPV_LOG" \
STARTED_AT_ENV="$STARTED_AT" \
FINISHED_AT_ENV="$FINISHED_AT" \
FAILED_ENV="$FAILED" \
FAILURE_COMMAND_ENV="${FAILURE_COMMAND:-}" \
FAILURE_STDOUT_ENV="${FAILURE_STDOUT:-}" \
FAILURE_STDERR_ENV="${FAILURE_STDERR:-}" \
bun -e '
const fs = require("fs");
const path = require("path");
const lines = fs
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "steps.tsv"), "utf8")
.trim()
.split("\n")
.filter(Boolean)
.slice(1)
.map((line) => {
const [lane, name, status, exitCode, command, stdout, stderr, note] = line.split("\t");
return { lane, name, status, exitCode: Number(exitCode), command, stdout, stderr, note };
});
const payload = {
sessionId: process.env.SESSION_ID_ENV,
startedAt: process.env.STARTED_AT_ENV,
finishedAt: process.env.FINISHED_AT_ENV,
status: process.env.FINAL_STATUS_ENV,
pathSelectionMode: process.env.PATH_SELECTION_MODE_ENV,
allowRealRuntime: process.env.ALLOW_REAL_RUNTIME_ENV === "1",
sessionHome: process.env.SESSION_HOME_ENV,
sessionXdgConfigHome: process.env.SESSION_XDG_CONFIG_HOME_ENV,
sessionMpvDir: process.env.SESSION_MPV_DIR_ENV,
sessionLogsDir: process.env.SESSION_LOGS_DIR_ENV,
sessionMpvLog: process.env.SESSION_MPV_LOG_ENV,
failed: process.env.FAILED_ENV === "1",
failure: process.env.FAILURE_COMMAND_ENV
? {
command: process.env.FAILURE_COMMAND_ENV,
stdout: process.env.FAILURE_STDOUT_ENV,
stderr: process.env.FAILURE_STDERR_ENV,
}
: null,
blockers: fs
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "blockers.txt"), "utf8")
.split("\n")
.filter(Boolean),
lanes: fs
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "lanes.txt"), "utf8")
.split("\n")
.filter(Boolean),
requestedPaths: fs
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "requested-paths.txt"), "utf8")
.split("\n")
.filter(Boolean),
steps: lines,
};
fs.writeFileSync(
path.join(process.env.ARTIFACT_DIR_ENV, "summary.json"),
JSON.stringify(payload, null, 2) + "\n",
);
const summaryLines = [
`status: ${payload.status}`,
`session: ${payload.sessionId}`,
`artifacts: ${process.env.ARTIFACT_DIR_ENV}`,
`lanes: ${payload.lanes.join(", ") || "(none)"}`,
];
if (payload.requestedPaths.length > 0) {
summaryLines.push("requested paths:");
for (const entry of payload.requestedPaths) {
summaryLines.push(`- ${entry}`);
}
}
if (payload.failure) {
summaryLines.push(`failure command: ${payload.failure.command}`);
summaryLines.push(`failure stdout: ${payload.failure.stdout}`);
summaryLines.push(`failure stderr: ${payload.failure.stderr}`);
}
if (payload.blockers.length > 0) {
summaryLines.push("blockers:");
for (const blocker of payload.blockers) {
summaryLines.push(`- ${blocker}`);
}
}
summaryLines.push("steps:");
for (const step of payload.steps) {
summaryLines.push(`- ${step.lane}/${step.name}: ${step.status}`);
}
fs.writeFileSync(
path.join(process.env.ARTIFACT_DIR_ENV, "summary.txt"),
summaryLines.join("\n") + "\n",
);
'
}
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
SKILL_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
declare -a PATH_ARGS=()
declare -a SELECTED_LANES=()
declare -a COMMANDS_RUN=()
declare -a BLOCKERS=()
ALLOW_REAL_RUNTIME=0
DRY_RUN=0
FAILED=0
BLOCKED=0
EXECUTED_REAL_STEPS=0
FAILURE_STEP=""
FAILURE_COMMAND=""
FAILURE_STDOUT=""
FAILURE_STDERR=""
REAL_RUNTIME_LEASE_DIR=""
REAL_RUNTIME_LEASE_ERROR=""
PATH_SELECTION_MODE="auto"
trap 'release_real_runtime_lease' EXIT
while [[ $# -gt 0 ]]; do
case "$1" in
--lane)
shift
[[ $# -gt 0 ]] || {
echo "Missing value for --lane" >&2
exit 2
}
add_lane "$1"
PATH_SELECTION_MODE="explicit-lanes"
;;
--artifact-dir)
shift
[[ $# -gt 0 ]] || {
echo "Missing value for --artifact-dir" >&2
exit 2
}
ARTIFACT_DIR=$1
;;
--allow-real-runtime|--allow-real-gui)
ALLOW_REAL_RUNTIME=1
;;
--dry-run)
DRY_RUN=1
;;
--help|-h)
usage
exit 0
;;
*)
PATH_ARGS+=("$1")
;;
esac
shift || true
done
if [[ -z "${ARTIFACT_DIR:-}" ]]; then
SESSION_ID=$(generate_session_id)
ARTIFACT_DIR="$REPO_ROOT/.tmp/skill-verification/$SESSION_ID"
else
validate_artifact_dir "$ARTIFACT_DIR"
SESSION_ID=$(basename "$ARTIFACT_DIR")
fi
mkdir -p "$ARTIFACT_DIR/steps"
STEPS_TSV="$ARTIFACT_DIR/steps.tsv"
printf 'lane\tstep\tstatus\texit_code\tcommand\tstdout\tstderr\tnote\n' >"$STEPS_TSV"
STARTED_AT=$(timestamp_iso)
SESSION_HOME="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/home"
SESSION_XDG_CONFIG_HOME="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/xdg-config"
SESSION_MPV_DIR="$SESSION_XDG_CONFIG_HOME/mpv"
SESSION_LOGS_DIR="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/logs"
SESSION_MPV_LOG="$SESSION_LOGS_DIR/mpv.log"
mkdir -p "$SESSION_HOME" "$SESSION_MPV_DIR" "$SESSION_LOGS_DIR"
CLASSIFIER_OUTPUT="$ARTIFACT_DIR/classification.txt"
if [[ ${#SELECTED_LANES[@]} -eq 0 ]]; then
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
PATH_SELECTION_MODE="explicit-paths"
fi
if "$SCRIPT_DIR/classify_subminer_diff.sh" "${PATH_ARGS[@]}" >"$CLASSIFIER_OUTPUT"; then
while IFS= read -r line; do
case "$line" in
lane:*)
add_lane "${line#lane:}"
;;
esac
done <"$CLASSIFIER_OUTPUT"
else
record_failed_step "meta" "classify" "classification failed"
fi
else
: >"$CLASSIFIER_OUTPUT"
fi
record_env
if [[ ${#SELECTED_LANES[@]} -eq 0 ]]; then
add_lane "core"
fi
for lane in "${SELECTED_LANES[@]}"; do
case "$lane" in
docs)
run_step "$lane" "docs-kb" "bun run test:docs:kb" || break
;;
config)
run_step "$lane" "config" "bun run test:config" || break
;;
stats)
run_step "$lane" "stats" "bun run test:stats" || break
;;
core)
run_step "$lane" "typecheck" "bun run typecheck" || break
run_step "$lane" "fast-tests" "bun run test:fast" || break
;;
launcher-plugin)
run_step "$lane" "launcher" "bun run test:launcher" || break
run_step "$lane" "plugin-src" "bun run test:plugin:src" || break
;;
runtime-compat)
run_step "$lane" "runtime-compat" "bun run test:runtime:compat" || break
;;
real-runtime)
if [[ "$ALLOW_REAL_RUNTIME" != "1" ]]; then
record_blocked_step "$lane" "real-runtime" "real-runtime requested without --allow-real-runtime"
continue
fi
if ! acquire_real_runtime_lease; then
record_blocked_step "$lane" "real-runtime-lease" "$REAL_RUNTIME_LEASE_ERROR"
continue
fi
helper=$(find_real_runtime_helper || true)
if [[ -z "${helper:-}" ]]; then
record_blocked_step "$lane" "real-runtime-helper" "no real-runtime helper script available in $SCRIPT_DIR"
continue
fi
run_step "$lane" "real-runtime" "\"$helper\" \"$SESSION_ID\" \"$ARTIFACT_DIR\"" || break
;;
*)
record_blocked_step "$lane" "unknown-lane" "unknown lane: $lane"
;;
esac
done
release_real_runtime_lease
FINISHED_AT=$(timestamp_iso)
compute_final_status
write_summary_files
printf 'summary:%s\n' "$ARTIFACT_DIR/summary.txt"
cat "$ARTIFACT_DIR/summary.txt"
@@ -1,118 +0,0 @@
---
name: 'subminer-scrum-master'
description: 'Use in the SubMiner repo when a request should be turned into planned work and driven through execution. Records a plan, dispatches one or more subagents when useful, and requires verification before handoff.'
---
# SubMiner Scrum Master
Canonical source: this plugin path.
Own workflow, not code by default.
Use this skill when the user gives a feature request, bug report, issue, refactor, or implementation ask and the agent should manage intake, planning, worker dispatch, and verification through completion.
## Core Rules
1. Keep the process light for questions, obvious mechanical edits, and tiny isolated changes.
2. Record a plan before dispatching coding work.
3. Split multi-part work into clear phases and ownership areas.
4. Dispatch conservatively. Parallelize only disjoint write scopes.
5. Require verification before handoff, typically via `subminer-change-verification`.
6. Report dispatched workers, verification, blockers, and remaining risks.
## Intake Workflow
1. Parse the request.
Classify it as question, mechanical edit, bugfix, feature, refactor, investigation, or follow-up.
2. Write a short working plan in-thread when the work is nontrivial.
3. Choose execution mode:
- no subagents for trivial work
- one worker for focused work
- parallel workers only for disjoint scopes
4. Run verification before handoff.
## Dispatch Rules
The scrum master orchestrates. Workers implement.
- Do not become the default implementer unless delegation is unnecessary.
- Do not parallelize overlapping files or tightly coupled runtime work.
- Give every worker explicit ownership of files/modules.
- Tell every worker other agents may be active and they must not revert unrelated edits.
- Require each worker to report:
- changed files
- tests run
- blockers
Use worker agents for implementation and explorer agents only for bounded codebase questions.
## Verification
Every nontrivial code task gets verification.
Preferred flow:
1. use `subminer-change-verification`
2. start with the cheapest sufficient lane
3. escalate only when needed
4. if worker verification is sufficient, accept it or run one final consolidating pass
Never hand off nontrivial work without stating what was verified and what was skipped.
## Pre-Handoff Policy Checks
Before handoff, always ask and answer both questions explicitly:
1. Docs update required?
2. Changelog fragment required?
Rules:
- Do not assume silence implies "no."
- If the answer is yes, complete the update or report the blocker.
- Include final yes/no answers in the handoff summary even when both answers are "no."
## Failure / Scope Handling
- If a worker hits ambiguity, pause and ask the user.
- If verification fails, either:
- send the worker back with exact failure context, or
- fix it directly if it is tiny and clearly in scope
- If new scope appears, pause and re-plan before silently expanding work.
## Representative Flows
### Trivial work
- keep a short plan
- implement directly or with one worker if helpful
- run targeted verification
- report outcome concisely
### Focused implementation
- record plan
- dispatch one worker
- integrate
- verify
- report outcome
### Multi-part execution
- define distinct deliverables/phases
- record sequencing in the plan
- dispatch workers only where scopes are disjoint
- integrate
- run consolidated verification
- report outcome
## Output Expectations
At the end, report:
- which workers were dispatched and what they owned
- what verification ran
- explicit answers to:
- docs update required?
- changelog fragment required?
- blockers, skips, and risks
-67
View File
@@ -1,67 +0,0 @@
## Highlights
### Added
- **Anki Maturity Known-Word Highlighting**
- Subtitle words you already know can now be color-coded by their Anki card maturity (new, learning, young, mature), like asbplayer's known-word coloring.
- Enable it with `ankiConnect.knownWords.maturityEnabled` (or toggle it mid-session); tier colors and the "mature" day threshold are configurable, and the in-session help legend shows the active colors.
- **Cross-Machine Sync for Stats & Watch History**
- Sync immersion stats and watch history between machines over SSH from a new Sync window (tray menu → Sync Stats & History, or `subminer sync --ui`) or the CLI (`subminer sync <host>`).
- Save multiple devices with per-host sync direction, run one-click syncs with live progress, and take manual database snapshots for backup or transfer.
- Windows remotes are supported over OpenSSH, and hosts can auto-sync in the background on a schedule, even during playback.
- **History Menu After Playback**
- After a watch-history episode ends (or mpv closes), the fzf/rofi launcher now offers to play the previous or next episode, rewatch, pick another episode, or quit, right from where you left off. Previous/Next continue across season folders.
- **Delete Entire Library Titles from Stats**
- The stats Library detail view now has a "Delete Entry" action that removes a whole title in one step, episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts, instead of clearing it episode by episode.
- Delete progress (sessions, episodes, or whole titles) now shows app-wide with a progress bar and status toast visible from any tab or window.
- **TsukiHime English Subtitle Downloads**
- Download subtitles for the currently playing video directly from TsukiHime, with Japanese loaded as the primary track and your configured secondary language alongside it.
### Changed
- **Configurable Clipboard-Video Shortcut**
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of fixed.
### Fixed
- **AniList Season Matching**
- Season 2+ episodes now resolve to the correct AniList entry instead of silently falling back to season 1. SubMiner follows AniList's sequel relations to find the right season, and cover art and watch progress now use the same season-aware match.
- If a season still can't be found, SubMiner no longer force-writes progress or a cover to the season 1 entry, it skips the update and points you to a manual AniList override, which now fixes the character dictionary and watch progress together.
- Manual overrides now stay applied consistently across every episode in a season folder, even when filenames guess differently episode to episode.
- **Subtitle Highlighting Accuracy**
- Fixed several known-word/annotation edge cases: part-of-speech exclusions now apply consistently to merged quote-particle tokens, annotations for rarer kanji are preserved, katakana punctuation is no longer mistaken for plain kana, and a specific noun-tagging case no longer loses its known+1 highlight.
- **AnkiConnect Proxy Port Conflicts**
- Fixed a crash on video startup when another process already held the configured AnkiConnect proxy port; you'll now get a notification explaining how to resolve it instead.
- **AppImage Crash Notification on Quit**
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
- **Startup Playback Pause Timing**
- Fixed playback occasionally resuming a couple seconds before subtitle tokenization actually finished warming up, most noticeable when resuming mid-episode or when a subtitle appears in the first two seconds.
- **Stats Library Cover After Relinking**
- Fixed the stats Library grid showing a stale cover image after relinking a title to a different AniList entry.
- **Faster Stats Deletes and Vocabulary Tab**
- Deleting sessions, episodes, and titles from stats is now dramatically faster and no longer stalls playback while it runs; the Vocabulary tab also loads much faster.
- The first launch after updating runs a one-time database migration (a few seconds, database grows about 20%); no action needed.
- **Settings Validation and Stats Server Hardening**
- Invalid AnkiConnect settings now fall back safely with a warning instead of silently breaking, and the stats server is hardened against malformed requests, stalled AniList searches, and other edge cases that could previously crash it.
- **Rofi Prompt Spacing**
- Fixed rofi menu prompts running into the search placeholder text with no space between them.
## What's Changed
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
- Anki maturity-based known-word highlighting by @ksyasuda in #172
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
## Installation
See the README and docs/installation guide for full setup steps.
## Assets
- Linux: `SubMiner.AppImage`
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
+1
View File
@@ -19,6 +19,7 @@ const requiredDocs = [
'docs/knowledge-base/catalog.md',
'docs/knowledge-base/quality.md',
'docs/workflow/README.md',
'docs/workflow/agent-skills.md',
'docs/workflow/planning.md',
'docs/workflow/verification.md',
] as const;
+21 -2
View File
@@ -3,14 +3,33 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
function buildScript(name: string): string {
const packageJson = JSON.parse(
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
) as { scripts: Record<string, string> };
const command = packageJson.scripts['build:syncui'] ?? '';
return packageJson.scripts[name] ?? '';
}
test('build:syncui bundles the sandboxed preload and keeps Electron external', () => {
const command = buildScript('build:syncui');
assert.match(command, /src\/preload-syncui\.ts/);
assert.match(command, /--bundle/);
assert.match(command, /--external:electron/);
assert.match(command, /--outfile=dist\/preload-syncui\.js/);
});
test('build:animeui bundles the sandboxed preload and keeps Electron external', () => {
const command = buildScript('build:animeui');
// The preload imports IPC_CHANNELS, so it must be bundled rather than
// emitted by plain tsc with a relative runtime require.
assert.match(command, /src\/preload-animeui\.ts/);
assert.match(command, /--bundle/);
assert.match(command, /--external:electron/);
assert.match(command, /--outfile=dist\/preload-animeui\.js/);
});
test('build:animeui runs as part of the top-level build', () => {
assert.match(buildScript('build'), /bun run build:animeui/);
});
+15 -2
View File
@@ -11,6 +11,8 @@ const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
const syncUiOutputDir = path.join(repoRoot, 'dist', 'syncui');
const animeUiSourceDir = path.join(repoRoot, 'src', 'animeui');
const animeUiOutputDir = path.join(repoRoot, 'dist', 'animeui');
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
@@ -25,9 +27,11 @@ function copyFile(sourcePath, outputPath) {
fs.copyFileSync(sourcePath, outputPath);
}
function copyAssets(sourceDir, outputDir, label) {
function copyAssets(sourceDir, outputDir, label, stylesheets = ['style.css']) {
copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html'));
copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css'));
for (const stylesheet of stylesheets) {
copyFile(path.join(sourceDir, stylesheet), path.join(outputDir, stylesheet));
}
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
recursive: true,
force: true,
@@ -47,6 +51,14 @@ function copySyncUiAssets() {
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
}
function copyAnimeUiAssets() {
copyAssets(animeUiSourceDir, animeUiOutputDir, 'animeui', [
'style.css',
'detail.css',
'panels.css',
]);
}
function fallbackToMacosSource() {
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
@@ -90,6 +102,7 @@ function main() {
copyRendererAssets();
copySettingsAssets();
copySyncUiAssets();
copyAnimeUiAssets();
buildMacosHelper();
}
+17 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { existsSync, readFileSync } from 'node:fs';
import test from 'node:test';
const source = readFileSync('scripts/prepare-build-assets.mjs', 'utf8');
@@ -18,3 +18,19 @@ test('macOS helper build creates dist scripts directory before swiftc output', (
'buildMacosHelper must create dist/scripts before swiftc writes the helper binary',
);
});
test('anime UI stylesheet files exist and are all staged', () => {
const html = readFileSync('src/animeui/index.html', 'utf8');
const stylesheets = [...html.matchAll(/<link rel="stylesheet" href="\.\/(.+?\.css)"/g)].map(
(match) => match[1],
);
assert.deepEqual(stylesheets, ['style.css', 'detail.css', 'panels.css']);
for (const stylesheet of stylesheets) {
assert.equal(existsSync(`src/animeui/${stylesheet}`), true, `${stylesheet} must exist`);
}
assert.match(
source,
/copyAssets\(animeUiSourceDir, animeUiOutputDir, 'animeui', \[\s*'style\.css',\s*'detail\.css',\s*'panels\.css',?\s*\]\)/,
);
});
@@ -1,202 +0,0 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import test from 'node:test';
const repoRoot = process.cwd();
const classifyScript = path.join(
repoRoot,
'.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh',
);
const verifyScript = path.join(
repoRoot,
'.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh',
);
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-change-verification-test-'));
try {
return fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function runBash(args: string[]) {
return spawnSync('bash', args, {
cwd: repoRoot,
env: process.env,
encoding: 'utf8',
});
}
function parseArtifactDir(stdout: string): string {
const match = stdout.match(/^artifacts: (.+)$/m);
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
return match[1] ?? '';
}
function readSummaryJson(artifactDir: string) {
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
sessionId: string;
status: string;
lanes: string[];
blockers?: string[];
artifactDir: string;
pathSelectionMode?: string;
steps: Array<{
lane: string;
name: string;
stdout: string;
stderr: string;
note: string;
}>;
};
}
test('classifier marks launcher and plugin paths as real-runtime candidates', () => {
const result = runBash([classifyScript, 'launcher/mpv.ts', 'plugin/subminer/process.lua']);
assert.equal(result.status, 0, result.stderr || result.stdout);
assert.match(result.stdout, /^lane:launcher-plugin$/m);
assert.match(result.stdout, /^flag:real-runtime-candidate$/m);
assert.doesNotMatch(result.stdout, /real-gui-candidate/);
});
test('verifier blocks requested real-runtime lane when runtime execution is not allowed', () => {
withTempDir((root) => {
const artifactDir = path.join(root, 'artifacts');
const result = runBash([
verifyScript,
'--dry-run',
'--artifact-dir',
artifactDir,
'--lane',
'real-runtime',
'launcher/mpv.ts',
]);
assert.equal(result.status, 0, result.stdout);
const summary = readSummaryJson(artifactDir);
assert.equal(summary.status, 'blocked');
assert.deepEqual(summary.lanes, ['real-runtime']);
assert.ok(summary.sessionId.length > 0);
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
assert.equal(fs.existsSync(path.join(artifactDir, 'summary.json')), true);
});
});
test('verifier fails closed for unknown lanes', () => {
withTempDir((root) => {
const artifactDir = path.join(root, 'artifacts');
const result = runBash([
verifyScript,
'--dry-run',
'--artifact-dir',
artifactDir,
'--lane',
'not-a-lane',
'src/main.ts',
]);
assert.equal(result.status, 0, result.stdout);
const summary = readSummaryJson(artifactDir);
assert.equal(summary.status, 'blocked');
assert.deepEqual(summary.lanes, ['not-a-lane']);
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane')));
});
});
test('verifier keeps non-passing step artifacts distinct across lanes', () => {
withTempDir((root) => {
const artifactDir = path.join(root, 'artifacts');
const result = runBash([
verifyScript,
'--dry-run',
'--artifact-dir',
artifactDir,
'--lane',
'docs',
'--lane',
'not-a-lane',
'src/main.ts',
]);
assert.equal(result.status, 0, result.stdout);
const summary = readSummaryJson(artifactDir);
const docsStep = summary.steps.find((step) => step.lane === 'docs' && step.name === 'docs-kb');
const unknownStep = summary.steps.find(
(step) => step.lane === 'not-a-lane' && step.name === 'unknown-lane',
);
assert.ok(docsStep);
assert.ok(unknownStep);
assert.notEqual(docsStep?.stdout, unknownStep?.stdout);
assert.equal(fs.existsSync(path.join(artifactDir, docsStep!.stdout)), true);
assert.equal(fs.existsSync(path.join(artifactDir, unknownStep!.stdout)), true);
});
});
test('verifier records the real-runtime lease blocker once', () => {
withTempDir((root) => {
const artifactDir = path.join(root, 'artifacts');
const leaseDir = path.join(
repoRoot,
'.tmp',
'skill-verification',
'locks',
'exclusive-real-runtime',
);
fs.mkdirSync(leaseDir, { recursive: true });
fs.writeFileSync(path.join(leaseDir, 'session_id'), 'other-session');
try {
const result = runBash([
verifyScript,
'--dry-run',
'--artifact-dir',
artifactDir,
'--allow-real-runtime',
'--lane',
'real-runtime',
'launcher/mpv.ts',
]);
assert.equal(result.status, 0, result.stdout);
const summary = readSummaryJson(artifactDir);
assert.deepEqual(summary.blockers, ['real-runtime lease already held by other-session']);
} finally {
fs.rmSync(leaseDir, { recursive: true, force: true });
}
});
});
test('verifier allocates unique session ids and artifact roots by default', () => {
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
assert.equal(first.status, 0, first.stderr || first.stdout);
assert.equal(second.status, 0, second.stderr || second.stdout);
const firstArtifactDir = parseArtifactDir(first.stdout);
const secondArtifactDir = parseArtifactDir(second.stdout);
try {
const firstSummary = readSummaryJson(firstArtifactDir);
const secondSummary = readSummaryJson(secondArtifactDir);
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
assert.notEqual(firstArtifactDir, secondArtifactDir);
assert.equal(firstSummary.pathSelectionMode, 'explicit-lanes');
assert.equal(secondSummary.pathSelectionMode, 'explicit-lanes');
} finally {
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
fs.rmSync(secondArtifactDir, { recursive: true, force: true });
}
});
+180
View File
@@ -0,0 +1,180 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { AnimeBridgeClient, BridgeExtensionError } from './bridge-client';
import { BRIDGE_CONTEXT_KEY } from './types';
const EXTENSION_ID = 'a'.repeat(64);
const APK_BASE64 = 'QVBLLUJZVEVT';
const source = {
fingerprint: 'sha-1',
loadApkBase64: async () => APK_BASE64,
sourceId: 'source-1',
};
interface Recorded {
url: string;
body: Record<string, unknown>;
}
function stubFetch(responder: (call: Recorded, index: number) => Response): {
fetchImpl: typeof fetch;
calls: Recorded[];
} {
const calls: Recorded[] = [];
const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => {
const call: Recorded = {
url: String(input),
body: init?.body ? (JSON.parse(String(init.body)) as Record<string, unknown>) : {},
};
calls.push(call);
return responder(call, calls.length - 1);
}) as typeof fetch;
return { fetchImpl, calls };
}
function jsonResponse(body: unknown, extensionId?: string): Response {
const headers = new Headers({ 'Content-Type': 'application/json' });
if (extensionId) headers.set('x-mangatan-extension-id', extensionId);
return new Response(JSON.stringify(body), { status: 200, headers });
}
test('isReady requires every capability the client depends on', async () => {
const ready = new AnimeBridgeClient({
baseUrl: 'http://127.0.0.1:9',
fetchImpl: stubFetch(() =>
jsonResponse({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
).fetchImpl,
});
assert.equal(await ready.isReady(), true);
const partial = new AnimeBridgeClient({
baseUrl: 'http://127.0.0.1:9',
fetchImpl: stubFetch(() => jsonResponse({ mangatanMihonBridge: 1, sourceFactory: true }))
.fetchImpl,
});
assert.equal(await partial.isReady(), false);
});
test('isReady caps the probe at the deadline the caller passes', async () => {
// A bridge that accepts the socket and then stalls: only the abort ends it.
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
await new Promise((resolve) => init?.signal?.addEventListener('abort', resolve));
throw new Error('aborted');
}) as typeof fetch;
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
const started = Date.now();
assert.equal(await client.isReady(50), false);
// Well under the 5s default, so the per-call deadline is what applied.
assert.ok(Date.now() - started < 1000, 'probe outlived the caller deadline');
});
test('isReady reports false instead of throwing when the bridge is down', async () => {
const client = new AnimeBridgeClient({
baseUrl: 'http://127.0.0.1:9',
fetchImpl: (async () => {
throw new Error('ECONNREFUSED');
}) as typeof fetch,
});
assert.equal(await client.isReady(), false);
});
test('getVideoList posts the APK and episode url with a bridge context preference', async () => {
const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ videoUrl: 'http://x/video/t' }]));
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9/', fetchImpl });
const videos = await client.getVideoList(source, 'https://origin.example/ep/1');
assert.equal(calls[0]?.url, 'http://127.0.0.1:9/dalvik');
assert.equal(calls[0]?.body.method, 'getVideoList');
assert.deepEqual(calls[0]?.body.episodeData, { url: 'https://origin.example/ep/1' });
assert.equal(calls[0]?.body.data, APK_BASE64);
assert.deepEqual(calls[0]?.body.preferences, [{ key: BRIDGE_CONTEXT_KEY, sourceId: 'source-1' }]);
assert.equal(videos.length, 1);
});
test('a cached extension id replaces the APK upload on later calls', async () => {
const { fetchImpl, calls } = stubFetch(() => jsonResponse([], EXTENSION_ID));
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
await client.getVideoList(source, 'https://origin.example/ep/1');
await client.getVideoList(source, 'https://origin.example/ep/2');
assert.equal(calls[0]?.body.data, APK_BASE64);
assert.equal(calls[0]?.body.extensionId, undefined);
assert.equal(calls[1]?.body.data, undefined);
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
});
test('an upgraded APK re-uploads instead of reusing the previous extension id', async () => {
const { fetchImpl, calls } = stubFetch(() => jsonResponse([], EXTENSION_ID));
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
await client.getVideoList(source, 'https://origin.example/ep/1');
// Same source id, new build in the same file: the id cache must miss.
const upgraded = { ...source, fingerprint: 'sha-2', loadApkBase64: async () => 'TkVXLUFQSw==' };
await client.getVideoList(upgraded, 'https://origin.example/ep/2');
assert.equal(calls[1]?.body.extensionId, undefined);
assert.equal(calls[1]?.body.data, 'TkVXLUFQSw==');
});
test('a 409 re-uploads the APK once and succeeds', async () => {
const { fetchImpl, calls } = stubFetch((call, index) => {
if (index === 0) return jsonResponse([], EXTENSION_ID);
// Cache evicted: reject the id-only call, accept the re-upload.
if (call.body.extensionId !== undefined) return new Response('', { status: 409 });
return jsonResponse([{ videoUrl: 'http://x/video/t' }], EXTENSION_ID);
});
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
await client.getVideoList(source, 'https://origin.example/ep/1');
const videos = await client.getVideoList(source, 'https://origin.example/ep/2');
assert.equal(calls.length, 3);
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
assert.equal(calls[2]?.body.data, APK_BASE64);
assert.equal(videos.length, 1);
});
test('an error body on a 200 response raises BridgeExtensionError with the code', async () => {
const { fetchImpl } = stubFetch(() => jsonResponse({ error: 'Cloudflare challenge', code: 403 }));
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
await assert.rejects(
() => client.getVideoList(source, 'https://origin.example/ep/1'),
(error: unknown) => {
assert.ok(error instanceof BridgeExtensionError);
assert.equal(error.code, 403);
assert.match(error.message, /Cloudflare challenge/);
return true;
},
);
});
test('searchAnime sends a 1-based page and returns the page payload', async () => {
const { fetchImpl, calls } = stubFetch(() =>
jsonResponse({ animes: [{ title: 'Example' }], hasNextPage: true }),
);
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
const page = await client.searchAnime(source, 'example');
assert.equal(calls[0]?.body.method, 'getSearchAnime');
assert.equal(calls[0]?.body.page, 1);
assert.equal(calls[0]?.body.search, 'example');
assert.deepEqual(calls[0]?.body.filterList, []);
assert.equal(page.hasNextPage, true);
assert.equal(page.animes?.length, 1);
});
test('getEpisodeList wraps the anime url in animeData', async () => {
const { fetchImpl, calls } = stubFetch(() => jsonResponse([{ name: 'Episode 1', url: '/ep/1' }]));
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
const episodes = await client.getEpisodeList(source, 'https://origin.example/anime/1');
assert.equal(calls[0]?.body.method, 'getEpisodeList');
assert.deepEqual(calls[0]?.body.animeData, { url: 'https://origin.example/anime/1' });
assert.equal(episodes[0]?.name, 'Episode 1');
});
+245
View File
@@ -0,0 +1,245 @@
import { BRIDGE_CONTEXT_KEY } from './types';
import type {
BridgeAnime,
BridgeAnimePage,
BridgeCapabilities,
BridgeEpisode,
BridgePreference,
BridgeSourceDescriptor,
BridgeVideo,
} from './types';
const EXTENSION_ID_HEADER = 'x-mangatan-extension-id';
const EXTENSION_ID_PATTERN = /^[0-9a-f]{64}$/;
export interface BridgeSource {
/**
* Identity of the APK's contents. Keys the extension-id cache, so an upgraded
* APK is re-uploaded instead of reusing the previous build's id.
*/
fingerprint: string;
/**
* Reads and base64-encodes the APK. Called only when the bridge actually
* needs the bytes, so multi-megabyte payloads are not held on the heap.
*/
loadApkBase64: () => Promise<string>;
/** Selects one source inside a multi-source (SourceFactory) APK. */
sourceId?: string;
preferences?: BridgePreference[];
}
export interface BridgeClientOptions {
/** Loopback base URL of the running bridge, e.g. `http://127.0.0.1:53112`. */
baseUrl: string;
fetchImpl?: typeof fetch;
/**
* Per-request deadline. Node's `fetch` has none, so a sidecar that accepts
* the socket and then stalls would leave every call pending forever.
*/
requestTimeoutMs?: number;
}
/** Extension calls can be slow (a source may scrape several pages). */
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
/** The readiness probe is a local health check; it should answer at once. */
const CAPABILITIES_TIMEOUT_MS = 5_000;
/** The bridge reports extension failures as HTTP 200 with an error body. */
export class BridgeExtensionError extends Error {
readonly code?: number;
constructor(message: string, code?: number) {
super(message);
this.name = 'BridgeExtensionError';
this.code = code;
}
}
/**
* Client for the M-Extension-Server `/dalvik` RPC endpoint.
*
* The server caches uploaded APKs and returns a content hash, letting
* subsequent calls send that id instead of re-uploading megabytes of base64.
* A 409 means the cache was evicted, so the APK is resent once.
*/
export class AnimeBridgeClient {
private readonly baseUrl: string;
private readonly fetchImpl: typeof fetch;
private readonly requestTimeoutMs: number;
private readonly extensionIds = new Map<string, string>();
constructor(options: BridgeClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
this.fetchImpl = options.fetchImpl ?? fetch;
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
}
/**
* `timeoutMs` lets a caller with its own deadline (the readiness loop) cap the
* probe below the default, so a short readiness budget is actually honored.
*/
async getCapabilities(timeoutMs = CAPABILITIES_TIMEOUT_MS): Promise<BridgeCapabilities> {
const response = await this.fetchImpl(`${this.baseUrl}/capabilities`, {
signal: AbortSignal.timeout(Math.max(0, Math.min(timeoutMs, this.requestTimeoutMs))),
});
if (!response.ok) {
throw new Error(`Anime bridge capabilities check failed (${response.status}).`);
}
return (await response.json()) as BridgeCapabilities;
}
/** True once the bridge is up and reports the features this client needs. */
async isReady(timeoutMs?: number): Promise<boolean> {
try {
const capabilities = await this.getCapabilities(timeoutMs);
return (
capabilities.mangatanMihonBridge === 1 &&
capabilities.sourceFactory === true &&
capabilities.preferenceCallbacks === true
);
} catch {
return false;
}
}
async searchAnime(
source: BridgeSource,
query: string,
page = 1,
filterList: unknown[] = [],
): Promise<BridgeAnimePage> {
return this.call<BridgeAnimePage>(source, 'getSearchAnime', {
page,
search: query,
filterList,
});
}
/**
* List the sources an extension APK provides. A single APK may expose many
* (a SourceFactory), so this is how a package becomes selectable entries.
*/
async listAnimeSources(source: BridgeSource): Promise<BridgeSourceDescriptor[]> {
return this.call<BridgeSourceDescriptor[]>(source, 'sourcesAnime', {});
}
/** The extension's own settings schema, with current values. */
async getSourcePreferences(source: BridgeSource): Promise<BridgePreference[]> {
return this.call<BridgePreference[]>(source, 'preferencesAnime', {});
}
/**
* Commit a preference change. The whole array is sent back with the edited
* entry, and `changedPreferenceKey` tells the extension which one moved so it
* can react (the Jellyfin source logs in when the address or password lands).
* Returns the extension's refreshed schema.
*/
async setSourcePreference(
source: BridgeSource,
changedPreferenceKey: string,
): Promise<BridgePreference[]> {
return this.call<BridgePreference[]>(source, 'setPreferenceAnime', {}, changedPreferenceKey);
}
/** Full metadata for one anime: description, cover art, genres, status. */
async getAnimeDetails(source: BridgeSource, animeUrl: string): Promise<BridgeAnime> {
return this.call<BridgeAnime>(source, 'getDetailsAnime', {
animeData: { url: animeUrl },
});
}
async getPopularAnime(source: BridgeSource, page = 1): Promise<BridgeAnimePage> {
return this.call<BridgeAnimePage>(source, 'getPopularAnime', { page });
}
async getEpisodeList(source: BridgeSource, animeUrl: string): Promise<BridgeEpisode[]> {
return this.call<BridgeEpisode[]>(source, 'getEpisodeList', {
animeData: { url: animeUrl },
});
}
async getVideoList(source: BridgeSource, episodeUrl: string): Promise<BridgeVideo[]> {
return this.call<BridgeVideo[]>(source, 'getVideoList', {
episodeData: { url: episodeUrl },
});
}
private buildPreferences(
source: BridgeSource,
changedPreferenceKey?: string,
): BridgePreference[] {
const context: BridgePreference = { key: BRIDGE_CONTEXT_KEY };
if (source.sourceId !== undefined) context.sourceId = source.sourceId;
if (changedPreferenceKey !== undefined) context.changedPreferenceKey = changedPreferenceKey;
return [...(source.preferences ?? []), context];
}
private async call<T>(
source: BridgeSource,
method: string,
extras: Record<string, unknown>,
changedPreferenceKey?: string,
): Promise<T> {
// Keyed by APK contents, not by source id: an in-place upgrade keeps the
// same source id, and reusing its cached extension id would silently run
// the previous build (the bridge has no reason to answer 409).
const cacheKey = `${source.fingerprint}:${source.sourceId ?? ''}`;
const cachedId = this.extensionIds.get(cacheKey);
let response = await this.post(method, extras, source, cachedId, changedPreferenceKey);
if (response.status === 409 && cachedId !== undefined) {
// Server evicted the cached APK; upload it again.
this.extensionIds.delete(cacheKey);
response = await this.post(method, extras, source, undefined, changedPreferenceKey);
}
if (!response.ok) {
throw new Error(`Anime bridge ${method} failed (${response.status}).`);
}
const returnedId = response.headers.get(EXTENSION_ID_HEADER)?.trim();
if (returnedId && EXTENSION_ID_PATTERN.test(returnedId)) {
this.extensionIds.set(cacheKey, returnedId);
}
const body = (await response.json()) as T;
assertNoExtensionError(body, method);
return body;
}
private async post(
method: string,
extras: Record<string, unknown>,
source: BridgeSource,
extensionId: string | undefined,
changedPreferenceKey?: string,
): Promise<Response> {
const payload: Record<string, unknown> = {
method,
...extras,
preferences: this.buildPreferences(source, changedPreferenceKey),
...(extensionId === undefined ? { data: await source.loadApkBase64() } : { extensionId }),
};
return this.fetchImpl(`${this.baseUrl}/dalvik`, {
method: 'POST',
headers: {
'Content-Type': 'application/json; charset=utf-8',
Accept: 'application/json',
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(this.requestTimeoutMs),
});
}
}
function assertNoExtensionError(body: unknown, method: string): void {
if (body === null || typeof body !== 'object' || Array.isArray(body)) return;
const error = (body as { error?: unknown }).error;
if (typeof error !== 'string') return;
const code = (body as { code?: unknown }).code;
throw new BridgeExtensionError(
`Anime bridge ${method} failed: ${error}`,
typeof code === 'number' ? code : undefined,
);
}
+140
View File
@@ -0,0 +1,140 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildAnimeStreamMetadata,
buildAnimeStreamStatsPath,
buildStreamDisplayTitle,
splitEpisodeLabel,
splitSeasonFromTitle,
} from './episode-metadata';
test('splitSeasonFromTitle pulls a trailing season marker off the title', () => {
assert.deepEqual(splitSeasonFromTitle('Mushoku Tensei: Jobless Reincarnation Season 3'), {
title: 'Mushoku Tensei: Jobless Reincarnation',
season: 3,
});
assert.deepEqual(splitSeasonFromTitle('Spy x Family 2nd Season'), {
title: 'Spy x Family',
season: 2,
});
assert.deepEqual(splitSeasonFromTitle('Bocchi the Rock! S2'), {
title: 'Bocchi the Rock!',
season: 2,
});
assert.deepEqual(splitSeasonFromTitle('シャングリラ・フロンティア 第2期'), {
title: 'シャングリラ・フロンティア',
season: 2,
});
});
test('splitSeasonFromTitle leaves a title without a trailing marker alone', () => {
assert.deepEqual(splitSeasonFromTitle('My Teen Romantic Comedy SNAFU Climax!'), {
title: 'My Teen Romantic Comedy SNAFU Climax!',
season: null,
});
// "Season" inside the name is not a season marker.
assert.deepEqual(splitSeasonFromTitle('A Season of Snow and Ash'), {
title: 'A Season of Snow and Ash',
season: null,
});
// Nothing would be left of the title, so the marker is not a marker.
assert.deepEqual(splitSeasonFromTitle('Season 2'), { title: 'Season 2', season: null });
});
test('splitEpisodeLabel reads the number and the episode name', () => {
assert.deepEqual(splitEpisodeLabel('Episode 4'), { number: 4, title: null });
assert.deepEqual(splitEpisodeLabel('Episode 10: Gallantly, Shizuka Hiratsuka Moves Forward.'), {
number: 10,
title: 'Gallantly, Shizuka Hiratsuka Moves Forward.',
});
assert.deepEqual(splitEpisodeLabel('Ep. 7 - The Long Road'), {
number: 7,
title: 'The Long Road',
});
assert.deepEqual(splitEpisodeLabel('第12話 決戦'), { number: 12, title: '決戦' });
assert.deepEqual(splitEpisodeLabel('5. Homecoming'), { number: 5, title: 'Homecoming' });
assert.deepEqual(splitEpisodeLabel('13'), { number: 13, title: null });
assert.deepEqual(splitEpisodeLabel('Episode 6.5'), { number: 6.5, title: null });
});
test('splitEpisodeLabel keeps a label that carries no number as a name', () => {
assert.deepEqual(splitEpisodeLabel('Movie'), { number: null, title: 'Movie' });
assert.deepEqual(splitEpisodeLabel('OVA - Beach Episode'), {
number: null,
title: 'OVA - Beach Episode',
});
assert.deepEqual(splitEpisodeLabel(''), { number: null, title: null });
});
test('buildStreamDisplayTitle emits a form guessit and the jimaku parser both read', () => {
assert.equal(
buildStreamDisplayTitle('Mushoku Tensei: Jobless Reincarnation', 3, 4, null),
'Mushoku Tensei: Jobless Reincarnation S03E04',
);
assert.equal(
buildStreamDisplayTitle('My Teen Romantic Comedy SNAFU Climax!', null, 10, 'Gallantly'),
'My Teen Romantic Comedy SNAFU Climax! E10 - Gallantly',
);
assert.equal(buildStreamDisplayTitle('Some Movie', null, null, null), 'Some Movie');
});
test('buildAnimeStreamStatsPath is stable across playbacks of the same episode', () => {
const first = buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4');
const second = buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4');
assert.equal(first, second);
assert.notEqual(first, buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-5'));
assert.match(first, /^animebrowser:\/\//);
});
test('buildAnimeStreamMetadata resolves the browser strings into fields', () => {
const metadata = buildAnimeStreamMetadata({
sourceId: '9001',
animeUrl: '/anime/mushoku',
animeTitle: 'Mushoku Tensei: Jobless Reincarnation Season 3',
episodeUrl: '/watch/ep-4',
episodeName: 'Episode 4',
episodeNumber: 4,
mediaPath: 'http://127.0.0.1:41234/video/abc123.m3u8',
});
assert.equal(metadata.seriesTitle, 'Mushoku Tensei: Jobless Reincarnation');
assert.equal(metadata.seasonNumber, 3);
assert.equal(metadata.episodeNumber, 4);
assert.equal(metadata.episodeTitle, null);
assert.equal(metadata.displayTitle, 'Mushoku Tensei: Jobless Reincarnation S03E04');
assert.equal(metadata.mediaPath, 'http://127.0.0.1:41234/video/abc123.m3u8');
assert.equal(
metadata.statsPath,
buildAnimeStreamStatsPath('9001', '/anime/mushoku', '/watch/ep-4'),
);
});
test('buildAnimeStreamMetadata prefers the extension episode number over the label', () => {
const metadata = buildAnimeStreamMetadata({
sourceId: '1',
animeUrl: '/a',
animeTitle: 'Show',
episodeUrl: '/e',
episodeName: 'Finale',
episodeNumber: 24,
mediaPath: 'http://host/x.m3u8',
});
assert.equal(metadata.episodeNumber, 24);
assert.equal(metadata.episodeTitle, 'Finale');
assert.equal(metadata.displayTitle, 'Show E24 - Finale');
});
test('buildAnimeStreamMetadata falls back to the label when the source reports no number', () => {
const metadata = buildAnimeStreamMetadata({
sourceId: '1',
animeUrl: '/a',
animeTitle: 'Show 2nd Season',
episodeUrl: '/e',
episodeName: 'Episode 3: Rain',
episodeNumber: null,
mediaPath: 'http://host/x.m3u8',
});
assert.equal(metadata.seasonNumber, 2);
assert.equal(metadata.episodeNumber, 3);
assert.equal(metadata.displayTitle, 'Show S02E03 - Rain');
});
+205
View File
@@ -0,0 +1,205 @@
/**
* Structured metadata for a streamed episode.
*
* Extensions hand us two free-form strings an anime title that usually
* carries the season ("… Season 3") and an episode label that usually carries
* the number ("Episode 4: …"). Everything downstream (stats grouping, AniList,
* the subtitle modals) wants those as separate fields, so they are split once
* here rather than re-parsed out of the mpv title by each consumer.
*/
/** Where a stream came from, resolved into the fields consumers actually want. */
export interface AnimeStreamMetadata {
/** The URL handed to mpv. Matches what mpv reports as `path`. */
mediaPath: string;
/**
* Stable identity for this episode. The stream URL carries a per-playback
* proxy port and token, so it cannot be the key stats stores.
*/
statsPath: string;
/** Series name with the season suffix removed. */
seriesTitle: string;
seasonNumber: number | null;
episodeNumber: number | null;
/** The episode's own name, or null when the label was only a number. */
episodeTitle: string | null;
/** Shown by mpv, and the fallback every string parser sees. */
displayTitle: string;
}
export interface AnimeStreamMetadataInput {
sourceId: string;
animeUrl: string;
animeTitle: string;
episodeUrl: string;
episodeName: string;
/** Extension-reported number; trusted over anything parsed from the label. */
episodeNumber: number | null;
/** The URL playback actually uses, after proxy rewriting. */
mediaPath: string;
}
/**
* Season suffixes, anchored to the end of the title so a "Season" that is part
* of the name ("A Season of Snow") cannot be mistaken for one.
*/
const SEASON_SUFFIX_PATTERNS: RegExp[] = [
/[\s:_-]+season\s*(\d{1,2})\s*$/i,
/[\s:_-]+(\d{1,2})(?:st|nd|rd|th)\s+season\s*$/i,
/[\s:_-]+s(\d{1,2})\s*$/i,
/[\s:_-]*第\s*(\d{1,2})\s*期\s*$/,
/[\s:_-]+(\d{1,2})\s*期\s*$/,
];
/**
* Episode labels, most specific first. The trailing group is the episode's own
* name when the label carries one.
*/
const EPISODE_LABEL_PATTERNS: RegExp[] = [
/^\s*(?:episodio|épisode|episode|ep|e)\s*[.#]?\s*(\d{1,4}(?:\.\d+)?)\s*(?:[:\-–—.)]+\s*(.*))?$/i,
/^\s*第\s*(\d{1,4})\s*話\s*(?:[:\-–—]+\s*)?(.*)$/,
/^\s*(\d{1,4}(?:\.\d+)?)\s*[:\-–—.)]+\s*(.*)$/,
/^\s*(\d{1,4}(?:\.\d+)?)\s*$/,
];
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
/**
* Trims separators a split left dangling on either end. `.` is deliberately not
* one of them: an episode name often ends in a full stop that belongs to it.
*/
function trimSeparators(value: string): string {
return collapseWhitespace(value)
.replace(/^[\s:_\-–—]+/, '')
.replace(/[\s:_\-–—]+$/, '')
.trim();
}
function toEpisodeNumber(value: unknown): number | null {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return null;
return value;
}
/**
* Split a trailing season marker off an anime title.
*
* "Mushoku Tensei: Jobless Reincarnation Season 3" becomes the series plus
* season 3, which is what both AniList and the stats grouping key want. A title
* with no marker is returned unchanged with a null season season 1 is *not*
* assumed, because "unknown" and "one" behave differently when grouping.
*/
export function splitSeasonFromTitle(animeTitle: string): {
title: string;
season: number | null;
} {
const normalized = collapseWhitespace(animeTitle);
for (const pattern of SEASON_SUFFIX_PATTERNS) {
const match = normalized.match(pattern);
if (!match || match.index === undefined) continue;
const season = Number.parseInt(match[1]!, 10);
if (!Number.isInteger(season) || season <= 0) continue;
const title = trimSeparators(normalized.slice(0, match.index));
// A title that is *only* a season marker is not a title; keep the original.
if (!title) continue;
return { title, season };
}
return { title: normalized, season: null };
}
/**
* Split an episode label into its number and its own name.
*
* Sources are inconsistent here: "Episode 4", "4. Title", "第4話 タイトル" and a
* bare "4" all show up. A label that matches nothing is treated as a pure
* episode name, which is right for movies and specials.
*/
export function splitEpisodeLabel(episodeName: string): {
number: number | null;
title: string | null;
} {
const normalized = collapseWhitespace(episodeName);
if (!normalized) return { number: null, title: null };
for (const pattern of EPISODE_LABEL_PATTERNS) {
const match = normalized.match(pattern);
if (!match) continue;
const parsed = Number.parseFloat(match[1]!);
if (!Number.isFinite(parsed) || parsed <= 0) continue;
const title = trimSeparators(match[2] ?? '');
return { number: parsed, title: title || null };
}
return { number: null, title: normalized };
}
function formatEpisodePart(value: number): string {
return Number.isInteger(value) ? String(value).padStart(2, '0') : String(value);
}
/**
* The title mpv shows.
*
* `SxxEyy` is not just for looks: it is the one form both guessit and
* SubMiner's own filename parser read reliably, so any consumer that only ever
* sees the title string still lands on the right series and episode.
*/
export function buildStreamDisplayTitle(
seriesTitle: string,
season: number | null,
episode: number | null,
episodeTitle: string | null,
): string {
const parts: string[] = [seriesTitle];
if (episode !== null) {
parts.push(
season !== null
? `S${String(season).padStart(2, '0')}E${formatEpisodePart(episode)}`
: `E${formatEpisodePart(episode)}`,
);
} else if (season !== null) {
parts.push(`S${String(season).padStart(2, '0')}`);
}
const head = parts.join(' ');
return episodeTitle ? `${head} - ${episodeTitle}` : head;
}
/**
* A per-episode identity that survives across playbacks.
*
* The stream URL points at the strip proxy, whose port and token are minted per
* playback, so keying stats on it makes every rewatch a new video. The source's
* own episode url is stable, so that is what stats records instead with the
* real URL kept as an alias so mpv's path change still finds the row.
*/
export function buildAnimeStreamStatsPath(
sourceId: string,
animeUrl: string,
episodeUrl: string,
): string {
const source = encodeURIComponent(sourceId || 'unknown');
const anime = encodeURIComponent(animeUrl || 'unknown');
const episode = encodeURIComponent(episodeUrl || 'unknown');
return `animebrowser://${source}/${anime}/${episode}`;
}
export function buildAnimeStreamMetadata(input: AnimeStreamMetadataInput): AnimeStreamMetadata {
const { title: seriesTitle, season } = splitSeasonFromTitle(input.animeTitle);
const label = splitEpisodeLabel(input.episodeName);
const episodeNumber = toEpisodeNumber(input.episodeNumber) ?? label.number;
const displayTitle = buildStreamDisplayTitle(seriesTitle, season, episodeNumber, label.title);
return {
mediaPath: input.mediaPath,
statsPath: buildAnimeStreamStatsPath(input.sourceId, input.animeUrl, input.episodeUrl),
seriesTitle,
seasonNumber: season,
episodeNumber,
episodeTitle: label.title,
// A source that gave us neither a number nor a name leaves the series title
// alone rather than showing an empty suffix.
displayTitle: displayTitle || collapseWhitespace(input.animeTitle),
};
}
@@ -0,0 +1,250 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { installExtension, looksLikeApk, removeExtension } from './extension-installer';
import type { RepoExtension } from './extension-repo';
const PKG = 'eu.kanade.tachiyomi.animeextension.all.example';
function apkBytes(payload = 'APK-BODY'): Uint8Array {
// APKs are zip archives, so they start with the PK local-file-header magic.
return new Uint8Array([0x50, 0x4b, 0x03, 0x04, ...new TextEncoder().encode(payload)]);
}
function repoExtension(overrides: Partial<RepoExtension> = {}): RepoExtension {
return {
pkg: PKG,
name: 'Example Source',
lang: 'all',
version: '1.2.3',
versionCode: 12,
nsfw: false,
apkUrl: 'https://repo.example/anime/apk/example.apk',
iconUrl: 'https://repo.example/anime/icon/example.png',
repoUrl: 'https://repo.example/anime/index.min.json',
sourceNames: ['Example'],
...overrides,
};
}
function respondWith(bytes: Uint8Array, headers: Record<string, string> = {}): typeof fetch {
// Uint8Array is a valid Response body at runtime; the DOM lib types disagree.
const body = bytes as unknown as BodyInit;
return (async () => new Response(body, { status: 200, headers })) as typeof fetch;
}
test('looksLikeApk accepts the zip magic and rejects anything else', () => {
assert.equal(looksLikeApk(apkBytes()), true);
assert.equal(looksLikeApk(new TextEncoder().encode('<!DOCTYPE html>')), false);
assert.equal(looksLikeApk(new Uint8Array([])), false);
});
test('installExtension writes the apk named after its package', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const target = await installExtension({
extensionsDir: dir,
extension: repoExtension(),
fetchImpl: respondWith(apkBytes()),
});
assert.equal(target, path.join(dir, `${PKG}.apk`));
assert.match((await readFile(target)).toString(), /APK-BODY/);
});
test('installing again replaces the previous version in place', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
await installExtension({
extensionsDir: dir,
extension: repoExtension(),
fetchImpl: respondWith(apkBytes('OLD')),
});
await installExtension({
extensionsDir: dir,
extension: repoExtension({ version: '2.0.0', versionCode: 20 }),
fetchImpl: respondWith(apkBytes('NEW')),
});
const contents = (await readFile(path.join(dir, `${PKG}.apk`))).toString();
assert.match(contents, /NEW/);
assert.doesNotMatch(contents, /OLD/);
});
test('the extensions directory is created when missing', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const nested = path.join(root, 'does', 'not', 'exist');
await installExtension({
extensionsDir: nested,
extension: repoExtension(),
fetchImpl: respondWith(apkBytes()),
});
assert.equal(existsSync(path.join(nested, `${PKG}.apk`)), true);
});
test('a non-ok response is reported with the extension name', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const fetchImpl = (async () => new Response('', { status: 404 })) as typeof fetch;
await assert.rejects(
() => installExtension({ extensionsDir: dir, extension: repoExtension(), fetchImpl }),
/Example Source.*404/,
);
});
test('a response that is not an apk is rejected rather than written', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
// A misconfigured repo commonly serves an HTML error page instead.
const fetchImpl = respondWith(new TextEncoder().encode('<!DOCTYPE html><html>404</html>'));
await assert.rejects(
() => installExtension({ extensionsDir: dir, extension: repoExtension(), fetchImpl }),
/did not download as an APK/,
);
assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
});
test('an oversized download is refused by the declared length', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const fetchImpl = respondWith(apkBytes(), { 'content-length': '999999999' });
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension(),
fetchImpl,
maxBytes: 1024,
}),
/larger than the 1024 byte limit/,
);
});
test('an oversized download is refused even when the length header lies', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const fetchImpl = respondWith(apkBytes('x'.repeat(4096)), { 'content-length': '10' });
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension(),
fetchImpl,
maxBytes: 1024,
}),
/larger than the 1024 byte limit/,
);
assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
});
test('the byte limit stops the read instead of buffering the whole body', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
let pushed = 0;
// Endless body: if the limit were only checked after buffering, this hangs.
const body = new ReadableStream<Uint8Array>({
pull(controller) {
pushed += 1;
controller.enqueue(new Uint8Array(512));
},
});
const fetchImpl = (async () => new Response(body, { status: 200 })) as typeof fetch;
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension(),
fetchImpl,
maxBytes: 1024,
}),
/larger than the 1024 byte limit/,
);
// Only enough chunks to cross the limit were ever read.
assert.ok(pushed <= 4, `read ${pushed} chunks before aborting`);
});
test('a failed reader cancellation does not hide the size-limit error', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
let cancellationAttempted = false;
const body = new ReadableStream<Uint8Array>({
pull(controller) {
controller.enqueue(new Uint8Array(1025));
},
async cancel() {
cancellationAttempted = true;
throw new Error('cancel failed');
},
});
const fetchImpl = (async () => new Response(body, { status: 200 })) as typeof fetch;
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension(),
fetchImpl,
maxBytes: 1024,
}),
/larger than the 1024 byte limit/,
);
assert.ok(cancellationAttempted, 'the reader was never cancelled');
});
test('a failed staged write preserves the installed apk and removes the partial file', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const target = path.join(dir, `${PKG}.apk`);
await writeFile(target, apkBytes('OLD'));
let stagedPath = '';
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension({ version: '2.0.0', versionCode: 20 }),
fetchImpl: respondWith(apkBytes('NEW')),
fileIo: {
mkdir: (dirPath) => mkdir(dirPath, { recursive: true }),
async writeFile(filePath, bytes) {
stagedPath = filePath;
await writeFile(filePath, bytes.subarray(0, 5));
throw new Error('simulated disk write failure');
},
rename,
removeFile: (filePath) => rm(filePath, { force: true }),
},
}),
/simulated disk write failure/,
);
assert.match((await readFile(target)).toString(), /OLD/);
assert.notEqual(stagedPath, target);
assert.equal(existsSync(stagedPath), false);
});
test('a package name carrying path separators cannot escape the extensions dir', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const dir = path.join(root, 'extensions');
const escaping = `eu.kanade.tachiyomi.animeextension${path.sep}..${path.sep}..${path.sep}pwned`;
await assert.rejects(
() =>
installExtension({
extensionsDir: dir,
extension: repoExtension({ pkg: escaping }),
fetchImpl: respondWith(apkBytes()),
}),
/not a valid file name/,
);
assert.equal(existsSync(path.join(root, 'pwned.apk')), false);
});
test('removeExtension deletes the file and tolerates a missing one', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
const file = path.join(dir, `${PKG}.apk`);
await writeFile(file, 'x');
await removeExtension(dir, PKG);
assert.equal(existsSync(file), false);
await removeExtension(dir, PKG);
});
+155
View File
@@ -0,0 +1,155 @@
import { randomUUID } from 'node:crypto';
import { mkdir, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import { extensionFileName, type RepoExtension } from './extension-repo';
/**
* Downloads extension APKs into the extensions directory.
*
* Only URLs that came from a repository index the user configured are ever
* fetched; nothing here discovers or suggests sources.
*/
export interface InstallExtensionOptions {
extensionsDir: string;
extension: RepoExtension;
fetchImpl?: typeof fetch;
/** Guards against a mistyped repo serving something enormous. */
maxBytes?: number;
/** Cancels a stalled download; without it a hung repo blocks the install. */
signal?: AbortSignal;
/** Applied when no `signal` is given, so a download can never hang forever. */
timeoutMs?: number;
/** Injectable filesystem boundary for failure-path tests. */
fileIo?: ExtensionInstallerFileIo;
}
export interface ExtensionInstallerFileIo {
mkdir: (dir: string) => Promise<unknown>;
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
rename: (from: string, to: string) => Promise<void>;
removeFile: (filePath: string) => Promise<void>;
}
const DEFAULT_FILE_IO: ExtensionInstallerFileIo = {
mkdir: (dir) => mkdir(dir, { recursive: true }),
writeFile: (filePath, bytes) => writeFile(filePath, bytes),
rename,
removeFile: (filePath) => rm(filePath, { force: true }),
};
/** APKs are a few MB; anything far past that is not an extension. */
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
/** Generous enough for a large APK on a slow link, short of hanging forever. */
const DEFAULT_TIMEOUT_MS = 120_000;
const APK_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" — APKs are zip archives.
export function looksLikeApk(bytes: Uint8Array): boolean {
return APK_MAGIC.every((byte, index) => bytes[index] === byte);
}
/**
* Download one extension into `extensionsDir`, replacing any previous version.
*
* The file is named after the package so an update overwrites in place rather
* than leaving two versions for the bridge to load.
*/
export async function installExtension(options: InstallExtensionOptions): Promise<string> {
const fetchImpl = options.fetchImpl ?? fetch;
const fileIo = options.fileIo ?? DEFAULT_FILE_IO;
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
const signal = options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
const response = await fetchImpl(options.extension.apkUrl, { signal });
if (!response.ok) {
throw new Error(`Downloading ${options.extension.name} failed (${response.status}).`);
}
const declared = Number(response.headers.get('content-length') ?? '0');
if (declared > maxBytes) {
throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`);
}
const bytes = await readBounded(response, maxBytes, options.extension.name);
if (!looksLikeApk(bytes)) {
throw new Error(`${options.extension.name} did not download as an APK.`);
}
await fileIo.mkdir(options.extensionsDir);
const target = resolveTarget(options.extensionsDir, options.extension.pkg);
const staged = `${target}.${randomUUID()}.tmp`;
try {
await fileIo.writeFile(staged, bytes);
await fileIo.rename(staged, target);
} finally {
try {
await fileIo.removeFile(staged);
} catch {}
}
return target;
}
/**
* Read the body incrementally and stop the moment the limit is passed.
*
* Buffering first and measuring afterwards would let a repo that lies about
* (or omits) `content-length` push an unbounded amount into memory before the
* check ever runs.
*/
async function readBounded(
response: Response,
maxBytes: number,
name: string,
): Promise<Uint8Array> {
const reader = response.body?.getReader();
if (!reader) {
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > maxBytes) {
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
}
return bytes;
}
const chunks: Uint8Array[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > maxBytes) {
try {
await reader.cancel();
} catch {}
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
}
chunks.push(value);
}
const bytes = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return bytes;
}
/**
* Defence in depth against a repository index that smuggles path separators
* into a package name: the write target must stay inside `extensionsDir`.
*/
function resolveTarget(extensionsDir: string, pkg: string): string {
const root = path.resolve(extensionsDir);
const target = path.resolve(root, extensionFileName(pkg));
if (path.dirname(target) !== root) {
throw new Error(`Refusing to install ${pkg}: the package name is not a valid file name.`);
}
return target;
}
/** Delete an installed extension. Missing files are treated as already gone. */
export async function removeExtension(extensionsDir: string, pkg: string): Promise<void> {
await rm(resolveTarget(extensionsDir, pkg), { force: true });
}
+209
View File
@@ -0,0 +1,209 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
extensionFileName,
fetchRepoCatalogue,
fetchRepoIndex,
isValidRepoUrl,
parseRepoIndex,
repoBaseUrl,
} from './extension-repo';
const INDEX = 'https://repo.example/anime/index.min.json';
function animeEntry(overrides: Record<string, unknown> = {}): Record<string, unknown> {
return {
name: 'Aniyomi: Example Source',
pkg: 'eu.kanade.tachiyomi.animeextension.all.example',
apk: 'example-v1.2.3.apk',
lang: 'all',
code: 12,
version: '1.2.3',
nsfw: 0,
sources: [{ name: 'Example', lang: 'en' }],
...overrides,
};
}
test('any https url naming a json index is accepted', () => {
assert.equal(isValidRepoUrl(INDEX), true);
assert.equal(isValidRepoUrl(' ' + INDEX + ' '), true);
// Repos are free to name the index; index.min.json is only a convention.
assert.equal(isValidRepoUrl('https://repo.example/anime/index.json'), true);
assert.equal(
isValidRepoUrl('https://manatan-community.github.io/extensions/video.min.json'),
true,
);
// Plain http would let a network attacker swap the APK list.
assert.equal(isValidRepoUrl('http://repo.example/anime/index.min.json'), false);
assert.equal(isValidRepoUrl('https://repo.example/anime/'), false);
assert.equal(isValidRepoUrl('https://repo.example/index.min.json.txt'), false);
assert.equal(isValidRepoUrl('https://repo.example'), false);
assert.equal(isValidRepoUrl(''), false);
});
test('repoBaseUrl strips the index file name', () => {
assert.equal(repoBaseUrl(INDEX), 'https://repo.example/anime');
assert.equal(
repoBaseUrl('https://manatan-community.github.io/extensions/video.min.json'),
'https://manatan-community.github.io/extensions',
);
});
test('parseRepoIndex builds apk and icon urls from the repo root', () => {
const [extension] = parseRepoIndex(INDEX, [animeEntry()]);
assert.equal(extension?.pkg, 'eu.kanade.tachiyomi.animeextension.all.example');
assert.equal(extension?.apkUrl, 'https://repo.example/anime/apk/example-v1.2.3.apk');
assert.equal(
extension?.iconUrl,
'https://repo.example/anime/icon/eu.kanade.tachiyomi.animeextension.all.example.png',
);
assert.equal(extension?.repoUrl, INDEX);
assert.equal(extension?.versionCode, 12);
assert.deepEqual(extension?.sourceNames, ['Example']);
});
test('the Aniyomi name prefix is stripped', () => {
const [extension] = parseRepoIndex(INDEX, [animeEntry()]);
assert.equal(extension?.name, 'Example Source');
});
test('manga packages are excluded', () => {
const entries = [animeEntry(), animeEntry({ pkg: 'eu.kanade.tachiyomi.extension.en.somemanga' })];
const parsed = parseRepoIndex(INDEX, entries);
assert.equal(parsed.length, 1);
assert.match(parsed[0]!.pkg, /animeextension/);
});
test('malformed entries are skipped rather than failing the repo', () => {
const parsed = parseRepoIndex(INDEX, [
null,
'nonsense',
animeEntry({ apk: undefined }),
animeEntry({ pkg: undefined }),
animeEntry(),
]);
assert.equal(parsed.length, 1);
});
test('a package name that is not a plain identifier is rejected', () => {
// The package name becomes the on-disk file name, and a repo index is
// unauthenticated: path separators here would write outside the extensions
// directory even though the prefix check passes.
const parsed = parseRepoIndex(INDEX, [
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension/../../../../etc/cron.d/x' }),
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension\\..\\evil' }),
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension.all.ok' }),
]);
assert.deepEqual(
parsed.map((extension) => extension.pkg),
['eu.kanade.tachiyomi.animeextension.all.ok'],
);
});
test('an apk file name with path characters is rejected', () => {
const parsed = parseRepoIndex(INDEX, [animeEntry({ apk: '../../../etc/passwd' })]);
assert.deepEqual(parsed, []);
});
test('parseRepoIndex tolerates a non-array payload', () => {
assert.deepEqual(parseRepoIndex(INDEX, { message: 'Not Found' }), []);
assert.deepEqual(parseRepoIndex(INDEX, null), []);
});
test('missing optional fields fall back to safe defaults', () => {
const [extension] = parseRepoIndex(INDEX, [
{ pkg: 'eu.kanade.tachiyomi.animeextension.all.bare', apk: 'bare.apk' },
]);
assert.equal(extension?.name, 'eu.kanade.tachiyomi.animeextension.all.bare');
assert.equal(extension?.lang, 'all');
assert.equal(extension?.versionCode, 0);
assert.equal(extension?.nsfw, false);
assert.deepEqual(extension?.sourceNames, []);
});
test('nsfw is read from the numeric flag', () => {
assert.equal(parseRepoIndex(INDEX, [animeEntry({ nsfw: 1 })])[0]?.nsfw, true);
assert.equal(parseRepoIndex(INDEX, [animeEntry({ nsfw: 0 })])[0]?.nsfw, false);
});
test('fetchRepoIndex rejects an invalid url before making a request', async () => {
let called = false;
const fetchImpl = (async () => {
called = true;
return new Response('[]');
}) as typeof fetch;
await assert.rejects(() => fetchRepoIndex('http://insecure/index.min.json', { fetchImpl }));
assert.equal(called, false);
});
test('fetchRepoIndex surfaces a non-ok response', async () => {
const fetchImpl = (async () => new Response('', { status: 404 })) as typeof fetch;
await assert.rejects(() => fetchRepoIndex(INDEX, { fetchImpl }), /404/);
});
test('fetchRepoIndex applies a deadline when the caller supplies no signal', async () => {
let receivedSignal: AbortSignal | undefined;
const fetchImpl = (async (_input: RequestInfo | URL, init?: RequestInit) => {
receivedSignal = init?.signal instanceof AbortSignal ? init.signal : undefined;
return new Response('[]');
}) as typeof fetch;
await fetchRepoIndex(INDEX, { fetchImpl, timeoutMs: 50 });
assert.ok(receivedSignal, 'repository request should receive a deadline signal');
});
test('fetchRepoCatalogue merges repos and keeps the highest version code', async () => {
const second = 'https://other.example/anime/index.min.json';
const fetchImpl = (async (input: RequestInfo | URL) => {
const url = String(input);
if (url === INDEX) {
return new Response(JSON.stringify([animeEntry({ code: 12, version: '1.2.3' })]));
}
return new Response(JSON.stringify([animeEntry({ code: 20, version: '2.0.0' })]));
}) as typeof fetch;
const catalogue = await fetchRepoCatalogue([INDEX, second], { fetchImpl });
assert.equal(catalogue.extensions.length, 1);
assert.equal(catalogue.extensions[0]?.versionCode, 20);
assert.equal(catalogue.extensions[0]?.repoUrl, second);
assert.deepEqual(catalogue.failures, []);
});
test('one failing repo does not hide the others', async () => {
const broken = 'https://broken.example/anime/index.min.json';
const fetchImpl = (async (input: RequestInfo | URL) => {
if (String(input) === broken) throw new Error('ENOTFOUND');
return new Response(JSON.stringify([animeEntry()]));
}) as typeof fetch;
const catalogue = await fetchRepoCatalogue([broken, INDEX], { fetchImpl });
assert.equal(catalogue.extensions.length, 1);
assert.equal(catalogue.failures.length, 1);
assert.equal(catalogue.failures[0]?.repoUrl, broken);
assert.match(catalogue.failures[0]?.error ?? '', /ENOTFOUND/);
});
test('an empty repo list yields an empty catalogue without any request', async () => {
let called = false;
const fetchImpl = (async () => {
called = true;
return new Response('[]');
}) as typeof fetch;
const catalogue = await fetchRepoCatalogue([], { fetchImpl });
assert.deepEqual(catalogue, { extensions: [], failures: [] });
assert.equal(called, false);
});
test('extensions are stored under their package name so updates replace in place', () => {
assert.equal(
extensionFileName('eu.kanade.tachiyomi.animeextension.all.example'),
'eu.kanade.tachiyomi.animeextension.all.example.apk',
);
});
+200
View File
@@ -0,0 +1,200 @@
/**
* Client for Aniyomi-format extension repositories.
*
* SubMiner ships no repositories and performs no discovery. A repository only
* exists once the user adds its index URL, and only extensions from those
* repositories are ever listed or downloaded.
*/
/** Aniyomi extension packages carry this prefix; manga packages are ignored. */
const ANIME_PACKAGE_PREFIX = 'eu.kanade.tachiyomi.animeextension';
/**
* A package name becomes the on-disk APK file name, and a repository index is
* unauthenticated content the user pointed us at. Only plain dotted identifiers
* are accepted, so nothing in an index can carry `/` or `..` into a file path.
*/
const PACKAGE_NAME_PATTERN = /^[A-Za-z0-9_.]+$/;
/** The APK file name is appended to the repo URL, so keep it a bare name. */
const APK_FILE_NAME_PATTERN = /^[A-Za-z0-9_.+-]+$/;
/**
* Repos are identified by their index URL. The file name is not fixed:
* `index.min.json` is the Aniyomi convention, but repositories publish under
* other names too (e.g. `video.min.json`), so only https and a `.json` file
* name are required.
*/
const INDEX_URL_PATTERN = /^https:\/\/[^\s/]+(?:\/[^\s/]*)*\/[^\s/]+\.json$/;
export interface RepoExtension {
/** Package name, the stable identity of an extension across versions. */
pkg: string;
name: string;
lang: string;
version: string;
/** Monotonic version code; the comparison basis for updates. */
versionCode: number;
nsfw: boolean;
apkUrl: string;
iconUrl: string;
/** Index URL of the repo this came from. */
repoUrl: string;
/** Source names the package provides, when the index declares them. */
sourceNames: string[];
}
/** `true` when `url` is a usable Aniyomi index URL. */
export function isValidRepoUrl(url: string): boolean {
return INDEX_URL_PATTERN.test(url.trim());
}
/** Strip the index file name to get the repo root. */
export function repoBaseUrl(indexUrl: string): string {
return indexUrl.trim().replace(/\/[^/]*$/, '');
}
interface RawEntry {
name?: unknown;
pkg?: unknown;
apk?: unknown;
lang?: unknown;
code?: unknown;
version?: unknown;
nsfw?: unknown;
sources?: unknown;
}
function readSourceNames(sources: unknown): string[] {
if (!Array.isArray(sources)) return [];
return sources
.map((source) =>
source !== null && typeof source === 'object'
? (source as { name?: unknown }).name
: undefined,
)
.filter((name): name is string => typeof name === 'string' && name.length > 0);
}
/**
* Parse an index payload into anime extensions.
*
* Entries that are malformed, or that are manga rather than anime packages,
* are skipped rather than failing the whole repo.
*/
export function parseRepoIndex(indexUrl: string, payload: unknown): RepoExtension[] {
if (!Array.isArray(payload)) return [];
const base = repoBaseUrl(indexUrl);
const extensions: RepoExtension[] = [];
for (const raw of payload as RawEntry[]) {
if (raw === null || typeof raw !== 'object') continue;
const pkg = typeof raw.pkg === 'string' ? raw.pkg : '';
const apk = typeof raw.apk === 'string' ? raw.apk : '';
if (!pkg.startsWith(ANIME_PACKAGE_PREFIX) || !PACKAGE_NAME_PATTERN.test(pkg)) continue;
if (apk.length === 0 || !APK_FILE_NAME_PATTERN.test(apk)) continue;
const versionCode = Number(raw.code);
extensions.push({
pkg,
// Repo entries are prefixed "Aniyomi: "; the app supplies its own context.
name: (typeof raw.name === 'string' ? raw.name : pkg).replace(/^Aniyomi:\s*/, ''),
lang: typeof raw.lang === 'string' ? raw.lang : 'all',
version: typeof raw.version === 'string' ? raw.version : '0',
versionCode: Number.isFinite(versionCode) ? versionCode : 0,
nsfw: Number(raw.nsfw) === 1,
apkUrl: `${base}/apk/${apk}`,
iconUrl: `${base}/icon/${pkg}.png`,
repoUrl: indexUrl,
sourceNames: readSourceNames(raw.sources),
});
}
return extensions;
}
export interface FetchRepoOptions {
fetchImpl?: typeof fetch;
signal?: AbortSignal;
/** Applied when no signal is supplied, so one stalled repo cannot block the catalogue. */
timeoutMs?: number;
}
const DEFAULT_REPO_TIMEOUT_MS = 15_000;
/** Fetch and parse one repository index. */
export async function fetchRepoIndex(
indexUrl: string,
options: FetchRepoOptions = {},
): Promise<RepoExtension[]> {
if (!isValidRepoUrl(indexUrl)) {
throw new Error(`Not a valid repository index URL: ${indexUrl}`);
}
const fetchImpl = options.fetchImpl ?? fetch;
const signal =
options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_REPO_TIMEOUT_MS);
const response = await fetchImpl(indexUrl.trim(), {
headers: { Accept: 'application/json' },
signal,
});
if (!response.ok) {
throw new Error(`Repository returned ${response.status} for ${indexUrl}`);
}
return parseRepoIndex(indexUrl, await response.json());
}
export interface RepoFetchFailure {
repoUrl: string;
error: string;
}
export interface RepoCatalogue {
extensions: RepoExtension[];
failures: RepoFetchFailure[];
}
/**
* Fetch every configured repository.
*
* When two repos publish the same package, the higher version code wins, so a
* user's preferred repo ordering does not silently pin an older build.
*/
export async function fetchRepoCatalogue(
indexUrls: string[],
options: FetchRepoOptions = {},
): Promise<RepoCatalogue> {
const failures: RepoFetchFailure[] = [];
const byPackage = new Map<string, RepoExtension>();
const results = await Promise.all(
indexUrls.map(async (indexUrl) => {
try {
return { indexUrl, extensions: await fetchRepoIndex(indexUrl, options) };
} catch (error) {
failures.push({
repoUrl: indexUrl,
error: error instanceof Error ? error.message : String(error),
});
return { indexUrl, extensions: [] as RepoExtension[] };
}
}),
);
for (const { extensions } of results) {
for (const extension of extensions) {
const existing = byPackage.get(extension.pkg);
if (!existing || extension.versionCode > existing.versionCode) {
byPackage.set(extension.pkg, extension);
}
}
}
return {
extensions: [...byPackage.values()].sort((a, b) => a.name.localeCompare(b.name)),
failures,
};
}
/** File name an extension is stored under, so updates replace in place. */
export function extensionFileName(pkg: string): string {
return `${pkg}.apk`;
}
+178
View File
@@ -0,0 +1,178 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
listExtensionSources,
readInstalledExtensions,
toBridgeSource,
toInstalledExtensionViews,
type ExtensionSource,
type InstalledExtension,
} from './extension-store';
import type { AnimeBridgeClient } from './bridge-client';
async function makeExtensionDir(files: Record<string, string>): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-ext-'));
for (const [name, contents] of Object.entries(files)) {
await writeFile(path.join(dir, name), contents);
}
return dir;
}
function fakeClient(
impl: (source: { fingerprint: string }) => Promise<unknown[]>,
): AnimeBridgeClient {
return { listAnimeSources: impl } as unknown as AnimeBridgeClient;
}
test('readInstalledExtensions fingerprints apks without holding their bytes', async () => {
const dir = await makeExtensionDir({ 'my-source.apk': 'APK-BYTES' });
const extensions = await readInstalledExtensions(dir);
assert.equal(extensions.length, 1);
assert.equal(extensions[0]?.fallbackName, 'my-source');
assert.equal(extensions[0]?.sha256, createHash('sha256').update('APK-BYTES').digest('hex'));
});
test('the fingerprint changes when an apk is replaced in place', async () => {
const dir = await makeExtensionDir({ 'my-source.apk': 'V1' });
const before = (await readInstalledExtensions(dir))[0]?.sha256;
await writeFile(path.join(dir, 'my-source.apk'), 'V2');
const after = (await readInstalledExtensions(dir))[0]?.sha256;
assert.notEqual(before, after);
});
test('toBridgeSource reads the apk only when the bridge asks for it', async () => {
const dir = await makeExtensionDir({ 'lazy.apk': 'APK-BYTES' });
const extension = (await readInstalledExtensions(dir))[0]!;
const bridgeSource = toBridgeSource(extension);
assert.equal(bridgeSource.fingerprint, extension.sha256);
assert.equal(Buffer.from(await bridgeSource.loadApkBase64(), 'base64').toString(), 'APK-BYTES');
});
test('readInstalledExtensions ignores non-apk files and subdirectories', async () => {
const dir = await makeExtensionDir({ 'a.apk': 'A', 'notes.txt': 'x', 'b.APK': 'B' });
await mkdir(path.join(dir, 'nested.apk'), { recursive: true });
const names = (await readInstalledExtensions(dir)).map((e) => e.fallbackName);
// Sorted, case-insensitive extension match, directories excluded.
assert.deepEqual(names, ['a', 'b']);
});
test('readInstalledExtensions returns empty for a missing directory', async () => {
assert.deepEqual(await readInstalledExtensions('/nonexistent/subminer/extensions'), []);
});
test('toBridgeSource includes sourceId only when selecting inside a factory apk', () => {
const extension: InstalledExtension = { file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a' };
assert.equal(toBridgeSource(extension).sourceId, undefined);
assert.equal(toBridgeSource(extension, 'src-1').sourceId, 'src-1');
assert.equal(toBridgeSource(extension, 'src-1').fingerprint, 'hash-a');
});
test('listExtensionSources flattens every source a factory apk provides', async () => {
const extensions: InstalledExtension[] = [
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
];
const client = fakeClient(async () => [
{ id: 101, name: 'Source One', lang: 'en' },
{ id: '102', name: 'Source Two', lang: 'ja' },
]);
const sources = await listExtensionSources(client, extensions);
assert.equal(sources.length, 2);
// Numeric bridge ids are normalized and package-qualified for UI state.
assert.equal(sources[0]?.id, 'multi:101');
assert.equal(sources[0]?.bridgeId, '101');
assert.equal(sources[0]?.name, 'Source One');
assert.equal(sources[1]?.lang, 'ja');
});
test('sources with the same bridge id in different packages have distinct runtime ids', async () => {
const extensions: InstalledExtension[] = [
{ file: '/x/one.apk', fallbackName: 'pkg.one', sha256: 'hash-one' },
{ file: '/x/two.apk', fallbackName: 'pkg.two', sha256: 'hash-two' },
];
const client = fakeClient(async () => [{ id: 'shared', name: 'Source', lang: 'en' }]);
const sources = await listExtensionSources(client, extensions);
assert.deepEqual(
sources.map(({ id, bridgeId, pkg }) => ({ id, bridgeId, pkg })),
[
{ id: 'pkg.one:shared', bridgeId: 'shared', pkg: 'pkg.one' },
{ id: 'pkg.two:shared', bridgeId: 'shared', pkg: 'pkg.two' },
],
);
});
test('listExtensionSources falls back to the file name and a default language', async () => {
const extensions: InstalledExtension[] = [
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', sha256: 'hash-a' },
];
const client = fakeClient(async () => [{ id: '1', name: ' ' }]);
const sources = await listExtensionSources(client, extensions);
assert.equal(sources[0]?.name, 'my-ext');
assert.equal(sources[0]?.lang, 'all');
});
test('listExtensionSources drops descriptors with no usable id', async () => {
const client = fakeClient(async () => [{ name: 'No Id' }, { id: '', name: 'Empty' }]);
const sources = await listExtensionSources(client, [
{ file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a' },
]);
assert.deepEqual(sources, []);
});
test('toInstalledExtensionViews names an extension after the sources it provides', () => {
const extensions: InstalledExtension[] = [
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
];
const sources: ExtensionSource[] = [
{ id: 'multi:1', bridgeId: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
{ id: 'multi:2', bridgeId: '2', name: 'Two', lang: 'ja', pkg: 'multi', file: '/x/multi.apk' },
];
assert.deepEqual(toInstalledExtensionViews(extensions, sources, []), [
{ pkg: 'multi', name: 'One, Two', langs: ['en', 'ja'], sourceCount: 2, error: null },
]);
});
test('toInstalledExtensionViews lists an extension that loaded nothing, with its reason', () => {
const extensions: InstalledExtension[] = [
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
];
// A broken APK is still installed, so it must stay listed and removable.
assert.deepEqual(
toInstalledExtensionViews(extensions, [], [{ pkg: 'broken', error: 'dex2jar failed' }]),
[{ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'dex2jar failed' }],
);
});
test('one broken extension does not hide the working ones', async () => {
const extensions: InstalledExtension[] = [
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
{ file: '/x/good.apk', fallbackName: 'good', sha256: 'hash-b' },
];
const failures: string[] = [];
const client = fakeClient(async (source) => {
if (source.fingerprint === 'hash-a') throw new Error('dex2jar failed');
return [{ id: '7', name: 'Good Source', lang: 'en' }];
});
const sources = await listExtensionSources(client, extensions, (extension) => {
failures.push(extension.fallbackName);
});
assert.deepEqual(failures, ['broken']);
assert.equal(sources.length, 1);
assert.equal(sources[0]?.name, 'Good Source');
});
+148
View File
@@ -0,0 +1,148 @@
import { createReadStream } from 'node:fs';
import { readdir, readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto';
import { pipeline } from 'node:stream/promises';
import path from 'node:path';
import type { AnimeBridgeClient } from './bridge-client';
import type { BridgeSource } from './bridge-client';
import type { ExtensionLoadFailure, InstalledExtensionView } from '../types/anime-browser';
/**
* Anime extensions are Aniyomi APKs the user supplies. They are read from a
* directory rather than fetched from a hardcoded catalogue, so which sources
* exist is entirely the user's choice.
*/
export interface InstalledExtension {
/** Absolute path to the .apk. */
file: string;
/** File name without extension, used when the bridge reports no name. */
fallbackName: string;
/**
* SHA-256 of the APK. Identifies the build rather than the slot, so the
* bridge's extension-id cache misses after an in-place upgrade.
*/
sha256: string;
}
export interface ExtensionSource {
/** Package-qualified id used by the UI and runtime. */
id: string;
/** Raw bridge id, which selects this source inside a factory APK. */
bridgeId: string;
name: string;
lang: string;
pkg: string;
file: string;
}
/**
* Discover every .apk in `directory`. A missing directory yields no extensions.
*
* Only a hash is kept, never the bytes: APKs run to several MB each and a
* base64 copy adds a third on top, so holding the whole set for the lifetime of
* the Anime Browser would cost far more than re-reading a file on the rare
* upload. Hashing streams, so peak memory stays flat regardless of APK size.
*/
export async function readInstalledExtensions(directory: string): Promise<InstalledExtension[]> {
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch {
return [];
}
const extensions: InstalledExtension[] = [];
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.apk')) continue;
const file = path.join(directory, entry.name);
extensions.push({
file,
fallbackName: entry.name.replace(/\.apk$/i, ''),
sha256: await hashFile(file),
});
}
return extensions;
}
async function hashFile(file: string): Promise<string> {
const hash = createHash('sha256');
await pipeline(createReadStream(file), hash);
return hash.digest('hex');
}
/**
* Describe what is on disk, for the installed list in the Extensions tab.
*
* Built from the directory rather than from a repository catalogue: an APK
* dropped in by hand, or one whose repository the user has since removed, is
* still installed and must stay removable.
*/
export function toInstalledExtensionViews(
extensions: InstalledExtension[],
sources: ExtensionSource[],
loadFailures: ExtensionLoadFailure[],
): InstalledExtensionView[] {
return extensions.map((extension) => {
const provided = sources.filter((source) => source.file === extension.file);
const names = [...new Set(provided.map((source) => source.name))];
return {
pkg: extension.fallbackName,
name: names.length > 0 ? names.join(', ') : extension.fallbackName,
langs: [...new Set(provided.map((source) => source.lang))],
sourceCount: provided.length,
error: loadFailures.find((failure) => failure.pkg === extension.fallbackName)?.error ?? null,
};
});
}
/**
* The bridge payload for a specific source inside an extension.
*
* The APK is read on demand: after the first upload the bridge answers by
* extension id, so most calls never touch the file at all.
*/
export function toBridgeSource(extension: InstalledExtension, sourceId?: string): BridgeSource {
return {
fingerprint: extension.sha256,
loadApkBase64: async () => (await readFile(extension.file)).toString('base64'),
...(sourceId ? { sourceId } : {}),
};
}
/**
* Ask the bridge which sources each extension provides.
*
* An extension that fails to load is skipped rather than aborting the scan, so
* one broken APK cannot hide every working one. Failures are reported through
* `onError` for surfacing in the UI.
*/
export async function listExtensionSources(
client: AnimeBridgeClient,
extensions: InstalledExtension[],
onError?: (extension: InstalledExtension, error: unknown) => void,
): Promise<ExtensionSource[]> {
const sources: ExtensionSource[] = [];
for (const extension of extensions) {
try {
const descriptors = await client.listAnimeSources(toBridgeSource(extension));
for (const descriptor of descriptors) {
const bridgeId = descriptor.id === undefined ? null : String(descriptor.id);
if (bridgeId === null || bridgeId.length === 0) continue;
sources.push({
id: `${extension.fallbackName}:${bridgeId}`,
bridgeId,
name: descriptor.name?.trim() || extension.fallbackName,
lang: descriptor.lang ?? 'all',
pkg: extension.fallbackName,
file: extension.file,
});
}
} catch (error) {
onError?.(extension, error);
}
}
return sources;
}
+73
View File
@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseOkHttpHeaders, resolveStream, toMpvHeaderFields } from './headers';
test('parseOkHttpHeaders flattens the alternating name/value array', () => {
const parsed = parseOkHttpHeaders({
namesAndValues$okhttp: ['Referer', 'https://origin.example/', 'User-Agent', 'Aniyomi'],
});
assert.deepEqual(parsed, {
Referer: 'https://origin.example/',
'User-Agent': 'Aniyomi',
});
});
test('parseOkHttpHeaders tolerates missing, empty, and odd-length input', () => {
assert.deepEqual(parseOkHttpHeaders(undefined), {});
assert.deepEqual(parseOkHttpHeaders({}), {});
assert.deepEqual(parseOkHttpHeaders({ namesAndValues$okhttp: [] }), {});
// A trailing name with no value is dropped rather than mapped to undefined.
assert.deepEqual(parseOkHttpHeaders({ namesAndValues$okhttp: ['Referer'] }), {});
});
test('toMpvHeaderFields joins entries and escapes commas in values', () => {
const fields = toMpvHeaderFields({
Referer: 'https://origin.example/',
Cookie: 'a=1, b=2',
});
assert.equal(fields, 'Referer: https://origin.example/,Cookie: a=1\\, b=2');
});
test('toMpvHeaderFields escapes backslashes so a trailing one cannot eat the separator', () => {
const fields = toMpvHeaderFields({ Referer: 'https://origin.example/path\\', Cookie: 'a=1' });
// Without doubling, the value's trailing backslash would escape the comma
// and merge Cookie into the Referer entry.
assert.equal(fields, 'Referer: https://origin.example/path\\\\,Cookie: a=1');
});
test('toMpvHeaderFields returns an empty string when there are no headers', () => {
assert.equal(toMpvHeaderFields({}), '');
});
test('resolveStream normalizes a bridge video into a playable stream', () => {
const stream = resolveStream({
url: 'https://origin.example/embed/1',
quality: '1080p',
videoUrl: 'http://127.0.0.1:8080/video/master-token',
headers: { namesAndValues$okhttp: ['Referer', 'https://origin.example/'] },
subtitleTracks: [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: 'English' }],
audioTracks: [{ url: 'http://127.0.0.1:8080/video/audio-token', lang: 'Japanese' }],
});
assert.deepEqual(stream, {
url: 'http://127.0.0.1:8080/video/master-token',
quality: '1080p',
headers: { Referer: 'https://origin.example/' },
subtitles: [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: 'English' }],
audios: [{ url: 'http://127.0.0.1:8080/video/audio-token', lang: 'Japanese' }],
});
});
test('resolveStream returns null when the extension resolved no media url', () => {
assert.equal(resolveStream({ url: 'https://origin.example/embed/1', quality: '1080p' }), null);
assert.equal(resolveStream({ videoUrl: '' }), null);
});
test('resolveStream drops tracks without a url and defaults a missing lang', () => {
const stream = resolveStream({
videoUrl: 'http://127.0.0.1:8080/video/master-token',
subtitleTracks: [{ lang: 'English' }, { url: 'http://127.0.0.1:8080/video/sub-token' }],
});
assert.deepEqual(stream?.subtitles, [{ url: 'http://127.0.0.1:8080/video/sub-token', lang: '' }]);
assert.equal(stream?.quality, '');
});
+56
View File
@@ -0,0 +1,56 @@
import type { BridgeVideo, OkHttpHeaders, ResolvedStream } from './types';
/**
* Flatten OkHttp's alternating `[name, value, name, value]` array into a map.
* A trailing name with no value is dropped rather than mapped to undefined.
*/
export function parseOkHttpHeaders(headers: OkHttpHeaders | undefined): Record<string, string> {
const flat = headers?.['namesAndValues$okhttp'];
if (!Array.isArray(flat)) return {};
const parsed: Record<string, string> = {};
for (let i = 0; i + 1 < flat.length; i += 2) {
const name = flat[i];
const value = flat[i + 1];
if (typeof name === 'string' && typeof value === 'string') parsed[name] = value;
}
return parsed;
}
/**
* Render headers as mpv's `--http-header-fields` string list. mpv splits
* entries on commas, so commas inside a value must be escaped and the
* backslash that does the escaping has to be escaped first, or a value ending
* in `\` would neutralise the separator and swallow the next header.
*/
export function toMpvHeaderFields(headers: Record<string, string>): string {
return Object.entries(headers)
.map(([name, value]) => `${name}: ${value.replace(/\\/g, '\\\\').replace(/,/g, '\\,')}`)
.join(',');
}
function normalizeTracks(
tracks: Array<{ url?: string; lang?: string }> | undefined,
): Array<{ url: string; lang: string }> {
if (!Array.isArray(tracks)) return [];
return tracks
.filter((track): track is { url: string; lang?: string } => typeof track.url === 'string')
.map((track) => ({ url: track.url, lang: track.lang ?? '' }));
}
/**
* Normalize a bridge video into a playable stream. Returns null when the
* extension produced no `videoUrl`, which happens for entries it failed to
* resolve.
*/
export function resolveStream(video: BridgeVideo): ResolvedStream | null {
if (typeof video.videoUrl !== 'string' || video.videoUrl.length === 0) return null;
return {
url: video.videoUrl,
quality: video.quality ?? '',
headers: parseOkHttpHeaders(video.headers),
subtitles: normalizeTracks(video.subtitleTracks),
audios: normalizeTracks(video.audioTracks),
};
}
+95
View File
@@ -0,0 +1,95 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseAnimeStatus, resolveBridgeMediaUrl, routeHlsThroughProxy } from './media-url';
const BRIDGE = 'http://127.0.0.1:56037';
const PROXY = 'http://127.0.0.1:60001';
test('a bridge m3u8 stream is routed through the strip proxy', () => {
assert.equal(
routeHlsThroughProxy(`${BRIDGE}/video/master.m3u8?q=1080`, BRIDGE, PROXY),
`${PROXY}/video/master.m3u8?q=1080`,
);
});
test('non-HLS bridge streams stay on the bridge', () => {
const direct = `${BRIDGE}/video/movie-token`;
assert.equal(routeHlsThroughProxy(direct, BRIDGE, PROXY), direct);
});
test('external m3u8 streams are not routed through the proxy', () => {
const remote = 'https://cdn.example.com/hls/master.m3u8';
assert.equal(routeHlsThroughProxy(remote, BRIDGE, PROXY), remote);
});
test('unparseable stream urls pass through routeHlsThroughProxy unchanged', () => {
assert.equal(routeHlsThroughProxy('not a url', BRIDGE, PROXY), 'not a url');
assert.equal(
routeHlsThroughProxy(`${BRIDGE}/video/a.m3u8`, 'garbage', PROXY),
`${BRIDGE}/video/a.m3u8`,
);
});
test('a loopback proxy url is rebased onto the live bridge port', () => {
assert.equal(
resolveBridgeMediaUrl(BRIDGE, 'http://127.0.0.1:8080/image/cover-uuid'),
'http://127.0.0.1:56037/image/cover-uuid',
);
assert.equal(
resolveBridgeMediaUrl(BRIDGE, 'http://localhost:8080/video/master-token'),
'http://127.0.0.1:56037/video/master-token',
);
});
test('query strings and fragments survive rebasing', () => {
assert.equal(
resolveBridgeMediaUrl(BRIDGE, 'http://127.0.0.1:8080/video/token?quality=1080#t=30'),
'http://127.0.0.1:56037/video/token?quality=1080#t=30',
);
});
test('ipv6 loopback is recognised', () => {
assert.equal(
resolveBridgeMediaUrl(BRIDGE, 'http://[::1]:8080/image/cover'),
'http://127.0.0.1:56037/image/cover',
);
});
test('remote urls are left untouched', () => {
const remote = 'https://cdn.example.com/covers/1.jpg';
assert.equal(resolveBridgeMediaUrl(BRIDGE, remote), remote);
});
test('loopback urls outside the proxy routes are left untouched', () => {
// Only /image and /video are proxy routes; /capabilities is the server's own API.
const other = 'http://127.0.0.1:8080/capabilities';
assert.equal(resolveBridgeMediaUrl(BRIDGE, other), other);
});
test('a base url without a scheme is assumed to be http', () => {
assert.equal(
resolveBridgeMediaUrl('127.0.0.1:56037', 'http://127.0.0.1:8080/image/cover'),
'http://127.0.0.1:56037/image/cover',
);
});
test('unparseable input is returned unchanged rather than throwing', () => {
assert.equal(resolveBridgeMediaUrl(BRIDGE, 'not a url'), 'not a url');
assert.equal(
resolveBridgeMediaUrl('', 'http://127.0.0.1:8080/image/c'),
'http://127.0.0.1:8080/image/c',
);
assert.equal(resolveBridgeMediaUrl(BRIDGE, ''), '');
});
test('parseAnimeStatus maps the SAnime constants', () => {
assert.equal(parseAnimeStatus(1), 'ongoing');
assert.equal(parseAnimeStatus(2), 'completed');
assert.equal(parseAnimeStatus(4), 'publishing-finished');
assert.equal(parseAnimeStatus(5), 'cancelled');
assert.equal(parseAnimeStatus(6), 'on-hiatus');
assert.equal(parseAnimeStatus(0), 'unknown');
assert.equal(parseAnimeStatus(undefined), 'unknown');
// 3 is unused in the SAnime constants.
assert.equal(parseAnimeStatus(3), 'unknown');
});
+98
View File
@@ -0,0 +1,98 @@
/**
* The bridge returns cover art and video URLs pointing at its own loopback
* media proxy, but the origin it embeds is not always the port we actually
* started it on. Rebase those onto the live bridge origin, and leave any
* genuinely remote URL untouched.
*/
const PROXY_ROUTES = new Set(['image', 'video']);
const LOOPBACK_HOSTS = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
function isLoopbackProxyUrl(candidate: URL): boolean {
if (candidate.protocol !== 'http:' && candidate.protocol !== 'https:') return false;
const host = candidate.hostname.toLowerCase();
if (!LOOPBACK_HOSTS.has(host)) return false;
const route = candidate.pathname.split('/').filter(Boolean)[0];
return route !== undefined && PROXY_ROUTES.has(route);
}
/**
* Rewrite a bridge media URL onto `bridgeBaseUrl`, preserving path and query.
* Returns the input unchanged when it is not a loopback proxy URL, or when
* either URL cannot be parsed.
*/
export function resolveBridgeMediaUrl(bridgeBaseUrl: string, mediaUrl: string): string {
let media: URL;
try {
media = new URL(mediaUrl);
} catch {
return mediaUrl;
}
if (!isLoopbackProxyUrl(media)) return mediaUrl;
const normalizedBase = bridgeBaseUrl.includes('://') ? bridgeBaseUrl : `http://${bridgeBaseUrl}`;
let base: URL;
try {
base = new URL(normalizedBase);
} catch {
return mediaUrl;
}
if (base.protocol !== 'http:' && base.protocol !== 'https:') return mediaUrl;
if (base.hostname.length === 0) return mediaUrl;
const rebased = new URL(base.origin);
rebased.pathname = media.pathname;
rebased.search = media.search;
rebased.hash = media.hash;
return rebased.toString();
}
/**
* Send a bridge-served HLS stream through the local strip proxy instead. Only
* `.m3u8` URLs on the bridge origin qualify: direct files need no fixing, and
* an external URL would not resolve through a proxy that forwards to the
* bridge. Anything unparseable comes back unchanged.
*/
export function routeHlsThroughProxy(
streamUrl: string,
bridgeBaseUrl: string,
proxyOrigin: string,
): string {
let stream: URL;
let bridge: URL;
try {
stream = new URL(streamUrl);
bridge = new URL(bridgeBaseUrl);
} catch {
return streamUrl;
}
if (stream.origin !== bridge.origin) return streamUrl;
if (!stream.pathname.endsWith('.m3u8')) return streamUrl;
return `${proxyOrigin}${stream.pathname}${stream.search}`;
}
/** Aniyomi's SAnime status constants. */
export type AnimeStatus =
| 'unknown'
| 'ongoing'
| 'completed'
| 'publishing-finished'
| 'cancelled'
| 'on-hiatus';
export function parseAnimeStatus(status: number | undefined): AnimeStatus {
switch (status) {
case 1:
return 'ongoing';
case 2:
return 'completed';
case 4:
return 'publishing-finished';
case 5:
return 'cancelled';
case 6:
return 'on-hiatus';
default:
return 'unknown';
}
}
+267
View File
@@ -0,0 +1,267 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildLoadfileOptions,
buildPlaybackCommands,
buildQueuedLoadfileOptions,
buildQueuedPlaybackCommands,
buildTrackCommands,
normalizeLangTag,
selectPreferredStream,
} from './mpv-playback';
import type { ResolvedStream } from './types';
function stream(overrides: Partial<ResolvedStream> = {}): ResolvedStream {
return {
url: 'http://127.0.0.1:8080/video/token',
quality: '1080p',
headers: {},
subtitles: [],
audios: [],
...overrides,
};
}
test('loadfile options keep one visible track and never scan the filesystem', () => {
const options = buildLoadfileOptions({ stream: stream() });
for (const expected of [
'sub-auto=no',
'secondary-sid=no',
'secondary-sub-visibility=no',
'sub-visibility=yes',
]) {
assert.ok(options.split(',').includes(expected), `missing ${expected}`);
}
// The source's own subtitles are the only ones this path gets, so they must
// not be suppressed the way the Jellyfin path suppresses them.
assert.ok(!options.split(',').includes('sid=no'));
// Comma-separated values would split the option list, so the language
// preferences ride as properties instead.
assert.ok(!options.includes('alang'));
assert.ok(!options.includes('slang'));
});
test('queued playback carries file-local title and language preferences into mpv', () => {
const options = {
stream: {
url: 'https://video.example/episode.m3u8',
quality: '1080p',
headers: { Referer: 'https://source.example/watch?a=1,b=2' },
audios: [],
subtitles: [],
},
title: 'Series, Part 1 = Episode 2',
};
const languagePreference = 'ja,jpn,jp,japanese';
assert.ok(
buildQueuedLoadfileOptions(options).includes(
`alang=%${Buffer.byteLength(languagePreference)}%${languagePreference}`,
),
);
assert.ok(
buildQueuedLoadfileOptions(options).includes(
`force-media-title=%${Buffer.byteLength(options.title)}%${options.title}`,
),
);
assert.deepEqual(buildQueuedPlaybackCommands(options), [
[
'loadfile',
'https://video.example/episode.m3u8',
'append-play',
-1,
buildQueuedLoadfileOptions(options),
],
]);
});
test('headers are percent-escaped so their commas do not split the option list', () => {
const headers = { Referer: 'https://a.test/', 'User-Agent': 'X' };
const options = buildLoadfileOptions({ stream: stream({ headers }) });
// Verified against mpv 0.41: the unescaped form yields an empty header list.
const fields = 'Referer: https://a.test/,User-Agent: X';
assert.ok(options.includes(`http-header-fields=%${fields.length}%${fields}`));
});
test('the escape length counts the full header string including separators', () => {
const options = buildLoadfileOptions({
stream: stream({ headers: { Cookie: 'a=1, b=2' } }),
});
// The comma inside the value is backslash-escaped first, so the length grows.
const fields = 'Cookie: a=1\\, b=2';
assert.ok(options.includes(`%${fields.length}%${fields}`));
});
test('the escape length is counted in utf-8 bytes, not js string units', () => {
// An extension may put a non-ASCII value in a header; mpv reads %n% as a
// byte count, so counting string units would truncate the value.
const options = buildLoadfileOptions({
stream: stream({ headers: { 'X-Title': '日本語' } }),
});
const fields = 'X-Title: 日本語';
assert.ok(options.includes(`%${Buffer.byteLength(fields, 'utf8')}%${fields}`));
assert.ok(!options.includes(`%${fields.length}%`));
});
test('no header option is emitted when the stream carries no headers', () => {
const options = buildLoadfileOptions({ stream: stream() });
assert.ok(!options.includes('http-header-fields'));
});
test('a positive start position is appended, zero is omitted', () => {
assert.ok(buildLoadfileOptions({ stream: stream(), startSeconds: 42 }).includes('start=42'));
assert.ok(!buildLoadfileOptions({ stream: stream(), startSeconds: 0 }).includes('start='));
assert.ok(!buildLoadfileOptions({ stream: stream() }).includes('start='));
});
test('playback commands set the language preference before loading the file', () => {
const commands = buildPlaybackCommands({ stream: stream(), title: 'Example - 01' });
assert.deepEqual(commands[0], ['script-message', 'subminer-managed-subtitles-loading']);
// Japanese first, so a multi-audio stream never starts on the dub. slang is
// Japanese-only: an English track belongs in the secondary slot, which the
// secondarySub auto-load fills by language tag.
assert.deepEqual(commands[1], ['set_property', 'alang', 'ja,jpn,jp,japanese']);
assert.deepEqual(commands[2], ['set_property', 'slang', 'ja,jpn,jp,japanese']);
assert.equal(commands[3]?.[0], 'loadfile');
assert.equal(commands[3]?.[1], 'http://127.0.0.1:8080/video/token');
assert.equal(commands[3]?.[2], 'replace');
assert.equal(commands[3]?.[3], -1);
assert.deepEqual(commands[4], ['set_property', 'force-media-title', 'Example - 01']);
});
test('force-media-title is skipped when there is no title', () => {
assert.equal(buildPlaybackCommands({ stream: stream() }).length, 4);
assert.equal(buildPlaybackCommands({ stream: stream(), title: '' }).length, 4);
});
test('external audio tracks are added, with the Japanese one selected', () => {
const commands = buildTrackCommands(
stream({
audios: [
{ url: 'http://host/en.m4a', lang: 'en' },
{ url: 'http://host/ja.m4a', lang: 'ja' },
],
}),
);
assert.deepEqual(commands, [
['audio-add', 'http://host/en.m4a', 'auto', 'en', 'en'],
['audio-add', 'http://host/ja.m4a', 'select', 'ja', 'ja'],
]);
});
test('external audio is left unselected when none of it is Japanese', () => {
// alang already picked a track off the container; do not override it.
const commands = buildTrackCommands(
stream({ audios: [{ url: 'http://host/en.m4a', lang: 'eng' }] }),
);
assert.deepEqual(commands, [['audio-add', 'http://host/en.m4a', 'auto', 'eng', 'en']]);
});
test('only a Japanese subtitle track is selected as primary', () => {
const japanese = buildTrackCommands(
stream({
subtitles: [
{ url: 'http://host/en.vtt', lang: 'English' },
{ url: 'http://host/ja.vtt', lang: 'Japanese' },
],
}),
);
assert.deepEqual(japanese[1], ['sub-add', 'http://host/ja.vtt', 'select', 'Japanese', 'ja']);
assert.equal(japanese[0]?.[2], 'auto');
// English is the user's *secondary* language; it must not take the primary
// slot. It rides in unselected, tagged so the secondarySub auto-load can
// route it to secondary-sid.
const englishOnly = buildTrackCommands(
stream({ subtitles: [{ url: 'http://host/en.vtt', lang: 'English' }] }),
);
assert.deepEqual(englishOnly, [['sub-add', 'http://host/en.vtt', 'auto', 'English', 'en']]);
});
test('language labels normalize to the tags users configure', () => {
assert.equal(normalizeLangTag('English'), 'en');
assert.equal(normalizeLangTag('eng'), 'en');
assert.equal(normalizeLangTag('en-US'), 'en');
assert.equal(normalizeLangTag('Japanese'), 'ja');
assert.equal(normalizeLangTag('jpn'), 'ja');
assert.equal(normalizeLangTag('Português'), 'pt');
// Unknown labels pass through untouched rather than being guessed at.
assert.equal(normalizeLangTag('Klingon'), 'Klingon');
assert.equal(normalizeLangTag(''), '');
});
test('unlabelled tracks still get a usable menu title, duplicates are dropped', () => {
const commands = buildTrackCommands(
stream({
subtitles: [
{ url: 'http://host/a.vtt', lang: '' },
{ url: 'http://host/a.vtt', lang: '' },
{ url: 'http://host/b.vtt', lang: '' },
],
}),
);
assert.deepEqual(commands, [
['sub-add', 'http://host/a.vtt', 'auto', 'Subtitle 1', ''],
['sub-add', 'http://host/b.vtt', 'auto', 'Subtitle 2', ''],
]);
});
test('a stream with no external tracks emits no track commands', () => {
assert.deepEqual(buildTrackCommands(stream()), []);
});
test('selectPreferredStream skips dub entries in favour of the original audio', () => {
const streams = [
stream({ quality: '1080p (Dub)' }),
stream({ quality: '720p (Sub)' }),
stream({ quality: '480p (Dub)' }),
];
// Language beats the quality hint: a 1080p dub is the wrong file, not a
// better one.
assert.equal(selectPreferredStream(streams)?.quality, '720p (Sub)');
assert.equal(selectPreferredStream(streams, '1080')?.quality, '720p (Sub)');
});
test('selectPreferredStream prefers an entry carrying a Japanese audio track', () => {
const streams = [
stream({ quality: '1080p' }),
stream({ quality: '720p', audios: [{ url: 'http://host/ja.m4a', lang: 'ja' }] }),
];
assert.equal(selectPreferredStream(streams)?.quality, '720p');
});
test('an all-dub list still plays rather than failing', () => {
const streams = [stream({ quality: '1080p Dub' }), stream({ quality: '720p Dub' })];
assert.equal(selectPreferredStream(streams)?.quality, '1080p Dub');
assert.equal(selectPreferredStream(streams, '720')?.quality, '720p Dub');
});
test('selectPreferredStream honours a quality hint, else takes the first', () => {
const streams = [stream({ quality: '360p' }), stream({ quality: '1080p' })];
assert.equal(selectPreferredStream(streams, '1080')?.quality, '1080p');
assert.equal(selectPreferredStream(streams, '1080P')?.quality, '1080p');
// Extensions label streams with the host name, so the hint matches a substring.
const decorated = [
stream({ quality: 'Doodstream - 360p' }),
stream({ quality: 'Vidhide - 720p' }),
];
assert.equal(selectPreferredStream(decorated, '720')?.quality, 'Vidhide - 720p');
// Extensions pre-sort by their own preference, so the first entry wins.
assert.equal(selectPreferredStream(streams)?.quality, '360p');
// A hint that matches nothing falls back rather than failing.
assert.equal(selectPreferredStream(streams, '4k')?.quality, '360p');
});
test('selectPreferredStream returns null for an empty list', () => {
assert.equal(selectPreferredStream([]), null);
assert.equal(selectPreferredStream([], '1080p'), null);
});
+260
View File
@@ -0,0 +1,260 @@
import { toMpvHeaderFields } from './headers';
import type { ResolvedStream } from './types';
/**
* Japanese first, always, and for subtitles Japanese *only*: the primary slot
* belongs to the language being mined, and an English track belongs in the
* secondary slot, where the `secondarySub` machinery puts it by language tag.
* For audio, mpv falls back to the first track when nothing matches, so an
* English-only release still plays.
*/
export const JAPANESE_LANGUAGE_PREFERENCE = 'ja,jpn,jp,japanese';
/**
* mpv must not scan the filesystem for sidecar subtitles when the "file" is a
* network stream, and the secondary slot stays empty so the overlay only ever
* reads one track. Everything else is left to normal track selection, driven
* by the language preferences above.
*
* `alang`/`slang` are set as properties instead of file-local options: their
* values are comma-separated lists, and a comma inside a `loadfile` option
* value splits the option list.
*/
const BASE_LOADFILE_OPTIONS = [
'sub-auto=no',
'secondary-sid=no',
'secondary-sub-visibility=no',
'sub-visibility=yes',
];
/** Matches a language tag or a label such as "Japanese (Sub)" or "[JPN]". */
const JAPANESE_PATTERN = /(^|[^a-z])(ja|jp|jpn|japanese|日本語)([^a-z]|$)/i;
/** Extensions label dub entries in the quality string, e.g. "1080p (Dub)". */
const DUB_PATTERN = /(^|[^a-z])(dub|dubbed|dublado|latino|castellano)([^a-z]|$)/i;
/** The counterpart label for original-audio entries, e.g. "SUB - 1080p". */
const SUBBED_PATTERN = /(^|[^a-z])(sub|subbed|softsub|hardsub|subtitulado|raw)([^a-z]|$)/i;
export type MpvCommand = Array<string | number>;
export interface BuildPlaybackOptions {
stream: ResolvedStream;
/** Shown as the mpv window/OSD title. */
title?: string;
/** Resume position in seconds. */
startSeconds?: number;
}
export function isJapaneseTag(value: string): boolean {
return JAPANESE_PATTERN.test(value);
}
/**
* Build the mpv `loadfile` option string for a stream.
*
* Headers ride as `file-local-options/http-header-fields` so they apply to this
* file only, and so SubMiner's Anki media path can read them back off mpv when
* generating card audio and screenshots. Tracks added later with `sub-add` /
* `audio-add` inherit them too, which is how external tracks on an
* authenticated host stay reachable.
*/
export function buildLoadfileOptions(options: BuildPlaybackOptions): string {
const parts = [...BASE_LOADFILE_OPTIONS];
const headerFields = toMpvHeaderFields(options.stream.headers);
if (headerFields.length > 0) {
// Escape the mpv option-list separators so a header never splits the list.
parts.push(`http-header-fields=${escapeOptionValue(headerFields)}`);
}
if (options.startSeconds !== undefined && options.startSeconds > 0) {
parts.push(`start=${options.startSeconds}`);
}
return parts.join(',');
}
/**
* Build the file-local options needed when a resolved stream waits in mpv's
* playlist. Unlike the regular playback path, there is no opportunity to set
* global properties immediately before mpv advances to this file.
*/
export function buildQueuedLoadfileOptions(options: BuildPlaybackOptions): string {
const parts = [
buildLoadfileOptions(options),
`alang=${escapeOptionValue(JAPANESE_LANGUAGE_PREFERENCE)}`,
`slang=${escapeOptionValue(JAPANESE_LANGUAGE_PREFERENCE)}`,
];
if (options.title !== undefined && options.title.length > 0) {
parts.push(`force-media-title=${escapeOptionValue(options.title)}`);
}
return parts.join(',');
}
/**
* mpv splits `loadfile` options on commas and `=`-separates keys, so a value
* containing either must be quoted. Percent-encoding is mpv's own escape for
* embedded separators in option values.
*
* The count is in UTF-8 bytes of the decoded value, not JS string units, so a
* non-ASCII header value (extensions supply these) would otherwise under-count
* and mpv would cut the value short.
*/
function escapeOptionValue(value: string): string {
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
}
/**
* Ordered mpv commands that start playback of a resolved stream.
*
* The plugin is told subtitles are being managed before the file loads, so the
* overlay does not flash the source's own tracks during the swap.
*/
export function buildPlaybackCommands(options: BuildPlaybackOptions): MpvCommand[] {
const commands: MpvCommand[] = [
['script-message', 'subminer-managed-subtitles-loading'],
['set_property', 'alang', JAPANESE_LANGUAGE_PREFERENCE],
['set_property', 'slang', JAPANESE_LANGUAGE_PREFERENCE],
['loadfile', options.stream.url, 'replace', -1, buildLoadfileOptions(options)],
];
if (options.title !== undefined && options.title.length > 0) {
commands.push(['set_property', 'force-media-title', options.title]);
}
return commands;
}
/** Append a fully resolved stream without replacing the file playing now. */
export function buildQueuedPlaybackCommands(options: BuildPlaybackOptions): MpvCommand[] {
return [['loadfile', options.stream.url, 'append-play', -1, buildQueuedLoadfileOptions(options)]];
}
/**
* Commands that attach the extension's external audio and subtitle tracks.
*
* These must be sent *after* the file is loading, so they are separate from
* {@link buildPlaybackCommands}. Every track is added even the ones we do not
* select so they show up in mpv's track menu and can be switched by hand.
*/
export function buildTrackCommands(stream: ResolvedStream): MpvCommand[] {
return [
...buildAddTrackCommands('audio-add', stream.audios, 'Audio'),
...buildAddTrackCommands('sub-add', stream.subtitles, 'Subtitle'),
];
}
/**
* Only a Japanese track is ever selected outright the primary slot is for
* the mining language. A non-Japanese track is added unselected: for audio,
* `alang`'s pick off the container stands; for subtitles, the `secondarySub`
* auto-load matches the track's language tag against the user's configured
* secondary languages and routes it to `secondary-sid` instead.
*/
function buildAddTrackCommands(
command: 'audio-add' | 'sub-add',
tracks: Array<{ url: string; lang: string }>,
kind: 'Audio' | 'Subtitle',
): MpvCommand[] {
const unique = dedupeByUrl(tracks);
const selected = unique.findIndex((track) => isJapaneseTag(track.lang));
return unique.map((track, index) => [
command,
track.url,
index === selected ? 'select' : 'auto',
track.lang || `${kind} ${index + 1}`,
normalizeLangTag(track.lang),
]);
}
/** Extension language labels mapped to the tags users put in config. */
const LANG_TAG_BY_LABEL: Record<string, string> = {
japanese: 'ja',
: 'ja',
english: 'en',
eng: 'en',
spanish: 'es',
español: 'es',
portuguese: 'pt',
português: 'pt',
french: 'fr',
français: 'fr',
german: 'de',
deutsch: 'de',
italian: 'it',
italiano: 'it',
indonesian: 'id',
arabic: 'ar',
russian: 'ru',
korean: 'ko',
chinese: 'zh',
thai: 'th',
vietnamese: 'vi',
};
/**
* mpv's `lang` field is what SubMiner's secondary-subtitle auto-load compares
* against `secondarySub.secondarySubLanguages`, so a label like "English" must
* become the tag a user would actually configure. Unknown labels pass through;
* matching is best-effort, and the raw label stays visible as the track title.
*/
export function normalizeLangTag(lang: string): string {
const trimmed = lang.trim();
if (isJapaneseTag(trimmed)) return 'ja';
const mapped = LANG_TAG_BY_LABEL[trimmed.toLowerCase()];
if (mapped !== undefined) return mapped;
if (/^[A-Za-z]{2,3}([-_][A-Za-z0-9]+)?$/.test(trimmed)) {
return trimmed.split(/[-_]/, 1)[0]?.toLowerCase() ?? trimmed.toLowerCase();
}
return trimmed;
}
function dedupeByUrl(
tracks: Array<{ url: string; lang: string }>,
): Array<{ url: string; lang: string }> {
const seen = new Set<string>();
return tracks.filter((track) => {
if (track.url.length === 0 || seen.has(track.url)) return false;
seen.add(track.url);
return true;
});
}
/**
* Rank a stream by how likely it is to carry Japanese audio.
*
* Sources commonly return the dub and the original as separate entries rather
* than as two audio tracks of one entry, so the choice of *entry* is the first
* place a dub can slip in.
*/
function scoreStream(stream: ResolvedStream): number {
if (stream.audios.some((audio) => isJapaneseTag(audio.lang))) return 2;
const label = stream.quality;
if (isJapaneseTag(label) || SUBBED_PATTERN.test(label)) return 1;
if (DUB_PATTERN.test(label)) return -1;
return 0;
}
/**
* Pick the best stream from an extension's video list.
*
* Japanese audio outranks the quality hint a 1080p dub is the wrong file, not
* a better one. Within the surviving entries the hint decides, and otherwise
* the extension's own ordering does.
*/
export function selectPreferredStream(
streams: ResolvedStream[],
preferredQuality?: string,
): ResolvedStream | null {
if (streams.length === 0) return null;
const best = Math.max(...streams.map(scoreStream));
const candidates = streams.filter((stream) => scoreStream(stream) === best);
if (preferredQuality !== undefined && preferredQuality.length > 0) {
const needle = preferredQuality.toLowerCase();
const match = candidates.find((stream) => stream.quality.toLowerCase().includes(needle));
if (match) return match;
}
return candidates[0] ?? null;
}
@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { interleave, mapSourcesConcurrently } from './multi-source-search';
const source = (id: string) => ({ id, name: `Source ${id}` });
test('mapSourcesConcurrently returns results in source order, not completion order', async () => {
const sources = [source('a'), source('b'), source('c')];
const delays: Record<string, number> = { a: 20, b: 0, c: 10 };
const { results, failures } = await mapSourcesConcurrently(sources, async (target) => {
await new Promise((resolve) => setTimeout(resolve, delays[target.id]));
return target.id;
});
assert.deepEqual(results, ['a', 'b', 'c']);
assert.deepEqual(failures, []);
});
test('a failing source is reported without losing the others', async () => {
const sources = [source('a'), source('b'), source('c')];
const { results, failures } = await mapSourcesConcurrently(sources, async (target) => {
if (target.id === 'b') throw new Error('login required');
return target.id;
});
assert.deepEqual(results, ['a', 'c']);
assert.deepEqual(failures, [{ sourceId: 'b', sourceName: 'Source b', error: 'login required' }]);
});
test('mapSourcesConcurrently never runs more than the concurrency limit at once', async () => {
const sources = ['a', 'b', 'c', 'd', 'e'].map(source);
let running = 0;
let peak = 0;
await mapSourcesConcurrently(
sources,
async () => {
running += 1;
peak = Math.max(peak, running);
await new Promise((resolve) => setTimeout(resolve, 5));
running -= 1;
},
2,
);
assert.equal(peak, 2);
});
test('mapSourcesConcurrently handles an empty source list', async () => {
const { results, failures } = await mapSourcesConcurrently([], async () => 'x');
assert.deepEqual(results, []);
assert.deepEqual(failures, []);
});
test('interleave takes one from each source before taking a second', () => {
assert.deepEqual(interleave([['a1', 'a2', 'a3'], ['b1'], ['c1', 'c2']]), [
'a1',
'b1',
'c1',
'a2',
'c2',
'a3',
]);
});
test('interleave ignores empty groups', () => {
assert.deepEqual(interleave([[], ['b1', 'b2'], []]), ['b1', 'b2']);
assert.deepEqual(interleave([]), []);
});
+85
View File
@@ -0,0 +1,85 @@
import type { SourceSearchFailure } from '../types/anime-browser';
/**
* Running one query against every installed source at once.
*
* Each source is a separate extension behind the same single-threaded bridge,
* so the fan-out is bounded rather than unleashed: a dozen extensions all
* uploading and searching at once starves the ones the user is waiting on.
*/
/** Enough to hide the latency of a slow source without queueing the bridge. */
const DEFAULT_CONCURRENCY = 4;
export interface SourceTarget {
id: string;
name: string;
}
export interface FanOutResult<T> {
/** One entry per source that succeeded, in source order. */
results: T[];
/** One entry per source that threw, in source order. */
failures: SourceSearchFailure[];
}
/**
* Run `task` against every source, at most `concurrency` at a time.
*
* A source that throws becomes a failure instead of rejecting the whole call
* one misconfigured extension must not hide every other source's results.
*/
export async function mapSourcesConcurrently<S extends SourceTarget, T>(
sources: S[],
task: (source: S) => Promise<T>,
concurrency: number = DEFAULT_CONCURRENCY,
): Promise<FanOutResult<T>> {
// Slots keep the output in source order regardless of completion order, so
// the same query lays out the same way twice.
const results: Array<{ value: T } | null> = sources.map(() => null);
const failures: Array<SourceSearchFailure | null> = sources.map(() => null);
let next = 0;
const worker = async (): Promise<void> => {
for (;;) {
const index = next;
next += 1;
const source = sources[index];
if (!source) return;
try {
results[index] = { value: await task(source) };
} catch (error) {
failures[index] = {
sourceId: source.id,
sourceName: source.name,
error: error instanceof Error ? error.message : String(error),
};
}
}
};
const workers = Math.max(1, Math.min(concurrency, sources.length));
await Promise.all(Array.from({ length: workers }, () => worker()));
return {
results: results
.filter((slot): slot is { value: T } => slot !== null)
.map((slot) => slot.value),
failures: failures.filter((slot): slot is SourceSearchFailure => slot !== null),
};
}
/**
* Round-robin merge, so the grid opens with one hit from each source rather
* than the whole of the first source before the second one starts.
*/
export function interleave<T>(groups: T[][]): T[] {
const merged: T[] = [];
const longest = groups.reduce((max, group) => Math.max(max, group.length), 0);
for (let index = 0; index < longest; index += 1) {
for (const group of groups) {
if (index < group.length) merged.push(group[index] as T);
}
}
return merged;
}
+133
View File
@@ -0,0 +1,133 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { watchPlaybackOutcome, type PlaybackEndFileEvent } from './playback-outcome';
type Harness = {
emitEndFile: (event: PlaybackEndFileEvent) => void;
listenerCount: () => number;
setProperty: (name: string, value: unknown) => void;
failProperty: (name: string) => void;
/** Virtual milliseconds burned so far, by sleeps and by property reads. */
elapsed: () => number;
};
/**
* The clock is virtual and only moves when the code under test sleeps (or,
* with `readCostMs`, when it reads a property), so timeout behaviour is
* asserted without any real waiting.
*/
function createHarness(overrides?: {
timeoutMs?: number;
probeIntervalMs?: number;
readCostMs?: number;
}) {
const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
const properties = new Map<string, unknown>();
const failing = new Set<string>();
const readCostMs = overrides?.readCostMs ?? 0;
let clock = 0;
const watch = watchPlaybackOutcome({
onEndFile: (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
},
readProperty: async (name) => {
clock += readCostMs;
if (failing.has(name)) throw new Error(`Failed to read MPV property '${name}'`);
return properties.get(name);
},
wait: async (ms) => {
clock += ms;
},
now: () => clock,
timeoutMs: overrides?.timeoutMs ?? 1000,
probeIntervalMs: overrides?.probeIntervalMs ?? 100,
});
const harness: Harness = {
emitEndFile: (event) => {
for (const listener of listeners) listener(event);
},
listenerCount: () => listeners.size,
setProperty: (name, value) => properties.set(name, value),
failProperty: (name) => failing.add(name),
elapsed: () => clock,
};
return { watch, harness };
}
test('resolves ok once mpv configures a video output', async () => {
const { watch, harness } = createHarness();
harness.setProperty('vo-configured', true);
const outcome = await watch.wait();
assert.deepEqual(outcome, { ok: true });
watch.dispose();
});
test('resolves failure with the mpv error when the file ends in error', async () => {
const { watch, harness } = createHarness();
const pending = watch.wait();
harness.emitEndFile({ reason: 'error', fileError: 'no audio or video data played' });
const outcome = await pending;
assert.equal(outcome.ok, false);
assert.ok(!outcome.ok && outcome.error.includes('no audio or video data played'));
watch.dispose();
});
test('ignores the end-file fired for the file being replaced', async () => {
const { watch, harness } = createHarness();
const pending = watch.wait();
harness.emitEndFile({ reason: 'stop', fileError: null });
harness.setProperty('vo-configured', true);
const outcome = await pending;
assert.deepEqual(outcome, { ok: true });
watch.dispose();
});
test('times out with a failure when nothing ever starts', async () => {
const { watch, harness } = createHarness({ timeoutMs: 300, probeIntervalMs: 100 });
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
assert.ok(!outcome.ok && outcome.error.length > 0);
assert.equal(harness.elapsed(), 300);
watch.dispose();
});
test('slow property reads eat the budget instead of extending it', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
probeIntervalMs: 100,
readCostMs: 250,
});
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
// Two probes: 250 + 50 (the sleep clamped to what was left) then 250 again.
assert.ok(harness.elapsed() >= 300, 'gave up before the timeout');
assert.ok(harness.elapsed() < 900, 'read delays stretched the timeout');
watch.dispose();
});
test('a zero probe interval still terminates at the deadline', async () => {
const { watch } = createHarness({ timeoutMs: 200, probeIntervalMs: 0, readCostMs: 50 });
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
watch.dispose();
});
test('keeps polling through property read failures', async () => {
const { watch, harness } = createHarness();
harness.failProperty('vo-configured');
const pending = watch.wait();
harness.emitEndFile({ reason: 'error', fileError: null });
const outcome = await pending;
assert.equal(outcome.ok, false);
watch.dispose();
});
test('dispose removes the end-file subscription', () => {
const { watch, harness } = createHarness();
assert.equal(harness.listenerCount(), 1);
watch.dispose();
assert.equal(harness.listenerCount(), 0);
});
+89
View File
@@ -0,0 +1,89 @@
/**
* Confirms that a `loadfile` handed to mpv actually turned into playback.
*
* Sending the command proves nothing: mpv accepts the file, fails to decode it
* (a dead host, a disguised stream the proxy could not fix), fires `end-file`
* with reason "error", and drops back to `--idle` with no window, because
* idle mpv shows none. The UI would happily say "Playing" over a blank desktop.
*
* Success is mpv configuring a video output (`vo-configured`), which is
* literally "a window with frames in it". `file-loaded` is not enough the
* broken stream in the wild reached it before dying. Failure is an `end-file`
* with reason "error"; the end-file of the file being *replaced* arrives with
* "stop"/"redirect" and is ignored.
*/
export interface PlaybackEndFileEvent {
reason: string;
fileError: string | null;
}
export type PlaybackOutcome = { ok: true } | { ok: false; error: string };
export interface WatchPlaybackOutcomeDeps {
/** Subscribe to mpv end-file events; returns the unsubscribe. */
onEndFile: (listener: (event: PlaybackEndFileEvent) => void) => () => void;
/** One-shot mpv property read; may reject while the file is still loading. */
readProperty: (name: string) => Promise<unknown>;
wait: (ms: number) => Promise<void>;
/** Injectable clock; the timeout is wall-clock, not a probe count. */
now?: () => number;
timeoutMs?: number;
probeIntervalMs?: number;
}
export interface PlaybackOutcomeWatch {
wait: () => Promise<PlaybackOutcome>;
dispose: () => void;
}
export const DEFAULT_PLAYBACK_OUTCOME_TIMEOUT_MS = 20_000;
const DEFAULT_PROBE_INTERVAL_MS = 500;
/**
* Call *before* sending `loadfile` so the error subscription cannot lose a
* race against a fast failure; await `wait()` after the commands went out.
*/
export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOutcomeWatch {
const timeoutMs = deps.timeoutMs ?? DEFAULT_PLAYBACK_OUTCOME_TIMEOUT_MS;
const probeIntervalMs = deps.probeIntervalMs ?? DEFAULT_PROBE_INTERVAL_MS;
let failure: PlaybackOutcome | null = null;
const unsubscribe = deps.onEndFile((event) => {
if (event.reason !== 'error') return;
failure = {
ok: false,
error: event.fileError
? `mpv could not play this stream: ${event.fileError}`
: 'mpv could not play this stream.',
};
});
async function wait(): Promise<PlaybackOutcome> {
// Wall-clock, not a probe count: a slow `readProperty` must eat into the
// budget rather than stretch it, and a zero probe interval must still end.
const now = deps.now ?? Date.now;
const deadline = now() + timeoutMs;
while (now() < deadline) {
if (failure) return failure;
try {
if ((await deps.readProperty('vo-configured')) === true) return { ok: true };
} catch {
// The property is unreadable while mpv is between files; keep polling.
}
if (failure) return failure;
// Sleeping past the deadline would only delay the timeout report.
const remaining = deadline - now();
if (remaining <= 0) break;
await deps.wait(Math.min(probeIntervalMs, remaining));
}
return (
failure ?? {
ok: false,
error: 'Playback did not start. mpv gave no error; try another server or quality.',
}
);
}
return { wait, dispose: unsubscribe };
}
+128
View File
@@ -0,0 +1,128 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmod, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { PreferenceStore } from './preference-store';
async function storeFile(): Promise<string> {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-prefs-'));
return path.join(dir, 'anime-preferences.json');
}
test('values round-trip through the file', async () => {
const file = await storeFile();
await new PreferenceStore(file).set('pkg', 'src-1', [{ key: 'address' }]);
assert.deepEqual(await new PreferenceStore(file).get('pkg', 'src-1'), [{ key: 'address' }]);
});
test('the same bridge source id is isolated between extension packages', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
await store.set('pkg.one', 'shared-source', [{ key: 'password', value: 'one-secret' }]);
await store.set('pkg.two', 'shared-source', [{ key: 'password', value: 'two-secret' }]);
const reloaded = new PreferenceStore(file);
assert.deepEqual(await reloaded.get('pkg.one', 'shared-source'), [
{ key: 'password', value: 'one-secret' },
]);
assert.deepEqual(await reloaded.get('pkg.two', 'shared-source'), [
{ key: 'password', value: 'two-secret' },
]);
});
test('a legacy bare source id is discarded rather than assigned to an unproven package', async () => {
const file = await storeFile();
await writeFile(file, JSON.stringify({ 'legacy-source': [{ key: 'address', value: 'saved' }] }));
const store = new PreferenceStore(file);
assert.deepEqual(await store.get('pkg.one', 'legacy-source'), []);
const persisted = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
assert.equal(persisted['legacy-source'], undefined);
assert.equal(persisted['pkg.one:legacy-source'], undefined);
});
test('an ambiguous legacy source id is discarded instead of exposed to either package', async () => {
const file = await storeFile();
await writeFile(file, JSON.stringify({ shared: [{ key: 'password', value: 'old-secret' }] }));
const store = new PreferenceStore(file);
assert.deepEqual(await store.get('pkg.one', 'shared'), []);
assert.deepEqual(await store.get('pkg.two', 'shared'), []);
const persisted = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown>;
assert.equal(persisted.shared, undefined);
});
test('concurrent writes on a cold cache do not lose an update', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
// Both start before either has loaded; unserialized they would each get their
// own object and the later persist would drop the other's entry.
await Promise.all([
store.set('pkg', 'src-1', [{ key: 'a' }]),
store.set('pkg', 'src-2', [{ key: 'b' }]),
]);
const reloaded = new PreferenceStore(file);
assert.deepEqual(await reloaded.get('pkg', 'src-1'), [{ key: 'a' }]);
assert.deepEqual(await reloaded.get('pkg', 'src-2'), [{ key: 'b' }]);
});
test('a clear racing a set is applied in order', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
await store.set('pkg', 'src', [{ key: 'password' }]);
await Promise.all([store.clear('pkg'), store.set('other', 'src', [{ key: 'x' }])]);
const reloaded = new PreferenceStore(file);
assert.deepEqual(await reloaded.get('pkg', 'src'), []);
assert.deepEqual(await reloaded.get('other', 'src'), [{ key: 'x' }]);
});
test('the file is written owner-only even when an existing temporary file is permissive', async () => {
const file = await storeFile();
await writeFile(`${file}.tmp`, 'stale');
await chmod(`${file}.tmp`, 0o666);
await new PreferenceStore(file).set('pkg', 'src-1', [{ key: 'password' }]);
assert.equal((await stat(file)).mode & 0o777, 0o600);
assert.deepEqual(await readdir(path.dirname(file)), [path.basename(file)]);
});
test('a corrupt file starts empty rather than blocking the browser', async () => {
const file = await storeFile();
await writeFile(file, '{ not json');
assert.deepEqual(await new PreferenceStore(file).get('pkg', 'src-1'), []);
});
test('malformed persisted values are filtered to preference objects with string keys', async () => {
const file = await storeFile();
await writeFile(
file,
JSON.stringify({
'pkg:source': [null, { key: 42 }, 'bad', { key: 'valid', value: 'kept' }],
'pkg:not-an-array': { key: 'invalid-container' },
}),
);
const store = new PreferenceStore(file);
assert.deepEqual(await store.get('pkg', 'source'), [{ key: 'valid', value: 'kept' }]);
assert.deepEqual(await store.get('pkg', 'not-an-array'), []);
});
test('a write replaces the previous contents wholesale', async () => {
const file = await storeFile();
const store = new PreferenceStore(file);
await store.set('pkg', 'src-1', [{ key: 'first' }]);
await store.set('pkg', 'src-1', [{ key: 'second' }]);
const parsed = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown[]>;
assert.deepEqual(parsed['pkg:src-1'], [{ key: 'second' }]);
});

Some files were not shown because too many files have changed in this diff Show More