Compare commits

..

27 Commits

Author SHA1 Message Date
sudacode a982debd2f 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-10 22:14:57 -07:00
sudacode 0ecaec34b2 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-07 00:25:37 -07:00
sudacode 5c778a9442 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-06 22:41:16 -07:00
sudacode 47d31e9628 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-06 22:02:38 -07:00
sudacode 4c6bfaa22e 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-06 21:48:33 -07:00
sudacode 7db247ef5b 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-06 21:48:33 -07:00
sudacode dd0307cf75 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-06 21:48:33 -07:00
sudacode df3fd96808 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-06 21:48:33 -07:00
sudacode c07ba665ba 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-06 21:48:33 -07:00
sudacode 30f79857d8 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-06 21:48:00 -07:00
sudacode 36cd19794e 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-06 21:48:00 -07:00
sudacode 1eb7f73b3b 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-06 21:48:00 -07:00
sudacode ebab843ce5 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-06 21:48:00 -07:00
sudacode 45ca02a7d8 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-06 21:48:00 -07:00
sudacode 23d790d8f2 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-06 21:48:00 -07:00
sudacode f51ecb69d1 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-06 21:48:00 -07:00
sudacode 5a44a4f39d 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-06 21:48:00 -07:00
sudacode 62758bf9cb 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-06 21:48:00 -07:00
sudacode b528440d48 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-06 21:48:00 -07:00
sudacode a34c3e95c5 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-06 21:48:00 -07:00
sudacode e72d38a4eb 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-06 21:48:00 -07:00
sudacode c1e6e44025 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-06 21:48:00 -07:00
sudacode 07a97fd64c 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-06 21:48:00 -07:00
sudacode f3840e1f4c 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-06 21:48:00 -07:00
sudacode 301714777a 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-06 21:48:00 -07:00
sudacode ca5fc341cb 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-06 21:48:00 -07:00
sudacode ee08512988 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-06 21:48:00 -07:00
299 changed files with 21618 additions and 8914 deletions
+20
View File
@@ -0,0 +1,20 @@
{
"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,45 +1,22 @@
--- ---
name: subminer-change-verification 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. description: 'Compatibility shim. Canonical SubMiner change verification workflow now lives in the repo-local subminer-workflow plugin.'
--- ---
# SubMiner Change Verification # Compatibility Shim
Verify the behavior claimed by a change without running unrelated expensive checks by default. Canonical source:
## Workflow - `plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md`
1. Inspect the requested scope and changed paths with `git status --short` and `git diff`. Canonical helper scripts:
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.
Do not use hidden wrapper commands. Verification commands are owned by `package.json` and the workflow documentation. - `plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh`
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
## Lane Selection When this shim is invoked:
- Internal docs, `AGENTS.md`, or `.agents/skills/**`: `bun run test:docs:kb` 1. Read the canonical plugin-owned skill.
- User-facing `docs-site/**`: `bun run docs:test`, then `bun run docs:build` 2. Follow the plugin-owned skill as the source of truth.
- Config/schema/defaults: `bun run test:config` 3. Use the wrapper scripts in this shim directory only for compatibility with existing commands and docs.
- If defaults or templates changed, also run `bun run generate:config-example` and `bun run verify:config-example`. 4. Do not duplicate workflow changes here; update the plugin-owned skill and scripts instead.
- 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.
@@ -0,0 +1,13 @@
#!/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" "$@"
@@ -0,0 +1,13 @@
#!/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" "$@"
@@ -0,0 +1,18 @@
---
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.
+15 -12
View File
@@ -1,4 +1,4 @@
# AGENTS.md # AGENTS.MD
## Internal Docs ## 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. `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 ## Quick Start
@@ -25,9 +25,8 @@ Start here, then leave this file.
## Build / Test ## Build / Test
- Runtime/package manager: Bun; use the version pinned by `package.json`. - Runtime/package manager: Bun (`packageManager: bun@1.3.5`)
- Follow [`docs/workflow/verification.md`](./docs/workflow/verification.md) and start with the cheapest sufficient lane. - Default handoff gate:
- Full handoff gate for substantial changes:
`bun run typecheck` `bun run typecheck`
`bun run test:fast` `bun run test:fast`
`bun run test:env` `bun run test:env`
@@ -45,15 +44,13 @@ Start here, then leave this file.
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat` - Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
- Stats dashboard UI (`stats/`): `bun run test:stats` - Stats dashboard UI (`stats/`): `bun run test:stats`
- Build/release scripts (`scripts/**`): `bun run test:scripts` - Build/release scripts (`scripts/**`): `bun run test:scripts`
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb` - Docs-only: `bun run docs:test`, then `bun run docs:build`
- 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` - Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
## Docs Upkeep ## 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. - 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): - 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 - `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` - shortcuts/keybindings (`shortcuts.*`, `keybindings`, `stats.*Key`, `subtitleSidebar.toggleKey`, controller bindings) → `docs-site/shortcuts.md`
@@ -74,10 +71,16 @@ Start here, then leave this file.
## Release / PR Notes ## Release / PR Notes
- 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 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 docs changes get a `type: docs` fragment - User-visible docs changes get a `type: docs` fragment
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check` - CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
- PR review helpers: - PR review helpers:
- `gh pr view --json number,title --jq '"PR #\\(.number): \\(.title)"'` - `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'`
- `gh api repos/:owner/:repo/pulls/<num>/comments --paginate` - `gh api repos/:owner/:repo/pulls/<num>/comments --paginate`
- For CI debugging, inspect runs with `gh run list/view`; rerun or fix only within the requested scope.
## 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`
-24
View File
@@ -1,29 +1,5 @@
# Changelog # 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) ## v0.19.2 (2026-08-04)
### Changed ### Changed
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+25 -12
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><b>Jellyfin</b></td>
<td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td> <td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
</tr> </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> <tr>
<td><b>Jimaku</b></td> <td><b>Jimaku</b></td>
<td>Search and download Japanese subtitles</td> <td>Search and download Japanese subtitles</td>
@@ -256,19 +260,28 @@ Full guides on configuration, Anki setup, Jellyfin, immersion tracking, and more
SubMiner builds on the work of these open-source projects: SubMiner builds on the work of these open-source projects:
| Project | Role | | Project | Role |
| ------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | | ------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| [ani-skip](https://github.com/synacktraa/ani-skip) | AniSkip API client for anime intro/outro skip timestamps | | [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 | | [Anacreon-Script](https://github.com/friedrich-de/Anacreon-Script) | Inspiration for the mining workflow |
| [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing | | [Aniyomi](https://github.com/aniyomiorg/aniyomi) | Anime extension API and data model the anime browser targets |
| [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles | | [asbplayer](https://github.com/killergerbah/asbplayer) | Inspiration for subtitle sidebar and logic for YouTube subtitle parsing |
| [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration | | [Bee's Character Dictionary](https://github.com/bee-san/Japanese_Character_Name_Dictionary) | Character name recognition in subtitles |
| [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration | | [GameSentenceMiner](https://github.com/bpwhelan/GameSentenceMiner) | Inspiration for Electron overlay with Yomitan integration |
| [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads | | [jellyfin-mpv-shim](https://github.com/jellyfin/jellyfin-mpv-shim) | Jellyfin integration |
| [Renji's Texthooker Page](https://github.com/Renji-XD/texthooker-ui) | Base for the WebSocket texthooker integration | | [Jimaku.cc](https://jimaku.cc) | Japanese subtitle search and downloads |
| [Yomitan](https://github.com/yomidevs/yomitan) | Dictionary engine powering all lookups and the morphological parser | | [M-Extension-Server](https://github.com/1Selxo/M-Extension-Server) | Runs Aniyomi extension APKs off Android; the bridge the anime browser drives |
| [yomitan-jlpt-vocab](https://github.com/stephenmk/yomitan-jlpt-vocab) | JLPT level tags for vocabulary | | [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 |
## License ## License
[GNU General Public License v3.0](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.
+2 -2
View File
@@ -41,7 +41,7 @@
"fast-uri": "3.1.5", "fast-uri": "3.1.5",
"form-data": "4.0.6", "form-data": "4.0.6",
"ip-address": "10.2.0", "ip-address": "10.2.0",
"js-yaml": "4.3.1", "js-yaml": "4.3.0",
"lodash": "4.18.0", "lodash": "4.18.0",
"minimatch": "10.2.5", "minimatch": "10.2.5",
"picomatch": "4.0.4", "picomatch": "4.0.4",
@@ -498,7 +498,7 @@
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
"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=="], "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
+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, so episodes from different series can be lined up together, and it starts the next episode when the current one ends — resolving its stream at that point, so a signed stream URL cannot expire while it waits. Queueing with nothing playing just plays. mpv's `keep-open` is held off while the queue waits, since it otherwise pauses at the end of a file forever, and the setting is restored once the queue empties.
@@ -1,4 +0,0 @@
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`.
+7
View File
@@ -0,0 +1,7 @@
type: added
area: overlay
- Added an in-app changelog modal, opened from the tray ("View Changelog") or the "What's New" button on the update-available notification, which now stays on screen so "Update" is still reachable after reading the notes. It renders inside the player bounds when a video is playing and in its own window otherwise, the same as the help modal.
- The changelog is fetched from the newest published release, so release notes for versions newer than the installed build are visible; a failed download falls back to the changelog bundled with the install and says so in the modal.
- Versions are foldable: the current `0.x` line is expanded and older lines are folded, matching the docs-site changelog. A badge marks the installed version and newer versions are tagged "New".
- Keyboard: `J`/`K` or arrows move between versions, `Enter` folds/unfolds, `R` refetches, `Esc` closes.
-5
View File
@@ -1,5 +0,0 @@
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.
@@ -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.
-8
View File
@@ -1,8 +0,0 @@
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.
+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.
@@ -0,0 +1,20 @@
type: changed
area: subtitles
- Subtitle tokenization no longer runs a duplicate full `parseText` pass per line: the termsFind scanner walk is now the only tokenizer and emits its own hoverable filler runs for unmatched text (parseText is kept only as an error fallback). This roughly halves the dictionary work per line.
- The Yomitan scanning helpers are now installed once per parser window (`__subminerYomitanScan`) instead of re-shipping and re-parsing a ~500-line script for every subtitle line; each line only evaluates a tiny call.
- termsFind lookups are cached across subtitle lines in a window-persistent LRU keyed by substring, so repeated particles and verb forms stop costing backend round trips. The cache invalidates on dictionary/settings changes and window reloads.
- The scanner walk now skips lookups at punctuation and whitespace positions (latin letters and digits still look up, e.g. Tシャツ). The shrinking-window retry ladder keeps following the consumed lengths the backend reports, and only blind guesses (windows the backend consumed whole, which tell it nothing) are capped at four per position. A line that hits that cap escalates to a single `parseText` for the whole line, so a hard line still resolves to dictionary tokens instead of an unparsed run, without letting the ladder run to one lookup per window length.
- Tokenizer runtime dependencies are built once instead of per line, fixing a JLPT lookup cache that never hit (it was keyed on a per-call closure identity and leaked a Map per line) and a `which mecab` availability check that re-ran synchronously on every line when MeCab is absent.
- Subtitle changes no longer restart the prefetch run per line (which discarded in-flight tokenization work); prefetch now only pauses for the live line and restarts on real seeks, cache invalidation, or option changes. Prefetch also stays paused for the whole time the subtitle processing controller is working on the line, including the provisional raw emit that precedes tokenization, so it never competes with the on-screen line for the parser window. The pause is released when the controller reports it has settled, which also covers the lines that finish without an emit (a suppressed duplicate or a failed tokenization) and used to leave prefetching paused indefinitely.
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
- Fixed a reading that stopped covering its surface when an unmatched kana run extended the preceding token (for example a trailing る on 待ち合わせ), which silently disabled the known-word reading fallback for those tokens.
- Subtitle prefetching no longer stays paused for the rest of a cue when the same subtitle text is reported twice and there is nothing to tokenize. This covers the startup and overlay priming paths as well as ordinary subtitle changes.
- Character name and image lookups are now refreshed centrally whenever a character dictionary sync changes its content, so a newly added name can no longer be skipped by a stale candidate list.
- A subtitle that was on screen when its annotations were invalidated (by mining a card, for example) is now re-annotated instead of staying plain for the rest of the line.
- Character name annotations no longer cost a dictionary lookup at every position in a line. The scanner now knows which name forms the current title's character dictionary actually contains and only checks where one can start, which removes the whole overhead of having the character dictionary enabled (measured: 21 lookups per line down to 10, the same as with it disabled). Titles with no cached character data keep the previous exhaustive scan, so a missing snapshot costs speed rather than a missing name.
- The cross-line termsFind cache is now bounded by the number of retained dictionary entries as well as by key count, so a run of lookups that each carry hundreds of entries with full glossaries cannot grow the parser window's memory without limit. The budget is re-checked when a lookup resolves, so a single oversized response is dropped rather than parked in the cache and reused.
- The unnamed-mob disambiguator filter (Girl A / Girl B) now only drops a single letter or digit split off a name, instead of every one-character term: a name that is genuinely one character keeps its terms whatever the script (𠮷, あ, 별 김, ア・ベ). The character dictionary and the scanner's name pre-pass also share one Han code-point table now, so a name the dictionary accepts is a name the scanner will look for.
- A character name written in halfwidth katakana takes part in the greedy name pre-pass again, so a longer generic word can no longer swallow the start of it, and it now carries a reading (it used to come out blank, which disables known-word matching and frequency lookups for the token). Voiced halfwidth kana compose properly, so ガク reads ガク rather than ガク, and kana normalization folds halfwidth throughout so those tokens compare equal to the same word written fullwidth. Because the fold makes halfwidth text indexable, the character-name prefilter now judges halfwidth spellings like any other, and a position only bypasses it when an unfoldable voiced mark sits inside the lookup window. That covers a name that starts on a kanji and turns halfwidth later (山ガク), and one stretched out with emphatic characters in between (山ーーーーーーガク).
- Dictionary-entry classification (source dictionaries, character-dictionary media ids) is memoized per entry object for as long as the entry is cached, instead of being recomputed for every headword comparison and every retry window.
- Autoplay priming no longer broadcasts the plain subtitle twice: it tells the processing controller the line has already been painted, so the controller goes straight to the annotated payload.
+12
View File
@@ -611,6 +611,18 @@
} // Lapis kiku setting. } // Lapis kiku setting.
}, // Automatic Anki updates and media generation options. }, // 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
// Jimaku API configuration and defaults. // Jimaku API configuration and defaults.
+1
View File
@@ -327,6 +327,7 @@ const sidebar: DefaultTheme.SidebarItem[] = [
{ text: 'Anki', link: '/anki-integration' }, { text: 'Anki', link: '/anki-integration' },
{ text: 'Jellyfin', link: '/jellyfin-integration' }, { text: 'Jellyfin', link: '/jellyfin-integration' },
{ text: 'YouTube', link: '/youtube-integration' }, { text: 'YouTube', link: '/youtube-integration' },
{ text: 'Anime Browser', link: '/anime-browser' },
{ text: 'Jimaku', link: '/jimaku-integration' }, { text: 'Jimaku', link: '/jimaku-integration' },
{ text: 'TsukiHime', link: '/tsukihime-integration' }, { text: 'TsukiHime', link: '/tsukihime-integration' },
{ text: 'AniList', link: '/anilist-integration' }, { text: 'AniList', link: '/anilist-integration' },
+309
View File
@@ -0,0 +1,309 @@
# 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).
### 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
-24
View File
@@ -1,29 +1,5 @@
# Changelog # 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) ## v0.19.2 (2026-08-04)
**Changed** **Changed**
+32 -3
View File
@@ -154,6 +154,7 @@ The configuration file includes several main sections:
**External Integrations** **External Integrations**
- [**Anime Browser**](#anime-browser) - Extension repositories and stream preferences for the anime browser
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults - [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download - [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync` - [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
@@ -1154,6 +1155,30 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
## External Integrations ## 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 ### Jimaku
Configure Jimaku API access and defaults: Configure Jimaku API access and defaults:
@@ -1214,11 +1239,15 @@ Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The
| Option | Values | Description | | Option | Values | Description |
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- | | ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
| `alass_path` | string path | Path to `alass` executable. Empty falls back to `/usr/bin/alass`. `alass` must be installed separately. | | `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 falls back to `/usr/bin/ffsubsync`. `ffsubsync` 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` falls back to `/usr/bin/ffmpeg`. | | `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>`. | | `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. 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`. Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
+1 -29
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`). - 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`). - 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. - 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 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. - 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.
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running. - Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
### Dashboard Tabs ### Dashboard Tabs
@@ -125,34 +125,6 @@ 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. 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 ## Retention Defaults
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance: By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
+21 -17
View File
@@ -23,12 +23,12 @@ If no files match the current episode filter, a "Show all files" button lets you
### Modal Keyboard Shortcuts ### Modal Keyboard Shortcuts
| Key | Action | | Key | Action |
| --- | --- | | ------------------------- | ---------------------------- |
| `Enter` (in text field) | Search | | `Enter` (in text field) | Search |
| `Enter` (in list) | Select entry / download file | | `Enter` (in list) | Select entry / download file |
| `Arrow Up` / `Arrow Down` | Navigate entries or files | | `Arrow Up` / `Arrow Down` | Navigate entries or files |
| `Escape` | Close modal | | `Escape` | Close modal |
## Configuration ## Configuration
@@ -41,26 +41,26 @@ Add a `jimaku` section to your `config.jsonc`:
"apiKeyCommand": "cat ~/.jimaku_key", "apiKeyCommand": "cat ~/.jimaku_key",
"apiBaseUrl": "https://jimaku.cc", "apiBaseUrl": "https://jimaku.cc",
"languagePreference": "ja", "languagePreference": "ja",
"maxEntryResults": 10 "maxEntryResults": 10,
} },
} }
``` ```
| Option | Type | Default | Description | | Option | Type | Default | Description |
| --- | --- | --- | --- | | --------------------------- | ---------------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `jimaku.apiKey` | `string` | - | Jimaku API key (plaintext). Mutually exclusive with `apiKeyCommand`. | | `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.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. | | `jimaku.apiBaseUrl` | `string` | `"https://jimaku.cc"` | Base URL for the Jimaku API. Only change this if using a mirror or local instance. |
| `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. | | `jimaku.languagePreference` | `"ja"` \| `"en"` \| `"none"` | `"ja"` | Sort subtitle files by language tag. `"ja"` pushes Japanese-tagged files to the top; `"en"` does the same for English. `"none"` preserves the API order. |
| `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. | | `jimaku.maxEntryResults` | `number` | `10` | Maximum number of anime entries returned per search. |
The keyboard shortcut is configured separately under `shortcuts`: The keyboard shortcut is configured separately under `shortcuts`:
```jsonc ```jsonc
{ {
"shortcuts": { "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` - **Season + episode patterns:** `S01E03`, `1x03`
- **Episode-only patterns:** `E03`, `EP03`, or dash-separated numbers like `Title - 03 -` - **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 - **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 - **Bracket tags:** `[SubGroup]`, `[1080p]`, `[HEVC]` - stripped before title extraction
- **Year tags:** `(2024)` - stripped - **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. 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 ## Troubleshooting
**"Jimaku API key not set"** **"Jimaku API key not set"**
-1
View File
@@ -151,7 +151,6 @@ subminer stats -b # start background stats daemon
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) | | `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 -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` | 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 stats rebuild` / `backfill` | Rebuild or backfill rollup data |
| `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) | | `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) |
| `subminer settings` | Open the SubMiner settings window | | `subminer settings` | Open the SubMiner settings window |
+12
View File
@@ -611,6 +611,18 @@
} // Lapis kiku setting. } // Lapis kiku setting.
}, // Automatic Anki updates and media generation options. }, // 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
// Jimaku API configuration and defaults. // Jimaku API configuration and defaults.
+44 -10
View File
@@ -171,9 +171,7 @@ Without FFmpeg, card creation still works but audio and image fields will be emp
**Audio or screenshot generation hangs** **Audio or screenshot generation hangs**
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. 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:
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. - Using a local copy of the video file.
- Reducing `ankiConnect.media.imageQuality` or switching from `avif` to `static` image type. - Reducing `ankiConnect.media.imageQuality` or switching from `avif` to `static` image type.
@@ -209,21 +207,26 @@ 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. 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: Install alass or configure the path:
- **Homebrew**: `brew install alass`
- **Arch Linux (AUR)**: `paru -S alass` - **Arch Linux (AUR)**: `paru -S alass`
- **Cargo**: `cargo install alass-cli` - **Cargo**: `cargo install alass-cli`
- Set the path: `subsync.alass_path` in your config. - 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: Install ffsubsync or configure the path:
- **Arch Linux (AUR)**: `paru -S python-ffsubsync` - **Arch Linux (AUR)**: `paru -S python-ffsubsync`
- **pip**: `pip install 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"** **"alass synchronization failed" / "ffsubsync synchronization failed"**
@@ -234,6 +237,38 @@ If subtitle sync fails (the error message is prefixed with the engine name):
- Try running the sync tool manually to see detailed error output. - 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). - 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 ## TsukiHime
**"xz binary not found"** **"xz binary not found"**
@@ -407,9 +442,8 @@ On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and othe
SubMiner handles this automatically: SubMiner handles this automatically:
- It launches its own window under XWayland (it sets `--ozone-platform=x11`). - 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=x11vk,x11egl,x11`) is applied. Only the window context is overridden; your `vo`/`gpu-api` and user shaders are left alone. - 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.
- 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 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. - 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. - The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
@@ -423,7 +457,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: 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 - 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=x11vk,x11egl,x11 …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11vk` (Vulkan) / `gpu-context=x11egl` (OpenGL) in your `mpv.conf`. - 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`.
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing). 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. 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: 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. 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. 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.
+3 -5
View File
@@ -70,6 +70,7 @@ subminer https://youtu.be/... # Play a YouTube URL
subminer stats # Open the immersion stats dashboard subminer stats # Open the immersion stats dashboard
subminer doctor # Check dependencies, config, and the mpv socket subminer doctor # Check dependencies, config, and the mpv socket
subminer settings # Open the SubMiner settings window subminer settings # Open the SubMiner settings window
subminer anime # Open the anime browser window
subminer app --setup # Re-open first-run setup subminer app --setup # Re-open first-run setup
subminer -u # Check for updates subminer -u # Check for updates
``` ```
@@ -95,8 +96,6 @@ subminer texthooker # Texthooker-only mode (-o also opens the brow
subminer stats -b # Start/reuse the background stats daemon subminer stats -b # Start/reuse the background stats daemon
subminer stats -s # Stop the background stats daemon subminer stats -s # Stop the background stats daemon
subminer stats cleanup # Backfill vocabulary metadata, prune stale rows 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 stats rebuild # Rebuild rollup data
subminer doctor --refresh-known-words # Refresh the known-word cache subminer doctor --refresh-known-words # Refresh the known-word cache
subminer logs -e # Export a sanitized log ZIP and print its path subminer logs -e # Export a sanitized log ZIP and print its path
@@ -109,8 +108,6 @@ subminer app --stop # Stop the background app
subminer --version # Print the launcher's version 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). 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> </details>
@@ -135,13 +132,14 @@ SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
SubMiner.AppImage --yomitan # Open Yomitan settings SubMiner.AppImage --yomitan # Open Yomitan settings
SubMiner.AppImage --settings # Open the SubMiner settings window SubMiner.AppImage --settings # Open the SubMiner settings window
SubMiner.AppImage --jellyfin # Open the Jellyfin setup 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 --dictionary # Generate a character dictionary ZIP
SubMiner.AppImage --start --dev # Enable app/dev mode SubMiner.AppImage --start --dev # Enable app/dev mode
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
SubMiner.AppImage --help # Show all options 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), 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`. 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`.
</details> </details>
@@ -64,23 +64,18 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
A cue parser extracts both timing and text content from subtitle files for prefetching. A cue parser extracts both timing and text content from subtitle files for prefetching.
**Parsed cue structure:** **Parsed cue structure:**
```typescript ```typescript
interface SubtitleCue { interface SubtitleCue {
startTime: number; // seconds startTime: number; // seconds
endTime: number; // seconds endTime: number; // seconds
text: string; // plain text, decoded from the source format text: string; // raw subtitle text
} }
``` ```
**Supported formats:** **Supported formats:**
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks. - SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
- 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: 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 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 #### Prefetch Service Lifecycle
@@ -158,7 +153,6 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
### Dependency Analysis ### Dependency Analysis
All annotations either depend on MeCab POS data or benefit from running after it: All annotations either depend on MeCab POS data or benefit from running after it:
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately. - **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data. - **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data. - **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
@@ -175,14 +169,18 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
// Single pass: known word + frequency filtering + JLPT computed together // Single pass: known word + frequency filtering + JLPT computed together
const annotated = tokens.map((token) => { const annotated = tokens.map((token) => {
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false; const isKnown = nPlusOneEnabled
? token.isKnown || computeIsKnown(token, deps)
: false;
// Filter frequency rank using POS exclusions (rank values already set at parser level) // Filter frequency rank using POS exclusions (rank values already set at parser level)
const frequencyRank = frequencyEnabled const frequencyRank = frequencyEnabled
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions) ? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
: undefined; : undefined;
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined; const jlptLevel = jlptEnabled
? computeJlptLevel(token, deps.getJlptLevel)
: undefined;
return { ...token, isKnown, frequencyRank, jlptLevel }; return { ...token, isKnown, frequencyRank, jlptLevel };
}); });
@@ -223,7 +221,6 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
### Current Behavior ### Current Behavior
In `renderWithTokens` (`subtitle-render.ts`), each render cycle: In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
1. Clears DOM with `innerHTML = ''` 1. Clears DOM with `innerHTML = ''`
2. Creates a `DocumentFragment` 2. Creates a `DocumentFragment`
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle) 3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
@@ -259,30 +256,27 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
## Combined Impact Summary ## Combined Impact Summary
| Scenario | Before | After | Improvement | | Scenario | Before | After | Improvement |
| --------------------------------- | ---------- | ---------- | ----------- | |----------|--------|-------|-------------|
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% | | Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% | | Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% | | Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
--- ---
## Files Summary ## Files Summary
### New Files ### New Files
- `src/core/services/subtitle-prefetch.ts` - `src/core/services/subtitle-prefetch.ts`
- `src/core/services/subtitle-cue-parser.ts` - `src/core/services/subtitle-cue-parser.ts`
### Modified Files ### Modified Files
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`) - `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass) - `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
- `src/renderer/subtitle-render.ts` (template cloneNode) - `src/renderer/subtitle-render.ts` (template cloneNode)
- `src/main.ts` (wire up prefetch service) - `src/main.ts` (wire up prefetch service)
### Test Files ### Test Files
- New tests for subtitle cue parser (SRT, VTT, ASS formats) - New tests for subtitle cue parser (SRT, VTT, ASS formats)
- New tests for subtitle prefetch service (priority window, seek, pause/resume) - New tests for subtitle prefetch service (priority window, seek, pause/resume)
- Updated tests for annotation stage (same behavior, new implementation) - Updated tests for annotation stage (same behavior, new implementation)
+8 -2
View File
@@ -3,7 +3,7 @@
# Domain Ownership # Domain Ownership
Status: active Status: active
Last verified: 2026-07-15 Last verified: 2026-08-02
Owner: Kyle Yasuda Owner: Kyle Yasuda
Read when: you need to find the owner module for a behavior or test surface Read when: you need to find the owner module for a behavior or test surface
@@ -25,9 +25,14 @@ 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` - Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
- Immersion tracking: `src/core/services/immersion-tracker/` - 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. Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
`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/` - 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-*` - 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 is app-level rather than an mpv playlist (`src/main/runtime/anime-browser-queue.ts`):
a queued episode is resolved when its turn comes, driven off mpv's `end-file`
- Window trackers: `src/window-trackers/` - Window trackers: `src/window-trackers/`
- Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support - Stats HTTP app: `src/core/services/stats-server.ts`, with route groups and shared route support
in `src/core/services/stats-server/` in `src/core/services/stats-server/`
@@ -45,6 +50,7 @@ Read when: you need to find the owner module for a behavior or test surface
- Settings UI contracts: `src/types/settings.ts` - Settings UI contracts: `src/types/settings.ts`
- Session-binding contracts: `src/types/session-bindings.ts` - Session-binding contracts: `src/types/session-bindings.ts`
- Stats HTTP wire contracts: `src/types/stats-wire.ts`, `src/types/stats-http-contract.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` - Compatibility-only barrel: `src/types.ts`
## Ownership Heuristics ## Ownership Heuristics
+16 -16
View File
@@ -3,25 +3,25 @@
# Documentation Catalog # Documentation Catalog
Status: active Status: active
Last verified: 2026-08-13 Last verified: 2026-05-23
Owner: Kyle Yasuda Owner: Kyle Yasuda
Read when: finding internal docs or checking verification status Read when: finding internal docs or checking verification status
| Area | Path | Status | Last verified | Notes | | Area | Path | Status | Last verified | Notes |
| --- | --- | --- | --- | --- | | ------------------------ | ----------------------------------------------- | ------ | ------------- | ------------------------------------------ |
| KB home | `docs/README.md` | active | 2026-05-23 | internal entrypoint | | 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 | | 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 | | 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 | | 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 | | 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 | | 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 | | Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
| Workflow index | `docs/workflow/README.md` | active | 2026-08-13 | execution map | | Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans | | Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-13 | repo-local workflow skill ownership | | 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-08-13 | maintained verification lanes | | Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist | | Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
## Update Rules ## Update Rules
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
# 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.
@@ -0,0 +1,347 @@
# 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 # Workflow
Status: active Status: active
Last verified: 2026-08-13 Last verified: 2026-05-23
Owner: Kyle Yasuda Owner: Kyle Yasuda
Read when: planning or executing nontrivial work in this repo 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 - [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
- [Verification](./verification.md) - maintained test/build lanes and handoff gate - [Verification](./verification.md) - maintained test/build lanes and handoff gate
- [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership - [Agent Plugins](./agent-plugins.md) - repo-local plugin ownership for agent workflow skills
- [Release Guide](../RELEASING.md) - tagged release workflow - [Release Guide](../RELEASING.md) - tagged release workflow
## Default Flow ## Default Flow
+27
View File
@@ -0,0 +1,27 @@
<!-- 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
@@ -1,31 +0,0 @@
<!-- 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 # Verification
Status: active Status: active
Last verified: 2026-08-13 Last verified: 2026-07-06
Owner: Kyle Yasuda Owner: Kyle Yasuda
Read when: selecting the right verification lane for a change Read when: selecting the right verification lane for a change
## Lane Infrastructure ## Lane Infrastructure
- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by - Lane membership is defined once in `scripts/test-lanes.ts` and discovered by
directory, so new test files join their lane automatically; never hand-list test directory new test files join their lane automatically; never hand-list test
files in `package.json`. files in `package.json`.
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process - `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 (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 ## Cheap-First Lane Selection
- User-facing `docs-site/` changes: `bun run docs:test`, `bun run docs:build` - Docs-only boundary/content changes: `bun run docs:test`, `bun run docs:build`
- Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb` - Internal KB / `AGENTS.md` changes: `bun run test:docs:kb`
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed - 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` - Launcher/plugin: `bun run test:launcher` or `bun run test:env`
- Runtime-compat / compiled behavior: `bun run test:runtime:compat` - Runtime-compat / compiled behavior: `bun run test:runtime:compat`
+11
View File
@@ -16,6 +16,10 @@ type AppCommandDeps = {
appPath: string, appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'], logLevel: LauncherCommandContext['args']['logLevel'],
) => void; ) => void;
launchAnimeBrowserDetached: (
appPath: string,
logLevel: LauncherCommandContext['args']['logLevel'],
) => void;
}; };
const defaultAppCommandDeps: AppCommandDeps = { const defaultAppCommandDeps: AppCommandDeps = {
@@ -23,6 +27,8 @@ const defaultAppCommandDeps: AppCommandDeps = {
launchSyncUiDetached: (appPath, logLevel) => launchSyncUiDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'), launchAppCommandDetached(appPath, ['--sync-window'], logLevel, 'sync-ui'),
launchAppBackgroundDetached, launchAppBackgroundDetached,
launchAnimeBrowserDetached: (appPath, logLevel) =>
launchAppCommandDetached(appPath, ['--anime'], logLevel, 'anime'),
}; };
export function runAppPassthroughCommand( export function runAppPassthroughCommand(
@@ -37,6 +43,11 @@ export function runAppPassthroughCommand(
deps.runAppCommandWithInherit(appPath, ['--settings']); deps.runAppCommandWithInherit(appPath, ['--settings']);
return true; 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) { if (args.syncUi) {
deps.launchSyncUiDetached(appPath, args.logLevel); deps.launchSyncUiDetached(appPath, args.logLevel);
return true; return true;
@@ -207,6 +207,7 @@ test('app command starts default macOS background app detached from launcher', (
calls.push('attached'); calls.push('attached');
}, },
launchSyncUiDetached: () => calls.push('sync-ui'), launchSyncUiDetached: () => calls.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: (appPath, logLevel) => { launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${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'); calls.push('attached');
}, },
launchSyncUiDetached: () => calls.push('sync-ui'), launchSyncUiDetached: () => calls.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: (appPath, logLevel) => { launchAppBackgroundDetached: (appPath, logLevel) => {
calls.push(`detached:${appPath}:${logLevel}`); calls.push(`detached:${appPath}:${logLevel}`);
}, },
@@ -248,6 +250,7 @@ test('app command keeps explicit passthrough args attached', () => {
forwarded.push(appArgs); forwarded.push(appArgs);
}, },
launchSyncUiDetached: () => detached.push('sync-ui'), launchSyncUiDetached: () => detached.push('sync-ui'),
launchAnimeBrowserDetached: () => {},
launchAppBackgroundDetached: () => { launchAppBackgroundDetached: () => {
detached.push('detached'); detached.push('detached');
}, },
@@ -266,6 +269,7 @@ test('sync UI command launches the app detached from the terminal', () => {
const handled = runAppPassthroughCommand(context, { const handled = runAppPassthroughCommand(context, {
runAppCommandWithInherit: () => calls.push('piped'), runAppCommandWithInherit: () => calls.push('piped'),
launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`), launchSyncUiDetached: (appPath, logLevel) => calls.push(`sync-ui:${appPath}:${logLevel}`),
launchAnimeBrowserDetached: () => calls.push('anime'),
launchAppBackgroundDetached: () => calls.push('detached'), launchAppBackgroundDetached: () => calls.push('detached'),
}); });
@@ -63,6 +63,7 @@ function createContext(): LauncherCommandContext {
logsExport: false, logsExport: false,
version: false, version: false,
settings: false, settings: false,
animeBrowser: false,
configPath: false, configPath: false,
configShow: false, configShow: false,
mpvIdle: false, mpvIdle: false,
-9
View File
@@ -157,15 +157,6 @@ export async function runStatsCommand(
if (args.statsCleanupLifetime) { if (args.statsCleanupLifetime) {
forwarded.push('--stats-cleanup-lifetime'); 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)) { if (shouldForwardLogLevel(args.logLevel)) {
forwarded.push('--log-level', args.logLevel); forwarded.push('--log-level', args.logLevel);
} }
+4 -12
View File
@@ -120,6 +120,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
logLevel: 'warn', logLevel: 'warn',
}, },
settingsInvocation: null, settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null, mpvInvocation: null,
appInvocation: null, appInvocation: null,
dictionaryTriggered: false, dictionaryTriggered: false,
@@ -134,9 +135,6 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
statsCleanup: false, statsCleanup: false,
statsCleanupVocab: false, statsCleanupVocab: false,
statsCleanupLifetime: false, statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null, statsLogLevel: null,
syncTriggered: false, syncTriggered: false,
syncCliTokens: [], syncCliTokens: [],
@@ -174,6 +172,7 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
settingsInvocation: { settingsInvocation: {
logLevel: undefined, logLevel: undefined,
}, },
animeInvocation: null,
mpvInvocation: null, mpvInvocation: null,
appInvocation: null, appInvocation: null,
dictionaryTriggered: false, dictionaryTriggered: false,
@@ -188,9 +187,6 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
statsCleanup: false, statsCleanup: false,
statsCleanupVocab: false, statsCleanupVocab: false,
statsCleanupLifetime: false, statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null, statsLogLevel: null,
syncTriggered: false, syncTriggered: false,
syncCliTokens: [], syncCliTokens: [],
@@ -221,6 +217,7 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
action: undefined, action: undefined,
}, },
settingsInvocation: null, settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null, mpvInvocation: null,
appInvocation: null, appInvocation: null,
dictionaryTriggered: false, dictionaryTriggered: false,
@@ -235,9 +232,6 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
statsCleanup: false, statsCleanup: false,
statsCleanupVocab: false, statsCleanupVocab: false,
statsCleanupLifetime: false, statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null, statsLogLevel: null,
syncTriggered: false, syncTriggered: false,
syncCliTokens: [], syncCliTokens: [],
@@ -266,6 +260,7 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
jellyfinInvocation: null, jellyfinInvocation: null,
configInvocation: null, configInvocation: null,
settingsInvocation: null, settingsInvocation: null,
animeInvocation: null,
mpvInvocation: null, mpvInvocation: null,
appInvocation: null, appInvocation: null,
dictionaryTriggered: false, dictionaryTriggered: false,
@@ -280,9 +275,6 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
statsCleanup: false, statsCleanup: false,
statsCleanupVocab: false, statsCleanupVocab: false,
statsCleanupLifetime: false, statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
statsCleanupLookbackDays: null,
statsLogLevel: null, statsLogLevel: null,
syncTriggered: false, syncTriggered: false,
syncCliTokens: [], syncCliTokens: [],
+7 -7
View File
@@ -162,14 +162,13 @@ export function createDefaultArgs(
statsCleanup: false, statsCleanup: false,
statsCleanupVocab: false, statsCleanupVocab: false,
statsCleanupLifetime: false, statsCleanupLifetime: false,
statsCleanupDuplicateLines: false,
statsCleanupDryRun: false,
doctor: false, doctor: false,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
logsExport: false, logsExport: false,
version: false, version: false,
update: false, update: false,
settings: false, settings: false,
animeBrowser: false,
configPath: false, configPath: false,
configShow: false, configShow: false,
mpvIdle: false, mpvIdle: false,
@@ -260,11 +259,6 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
if (invocations.statsCleanup) parsed.statsCleanup = true; if (invocations.statsCleanup) parsed.statsCleanup = true;
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true; if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = 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) { if (invocations.dictionaryTarget) {
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget); parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
} else if ( } else if (
@@ -355,6 +349,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) {
if (invocations.settingsInvocation.logLevel) { if (invocations.settingsInvocation.logLevel) {
parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel); parsed.logLevel = parseLogLevel(invocations.settingsInvocation.logLevel);
+17 -47
View File
@@ -23,6 +23,7 @@ export interface CliInvocations {
jellyfinInvocation: JellyfinInvocation | null; jellyfinInvocation: JellyfinInvocation | null;
configInvocation: CommandActionInvocation | null; configInvocation: CommandActionInvocation | null;
settingsInvocation: CommandActionInvocation | null; settingsInvocation: CommandActionInvocation | null;
animeInvocation: CommandActionInvocation | null;
mpvInvocation: CommandActionInvocation | null; mpvInvocation: CommandActionInvocation | null;
appInvocation: { appArgs: string[] } | null; appInvocation: { appArgs: string[] } | null;
dictionaryTriggered: boolean; dictionaryTriggered: boolean;
@@ -37,9 +38,6 @@ export interface CliInvocations {
statsCleanup: boolean; statsCleanup: boolean;
statsCleanupVocab: boolean; statsCleanupVocab: boolean;
statsCleanupLifetime: boolean; statsCleanupLifetime: boolean;
statsCleanupDuplicateLines: boolean;
statsCleanupDryRun: boolean;
statsCleanupLookbackDays: number | null;
statsLogLevel: string | null; statsLogLevel: string | null;
syncTriggered: boolean; syncTriggered: boolean;
syncCliTokens: string[]; syncCliTokens: string[];
@@ -56,16 +54,6 @@ export interface CliInvocations {
texthookerOpenBrowser: boolean; 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 { function applyRootOptions(program: Command): void {
program program
.option( .option(
@@ -115,6 +103,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'doctor', 'doctor',
'config', 'config',
'settings', 'settings',
'anime',
'mpv', 'mpv',
'logs', 'logs',
'dictionary', 'dictionary',
@@ -168,6 +157,7 @@ export function parseCliPrograms(
let jellyfinInvocation: JellyfinInvocation | null = null; let jellyfinInvocation: JellyfinInvocation | null = null;
let configInvocation: CommandActionInvocation | null = null; let configInvocation: CommandActionInvocation | null = null;
let settingsInvocation: CommandActionInvocation | null = null; let settingsInvocation: CommandActionInvocation | null = null;
let animeInvocation: CommandActionInvocation | null = null;
let mpvInvocation: CommandActionInvocation | null = null; let mpvInvocation: CommandActionInvocation | null = null;
let appInvocation: { appArgs: string[] } | null = null; let appInvocation: { appArgs: string[] } | null = null;
let dictionaryTriggered = false; let dictionaryTriggered = false;
@@ -182,9 +172,6 @@ export function parseCliPrograms(
let statsCleanup = false; let statsCleanup = false;
let statsCleanupVocab = false; let statsCleanupVocab = false;
let statsCleanupLifetime = false; let statsCleanupLifetime = false;
let statsCleanupDuplicateLines = false;
let statsCleanupDryRun = false;
let statsCleanupLookbackDays: number | null = null;
let statsLogLevel: string | null = null; let statsLogLevel: string | null = null;
let syncTriggered = false; let syncTriggered = false;
let syncCliTokens: string[] = []; let syncCliTokens: string[] = [];
@@ -285,9 +272,6 @@ export function parseCliPrograms(
.option('-s, --stop', 'Stop the background stats server') .option('-s, --stop', 'Stop the background stats server')
.option('-v, --vocab', 'Clean vocabulary rows in the stats database') .option('-v, --vocab', 'Clean vocabulary rows in the stats database')
.option('-l, --lifetime', 'Rebuild lifetime summary rows from retained data') .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') .option('--log-level <level>', 'Log level')
.action((action: string | undefined, options: Record<string, unknown>) => { .action((action: string | undefined, options: Record<string, unknown>) => {
statsTriggered = true; statsTriggered = true;
@@ -308,35 +292,13 @@ export function parseCliPrograms(
if (normalizedAction && (statsBackground || statsStop)) { if (normalizedAction && (statsBackground || statsStop)) {
throw new Error('Stats background and stop flags cannot be combined with stats actions.'); throw new Error('Stats background and stop flags cannot be combined with stats actions.');
} }
if ( if (normalizedAction !== 'cleanup' && (options.vocab === true || options.lifetime === true)) {
normalizedAction !== 'cleanup' && throw new Error('Stats --vocab and --lifetime flags require the cleanup action.');
(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') { if (normalizedAction === 'cleanup') {
statsCleanup = true; statsCleanup = true;
statsCleanupLifetime = options.lifetime === true; statsCleanupLifetime = options.lifetime === true;
statsCleanupDuplicateLines = options.duplicateLines === true; statsCleanupVocab = statsCleanupLifetime ? false : options.vocab !== false;
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') { } else if (normalizedAction === 'rebuild' || normalizedAction === 'backfill') {
statsCleanup = true; statsCleanup = true;
statsCleanupLifetime = true; statsCleanupLifetime = true;
@@ -456,6 +418,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 commandProgram
.command('mpv') .command('mpv')
.description('MPV helpers') .description('MPV helpers')
@@ -510,6 +482,7 @@ export function parseCliPrograms(
jellyfinInvocation, jellyfinInvocation,
configInvocation, configInvocation,
settingsInvocation, settingsInvocation,
animeInvocation,
mpvInvocation, mpvInvocation,
appInvocation, appInvocation,
dictionaryTriggered, dictionaryTriggered,
@@ -524,9 +497,6 @@ export function parseCliPrograms(
statsCleanup, statsCleanup,
statsCleanupVocab, statsCleanupVocab,
statsCleanupLifetime, statsCleanupLifetime,
statsCleanupDuplicateLines,
statsCleanupDryRun,
statsCleanupLookbackDays,
statsLogLevel, statsLogLevel,
syncTriggered, syncTriggered,
syncCliTokens, syncCliTokens,
+1
View File
@@ -57,6 +57,7 @@ function createArgs(): Args {
logsExport: false, logsExport: false,
version: false, version: false,
settings: false, settings: false,
animeBrowser: false,
configPath: false, configPath: false,
configShow: false, configShow: false,
mpvIdle: false, mpvIdle: false,
+8 -5
View File
@@ -222,7 +222,7 @@ test('buildMpvEnv preserves native Wayland env for supported Hyprland and Sway a
}); });
}); });
test('buildMpvBackendArgs pins the X11 window context when backend resolves to x11', () => { test('buildMpvBackendArgs forces an explicit X11 renderer stack when backend resolves to x11', () => {
withPlatform('linux', () => { withPlatform('linux', () => {
assert.deepEqual( assert.deepEqual(
buildMpvBackendArgs(makeArgs({ backend: 'x11' }), { buildMpvBackendArgs(makeArgs({ backend: 'x11' }), {
@@ -230,12 +230,12 @@ test('buildMpvBackendArgs pins the X11 window context when backend resolves to x
WAYLAND_DISPLAY: 'wayland-0', WAYLAND_DISPLAY: 'wayland-0',
XDG_SESSION_TYPE: 'wayland', XDG_SESSION_TYPE: 'wayland',
}), }),
['--gpu-context=x11vk,x11egl,x11'], ['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
); );
}); });
}); });
test('buildMpvBackendArgs pins the same X11 window context for unsupported Wayland auto fallback', () => { test('buildMpvBackendArgs forces the same X11 renderer stack for unsupported Wayland auto fallback', () => {
withPlatform('linux', () => { withPlatform('linux', () => {
assert.deepEqual( assert.deepEqual(
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), { buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
@@ -245,7 +245,7 @@ test('buildMpvBackendArgs pins the same X11 window context for unsupported Wayla
XDG_CURRENT_DESKTOP: 'KDE', XDG_CURRENT_DESKTOP: 'KDE',
XDG_SESSION_DESKTOP: 'plasma', XDG_SESSION_DESKTOP: 'plasma',
}), }),
['--gpu-context=x11vk,x11egl,x11'], ['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
); );
}); });
}); });
@@ -292,7 +292,9 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
'--secondary-sub-visibility=no', '--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us', '--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--gpu-context=x11vk,x11egl,x11', '--vo=gpu',
'--gpu-api=opengl',
'--gpu-context=x11egl,x11',
'--window-maximized=yes', '--window-maximized=yes',
], ],
); );
@@ -627,6 +629,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
logsExport: false, logsExport: false,
version: false, version: false,
settings: false, settings: false,
animeBrowser: false,
configPath: false, configPath: false,
configShow: false, configShow: false,
mpvIdle: false, mpvIdle: false,
+1 -73
View File
@@ -232,75 +232,6 @@ test('parseArgs maps lifetime stats cleanup flag', () => {
assert.equal(parsed.statsCleanupLifetime, true); 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', () => { test('parseArgs rejects cleanup-only stats flags without cleanup action', () => {
const error = withProcessExitIntercept(() => { const error = withProcessExitIntercept(() => {
parseArgs(['stats', '--vocab'], 'subminer', {}); parseArgs(['stats', '--vocab'], 'subminer', {});
@@ -308,10 +239,7 @@ test('parseArgs rejects cleanup-only stats flags without cleanup action', () =>
assert.equal(error.code, 1); assert.equal(error.code, 1);
assert.match(error.message, /exit:1/); assert.match(error.message, /exit:1/);
assert.match( assert.match(error.stderr, /Stats --vocab and --lifetime flags require the cleanup action/);
error.stderr,
/Stats --vocab, --lifetime and --duplicate-lines flags require the cleanup action/,
);
}); });
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => { test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
+1 -3
View File
@@ -142,9 +142,6 @@ export interface Args {
statsCleanup?: boolean; statsCleanup?: boolean;
statsCleanupVocab?: boolean; statsCleanupVocab?: boolean;
statsCleanupLifetime?: boolean; statsCleanupLifetime?: boolean;
statsCleanupDuplicateLines?: boolean;
statsCleanupDryRun?: boolean;
statsCleanupLookbackDays?: number;
dictionaryTarget?: string; dictionaryTarget?: string;
doctor: boolean; doctor: boolean;
doctorRefreshKnownWords: boolean; doctorRefreshKnownWords: boolean;
@@ -152,6 +149,7 @@ export interface Args {
version: boolean; version: boolean;
update?: boolean; update?: boolean;
settings: boolean; settings: boolean;
animeBrowser: boolean;
configPath: boolean; configPath: boolean;
configShow: boolean; configShow: boolean;
mpvIdle: boolean; mpvIdle: boolean;
+4 -3
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.19.3", "version": "0.19.2",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration", "description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5", "packageManager": "bun@1.3.5",
"main": "dist/main-entry.js", "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: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", "build:stats": "cd stats && bun run build",
"dev:stats": "cd stats && bun run dev", "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: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: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: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:build": "bun run scripts/build-changelog.ts build-release",
"changelog:check": "bun run scripts/build-changelog.ts check", "changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs", "changelog:docs": "bun run scripts/build-changelog.ts docs",
@@ -89,7 +90,7 @@
"fast-uri": "3.1.5", "fast-uri": "3.1.5",
"form-data": "4.0.6", "form-data": "4.0.6",
"ip-address": "10.2.0", "ip-address": "10.2.0",
"js-yaml": "4.3.1", "js-yaml": "4.3.0",
"lodash": "4.18.0", "lodash": "4.18.0",
"minimatch": "10.2.5", "minimatch": "10.2.5",
"picomatch": "4.0.4", "picomatch": "4.0.4",
@@ -0,0 +1,30 @@
{
"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
@@ -0,0 +1,40 @@
<!-- 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
```
@@ -0,0 +1,143 @@
---
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.
@@ -0,0 +1,171 @@
#!/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
@@ -0,0 +1,537 @@
#!/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"
@@ -0,0 +1,118 @@
---
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
-1
View File
@@ -19,7 +19,6 @@ const requiredDocs = [
'docs/knowledge-base/catalog.md', 'docs/knowledge-base/catalog.md',
'docs/knowledge-base/quality.md', 'docs/knowledge-base/quality.md',
'docs/workflow/README.md', 'docs/workflow/README.md',
'docs/workflow/agent-skills.md',
'docs/workflow/planning.md', 'docs/workflow/planning.md',
'docs/workflow/verification.md', 'docs/workflow/verification.md',
] as const; ] as const;
+21 -2
View File
@@ -3,14 +3,33 @@ import assert from 'node:assert/strict';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; 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( const packageJson = JSON.parse(
fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'), fs.readFileSync(path.join(import.meta.dir, '..', 'package.json'), 'utf8'),
) as { scripts: Record<string, string> }; ) 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, /src\/preload-syncui\.ts/);
assert.match(command, /--bundle/); assert.match(command, /--bundle/);
assert.match(command, /--external:electron/); assert.match(command, /--external:electron/);
assert.match(command, /--outfile=dist\/preload-syncui\.js/); 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 settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui'); const syncUiSourceDir = path.join(repoRoot, 'src', 'syncui');
const syncUiOutputDir = path.join(repoRoot, 'dist', '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 scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift'); const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos'); const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
@@ -25,9 +27,11 @@ function copyFile(sourcePath, outputPath) {
fs.copyFileSync(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, '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'), { fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
recursive: true, recursive: true,
force: true, force: true,
@@ -47,6 +51,14 @@ function copySyncUiAssets() {
copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui'); copyAssets(syncUiSourceDir, syncUiOutputDir, 'syncui');
} }
function copyAnimeUiAssets() {
copyAssets(animeUiSourceDir, animeUiOutputDir, 'animeui', [
'style.css',
'detail.css',
'panels.css',
]);
}
function fallbackToMacosSource() { function fallbackToMacosSource() {
copyFile(macosHelperSourcePath, macosHelperSourceCopyPath); copyFile(macosHelperSourcePath, macosHelperSourceCopyPath);
process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`); process.stdout.write(`Staged macOS helper source fallback: ${macosHelperSourceCopyPath}\n`);
@@ -90,6 +102,7 @@ function main() {
copyRendererAssets(); copyRendererAssets();
copySettingsAssets(); copySettingsAssets();
copySyncUiAssets(); copySyncUiAssets();
copyAnimeUiAssets();
buildMacosHelper(); buildMacosHelper();
} }
+17 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import test from 'node:test'; import test from 'node:test';
const source = readFileSync('scripts/prepare-build-assets.mjs', 'utf8'); 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', '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*\]\)/,
);
});
@@ -0,0 +1,202 @@
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';
}
}
+231
View File
@@ -0,0 +1,231 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildLoadfileOptions,
buildPlaybackCommands,
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('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);
});
+238
View File
@@ -0,0 +1,238 @@
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(',');
}
/**
* 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;
}
/**
* 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' }]);
});
+130
View File
@@ -0,0 +1,130 @@
import { chmod, readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises';
import path from 'node:path';
import type { BridgePreference } from './types';
function parseStoredPreferences(value: unknown): Record<string, BridgePreference[]> {
if (value === null || typeof value !== 'object' || Array.isArray(value)) return {};
const parsed: Record<string, BridgePreference[]> = {};
for (const [key, entries] of Object.entries(value)) {
if (!Array.isArray(entries)) continue;
parsed[key] = entries.filter(
(entry): entry is BridgePreference =>
entry !== null &&
typeof entry === 'object' &&
!Array.isArray(entry) &&
typeof (entry as Record<string, unknown>).key === 'string',
);
}
return parsed;
}
/**
* Persists each source's preference array verbatim, keyed by extension package
* and bridge source id.
*
* Extensions keep credentials in here (the Jellyfin source stores a password),
* so the file is written with owner-only permissions.
*/
export class PreferenceStore {
private readonly file: string;
private cache: Record<string, BridgePreference[]> | null = null;
/**
* Mutations run one at a time. Two concurrent load-modify-persist cycles
* starting on a cold cache would each read their own object, and the later
* write would drop the earlier one's edit.
*/
private queue: Promise<unknown> = Promise.resolve();
constructor(file: string) {
this.file = file;
}
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
const result = this.queue.then(operation, operation);
// Keep the chain alive after a rejection so one failure cannot wedge it.
this.queue = result.catch(() => undefined);
return result;
}
private async load(): Promise<Record<string, BridgePreference[]>> {
if (this.cache !== null) return this.cache;
try {
const parsed = JSON.parse(await readFile(this.file, 'utf8')) as unknown;
this.cache = parseStoredPreferences(parsed);
} catch {
// Missing or corrupt file starts empty rather than blocking the browser.
this.cache = {};
}
return this.cache;
}
async get(pkg: string, sourceId: string): Promise<BridgePreference[]> {
return this.enqueue(async () => {
const all = await this.load();
const key = `${pkg}:${sourceId}`;
if (all[key]) return all[key];
// Bare source IDs predate package scoping and have no trustworthy owner.
// Never assign their credentials to whichever package happens to ask first.
const legacy = all[sourceId];
if (legacy) {
delete all[sourceId];
await this.persist(all);
}
return [];
});
}
async set(pkg: string, sourceId: string, preferences: BridgePreference[]): Promise<void> {
await this.enqueue(async () => {
const all = await this.load();
all[`${pkg}:${sourceId}`] = preferences;
await this.persist(all);
});
}
/**
* Drop every saved value whose key starts with `prefix`.
*
* Removing an extension should not leave its credentials on disk, and a
* source id is not knowable once the APK is gone so callers pass the
* package name and this clears anything recorded under it.
*/
async clear(prefix: string): Promise<void> {
await this.enqueue(async () => {
const all = await this.load();
let changed = false;
for (const key of Object.keys(all)) {
if (key === prefix || key.startsWith(`${prefix}:`)) {
delete all[key];
changed = true;
}
}
if (changed) await this.persist(all);
});
}
/**
* Write through a temporary file and rename into place.
*
* A write interrupted partway would otherwise leave truncated JSON, and
* `load()` treats unparseable content as empty which would quietly discard
* every saved credential.
*/
private async persist(all: Record<string, BridgePreference[]>): Promise<void> {
await mkdir(path.dirname(this.file), { recursive: true });
const temporary = `${this.file}.tmp`;
try {
await chmod(temporary, 0o600).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
await writeFile(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
await chmod(temporary, 0o600);
await rename(temporary, this.file);
} catch (error) {
await rm(temporary, { force: true }).catch(() => undefined);
throw error;
}
}
}
+143
View File
@@ -0,0 +1,143 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
applyPreferenceValue,
isSecretPreference,
parsePreferences,
type SourcePreferenceView,
} from './preferences';
import type { BridgePreference } from './types';
// Shapes taken from the real Jellyfin extension's preferencesAnime response.
const RAW: BridgePreference[] = [
{
key: 'host_url',
editTextPreference: {
title: 'Address',
summary: 'The server address',
value: '',
text: '',
},
},
{
key: 'password',
editTextPreference: { title: 'Password', summary: 'The user account password', value: '' },
},
{
key: 'pref_quality',
listPreference: {
title: 'Preferred quality',
summary: 'Preferred quality.',
valueIndex: 0,
entries: ['Source', '20 Mbps'],
entryValues: ['source', '20000000'],
},
},
{
key: 'pref_episode_details_key',
multiSelectListPreference: {
title: 'Additional details for episodes',
values: [],
entries: ['Overview', 'Runtime'],
entryValues: ['overview', 'runtime'],
},
},
{
key: 'pref_trust_cert',
switchPreferenceCompat: { title: 'Trust certificate', value: false },
},
];
function view(views: SourcePreferenceView[], key: string): SourcePreferenceView {
const found = views.find((candidate) => candidate.key === key);
assert.ok(found, `missing preference ${key}`);
return found;
}
test('parsePreferences flattens each widget type', () => {
const views = parsePreferences(RAW);
assert.equal(views.length, 5);
assert.equal(view(views, 'host_url').kind, 'text');
assert.equal(view(views, 'host_url').title, 'Address');
assert.equal(view(views, 'host_url').value, '');
const quality = view(views, 'pref_quality');
assert.equal(quality.kind, 'list');
// valueIndex 0 resolves through entryValues, not entries.
assert.equal(quality.value, 'source');
assert.deepEqual(quality.entries, ['Source', '20 Mbps']);
assert.deepEqual(view(views, 'pref_episode_details_key').value, []);
assert.equal(view(views, 'pref_trust_cert').value, false);
});
test('parsePreferences skips the bridge context entry and unknown widgets', () => {
const views = parsePreferences([
{ key: '__mangatan_bridge_context__', sourceId: '1' },
{ key: 'mystery', someFutureWidget: { title: 'X' } },
...RAW.slice(0, 1),
]);
assert.deepEqual(
views.map((v) => v.key),
['host_url'],
);
});
test('a list preference with no selection reads as empty', () => {
const views = parsePreferences([
{
key: 'library_pref',
listPreference: {
title: 'Select media library',
valueIndex: -1,
entries: [],
entryValues: [],
},
},
]);
assert.equal(view(views, 'library_pref').value, '');
});
test('applyPreferenceValue writes text into both value and text', () => {
const updated = applyPreferenceValue(RAW, 'host_url', 'https://media.example');
const body = updated.find((e) => e.key === 'host_url')!.editTextPreference as Record<
string,
unknown
>;
assert.equal(body.value, 'https://media.example');
assert.equal(body.text, 'https://media.example');
// Other entries are untouched.
assert.equal(parsePreferences(updated).length, RAW.length);
});
test('applyPreferenceValue moves a list preference by entry value', () => {
const updated = applyPreferenceValue(RAW, 'pref_quality', '20000000');
const body = updated.find((e) => e.key === 'pref_quality')!.listPreference as Record<
string,
unknown
>;
assert.equal(body.valueIndex, 1);
assert.equal(parsePreferences(updated).find((v) => v.key === 'pref_quality')?.value, '20000000');
});
test('applyPreferenceValue handles multi-select and switch widgets', () => {
const multi = applyPreferenceValue(RAW, 'pref_episode_details_key', ['overview']);
assert.deepEqual(
parsePreferences(multi).find((v) => v.key === 'pref_episode_details_key')?.value,
['overview'],
);
const toggled = applyPreferenceValue(RAW, 'pref_trust_cert', true);
assert.equal(parsePreferences(toggled).find((v) => v.key === 'pref_trust_cert')?.value, true);
});
test('applyPreferenceValue leaves unknown keys alone', () => {
assert.deepEqual(applyPreferenceValue(RAW, 'not-a-key', 'x'), RAW);
});
test('secrets are recognised by key or title', () => {
const views = parsePreferences(RAW);
assert.equal(isSecretPreference(view(views, 'password')), true);
assert.equal(isSecretPreference(view(views, 'host_url')), false);
});
+132
View File
@@ -0,0 +1,132 @@
import type { BridgePreference } from './types';
/**
* Extension preferences arrive as Android preference objects, one wrapper key
* per widget type. They are stored and sent back verbatim so the extension sees
* exactly the shape it produced; only the value field is edited.
*/
export type PreferenceKind = 'text' | 'list' | 'multi' | 'switch';
/** A preference flattened for rendering. */
export interface SourcePreferenceView {
key: string;
kind: PreferenceKind;
title: string;
summary: string | null;
/** Current value: string for text/list, string[] for multi, boolean for switch. */
value: string | string[] | boolean;
/** Display labels, parallel to entryValues. Empty for text/switch. */
entries: string[];
entryValues: string[];
}
const WIDGETS = {
editTextPreference: 'text',
listPreference: 'list',
multiSelectListPreference: 'multi',
switchPreferenceCompat: 'switch',
checkBoxPreference: 'switch',
} as const satisfies Record<string, PreferenceKind>;
type WidgetName = keyof typeof WIDGETS;
function widgetOf(
entry: BridgePreference,
): { name: WidgetName; body: Record<string, unknown> } | null {
for (const name of Object.keys(WIDGETS) as WidgetName[]) {
const body = entry[name];
if (body !== null && typeof body === 'object') {
return { name, body: body as Record<string, unknown> };
}
}
return null;
}
function stringList(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
/** Flatten bridge preference entries for display. Unknown widgets are skipped. */
export function parsePreferences(raw: BridgePreference[]): SourcePreferenceView[] {
const views: SourcePreferenceView[] = [];
for (const entry of raw) {
if (typeof entry.key !== 'string' || entry.key.startsWith('__')) continue;
const widget = widgetOf(entry);
if (!widget) continue;
const { body } = widget;
const kind = WIDGETS[widget.name];
const entries = stringList(body.entries);
const entryValues = stringList(body.entryValues);
let value: string | string[] | boolean;
if (kind === 'multi') {
value = stringList(body.values);
} else if (kind === 'switch') {
value = body.value === true;
} else if (kind === 'list') {
const index = typeof body.valueIndex === 'number' ? body.valueIndex : -1;
// valueIndex is -1 when the extension has no selection yet.
value = index >= 0 && index < entryValues.length ? entryValues[index]! : '';
} else {
value = typeof body.value === 'string' ? body.value : '';
}
views.push({
key: entry.key,
kind,
title: typeof body.title === 'string' ? body.title : entry.key,
summary: typeof body.summary === 'string' ? body.summary : null,
value,
entries,
entryValues,
});
}
return views;
}
/**
* Return a copy of `raw` with one preference's value replaced, in whichever
* fields that widget type reads. Unknown keys are returned unchanged.
*/
export function applyPreferenceValue(
raw: BridgePreference[],
key: string,
value: string | string[] | boolean,
): BridgePreference[] {
return raw.map((entry) => {
if (entry.key !== key) return entry;
const widget = widgetOf(entry);
if (!widget) return entry;
const body = { ...widget.body };
const kind = WIDGETS[widget.name];
if (kind === 'multi') {
body.values = Array.isArray(value) ? value : [];
} else if (kind === 'switch') {
body.value = value === true;
} else if (kind === 'list') {
const entryValues = stringList(body.entryValues);
const index = entryValues.indexOf(String(value));
body.valueIndex = index;
if (index >= 0) body.value = entryValues[index];
} else {
// editTextPreference carries the same string in both value and text.
body.value = String(value);
body.text = String(value);
}
return { ...entry, [widget.name]: body };
});
}
/** True when the preference should be masked in the UI and in logs. */
export function isSecretPreference(view: SourcePreferenceView): boolean {
return /password|token|api[-_ ]?key|secret/i.test(`${view.key} ${view.title}`);
}
+159
View File
@@ -0,0 +1,159 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
bundleReleaseUrl,
findBundleBinaries,
PINNED_BUNDLE_SHA256,
PINNED_BUNDLE_TAG,
resolveBundleAssetName,
selectBundleAsset,
sha256,
verifyPinnedBundle,
} from './sidecar-bundle';
test('resolveBundleAssetName maps supported platform/arch pairs', () => {
assert.equal(resolveBundleAssetName('darwin', 'arm64'), 'macOS-arm64-bundle.zip');
assert.equal(resolveBundleAssetName('darwin', 'x64'), 'macOS-x64-bundle.zip');
assert.equal(resolveBundleAssetName('linux', 'x64'), 'linux-x64-bundle.zip');
assert.equal(resolveBundleAssetName('win32', 'x64'), 'windows-x64-bundle.zip');
});
test('resolveBundleAssetName returns null for unpublished combinations', () => {
assert.equal(resolveBundleAssetName('linux', 'arm64'), null);
assert.equal(resolveBundleAssetName('win32', 'arm64'), null);
assert.equal(resolveBundleAssetName('freebsd', 'x64'), null);
});
const PINNED_RELEASE = {
tag_name: PINNED_BUNDLE_TAG,
assets: [
{
name: 'macOS-arm64-bundle.zip',
browser_download_url: 'https://example.test/macOS-arm64-bundle.zip',
size: 133_058_560,
},
],
};
test('selectBundleAsset reads the by-tag endpoint payload', () => {
const asset = selectBundleAsset(PINNED_RELEASE, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
assert.equal(asset?.sizeBytes, 133_058_560);
});
test('selectBundleAsset skips releases without a matching asset', () => {
const releases = [
// The iOS runtime release carries no desktop bundle.
{ tag_name: 'ios-runtime-v7', assets: [{ name: 'MExtensionServer-ios.jar' }] },
PINNED_RELEASE,
];
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
});
test('selectBundleAsset ignores releases newer than the pin', () => {
const releases = [
{
tag_name: 'v9.9.9.9',
assets: [
{
name: 'macOS-arm64-bundle.zip',
browser_download_url: 'https://example.test/unpinned.zip',
size: 1,
},
],
},
PINNED_RELEASE,
];
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
});
test('selectBundleAsset returns null when nothing matches', () => {
assert.equal(
selectBundleAsset([{ tag_name: PINNED_BUNDLE_TAG, assets: [] }], 'linux-x64-bundle.zip'),
null,
);
assert.equal(selectBundleAsset([], 'linux-x64-bundle.zip'), null);
assert.equal(selectBundleAsset({ message: 'rate limited' }, 'linux-x64-bundle.zip'), null);
});
test('bundleReleaseUrl targets the pinned tag', () => {
assert.equal(
bundleReleaseUrl(),
`https://api.github.com/repos/1Selxo/M-Extension-Server/releases/tags/${PINNED_BUNDLE_TAG}`,
);
});
test('findBundleBinaries locates the nested jre and jar', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
await mkdir(path.join(root, 'jre', 'jre', 'bin'), { recursive: true });
await writeFile(path.join(root, 'jre', 'jre', 'bin', 'java'), '');
await writeFile(path.join(root, 'MExtensionServer-1.0.6.0.jar'), '');
const binaries = await findBundleBinaries(root);
assert.equal(binaries?.javaPath, path.join(root, 'jre', 'jre', 'bin', 'java'));
assert.equal(binaries?.jarPath, path.join(root, 'MExtensionServer-1.0.6.0.jar'));
});
test('findBundleBinaries prefers the shallowest java when a nested copy exists', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
await mkdir(path.join(root, 'bin'), { recursive: true });
await mkdir(path.join(root, 'bin', 'nested', 'bin'), { recursive: true });
await writeFile(path.join(root, 'bin', 'java'), '');
await writeFile(path.join(root, 'bin', 'nested', 'bin', 'java'), '');
await writeFile(path.join(root, 'MExtensionServer.jar'), '');
const binaries = await findBundleBinaries(root);
assert.equal(binaries?.javaPath, path.join(root, 'bin', 'java'));
});
test('findBundleBinaries returns null when the bundle is incomplete', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
await writeFile(path.join(root, 'MExtensionServer.jar'), '');
assert.equal(await findBundleBinaries(root), null);
});
test('sha256 produces lowercase hex digests matching known vectors', () => {
const encode = (value: string) => new TextEncoder().encode(value);
assert.equal(
sha256(encode('')),
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
);
assert.equal(
sha256(encode('subminer')),
'f3b7fdb2037add4cd8f122c090a727243b46b1b9d8a6c379f71573e2df120885',
);
});
test('verifyPinnedBundle accepts a matching hash and rejects a mismatch', () => {
const asset = 'macOS-arm64-bundle.zip';
const wrong = verifyPinnedBundle(asset, new TextEncoder().encode('not the bundle'));
assert.equal(wrong.ok, false);
assert.match((wrong as { reason: string }).reason, /Checksum mismatch/);
});
test('verifyPinnedBundle refuses an asset that has no pin', () => {
const result = verifyPinnedBundle('windows-x64-bundle.zip', new Uint8Array([1, 2, 3]));
assert.equal(result.ok, false);
assert.match((result as { reason: string }).reason, /No pinned checksum/);
});
test('the pinned tag and hashes are the verified release', () => {
assert.equal(PINNED_BUNDLE_TAG, 'v1.0.6.0');
assert.equal(
PINNED_BUNDLE_SHA256['macOS-arm64-bundle.zip'],
'5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
);
assert.equal(
PINNED_BUNDLE_SHA256['linux-x64-bundle.zip'],
'c2b869d3905b06a308517fec0b44f70ff76f7212230c60710bba39a7025c3a69',
);
});
+169
View File
@@ -0,0 +1,169 @@
import { createHash } from 'node:crypto';
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
/**
* Locates the M-Extension-Server release bundle for the host platform. Each
* bundle ships a matching JRE alongside the server JAR, so no system JDK is
* required.
*/
const BUNDLE_REPO_API = 'https://api.github.com/repos/1Selxo/M-Extension-Server';
/**
* Fetch the pinned release by tag rather than listing releases: upstream ships
* several a week, so a paged list would scroll the pinned tag off page one.
*/
export function bundleReleaseUrl(tagName: string = PINNED_BUNDLE_TAG): string {
return `${BUNDLE_REPO_API}/releases/tags/${encodeURIComponent(tagName)}`;
}
/**
* The bridge release this integration was verified against.
*
* Upstream publishes no checksums for the desktop bundles, so we pin a tag and
* a hash we computed ourselves rather than tracking "latest". Bumping this
* means downloading the new asset, verifying it starts and reports the
* capabilities in `AnimeBridgeClient.isReady`, then updating both fields.
*/
export const PINNED_BUNDLE_TAG = 'v1.0.6.0';
/** SHA-256 of each pinned asset, keyed by release asset name. */
export const PINNED_BUNDLE_SHA256: Readonly<Record<string, string>> = {
'macOS-arm64-bundle.zip': '5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
'linux-x64-bundle.zip': 'c2b869d3905b06a308517fec0b44f70ff76f7212230c60710bba39a7025c3a69',
};
/**
* Check a downloaded asset against its pin. Assets we have not verified
* ourselves are rejected rather than trusted, so an unpinned platform fails
* loudly instead of silently running an unchecked binary.
*/
export function verifyPinnedBundle(
assetName: string,
bytes: Uint8Array,
): { ok: true } | { ok: false; reason: string } {
const expected = PINNED_BUNDLE_SHA256[assetName];
if (expected === undefined) {
return { ok: false, reason: `No pinned checksum for ${assetName}; refusing to run it.` };
}
const actual = sha256(bytes);
if (actual !== expected) {
return {
ok: false,
reason: `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}.`,
};
}
return { ok: true };
}
/** Release asset name for a platform/arch pair, or null when unsupported. */
export function resolveBundleAssetName(platform: string, arch: string): string | null {
if (platform === 'linux') return arch === 'x64' ? 'linux-x64-bundle.zip' : null;
if (platform === 'win32') return arch === 'x64' ? 'windows-x64-bundle.zip' : null;
if (platform === 'darwin') {
if (arch === 'arm64') return 'macOS-arm64-bundle.zip';
if (arch === 'x64') return 'macOS-x64-bundle.zip';
}
return null;
}
export interface BundleBinaries {
javaPath: string;
jarPath: string;
}
async function walk(dir: string, depth: number, onFile: (file: string) => void): Promise<void> {
if (depth < 0) return;
let entries;
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) await walk(full, depth - 1, onFile);
else onFile(full);
}
}
/**
* Find the bundled `java` executable and `MExtensionServer-*.jar` inside an
* extracted bundle. The archive nests them a few levels deep and the exact
* layout differs per platform, so this searches rather than assuming a path.
*/
export async function findBundleBinaries(rootDir: string): Promise<BundleBinaries | null> {
const javaCandidates: string[] = [];
const jarCandidates: string[] = [];
await walk(rootDir, 6, (file) => {
const base = path.basename(file);
if (base === 'java' || base === 'java.exe') javaCandidates.push(file);
else if (/^MExtensionServer.*\.jar$/.test(base)) jarCandidates.push(file);
});
// Prefer the shallowest match so a nested duplicate never shadows the real one.
const byDepth = (a: string, b: string) => a.split(path.sep).length - b.split(path.sep).length;
const javaPath = javaCandidates.sort(byDepth)[0];
const jarPath = jarCandidates.sort(byDepth)[0];
if (!javaPath || !jarPath) return null;
return { javaPath, jarPath };
}
/** Verify a downloaded archive against a pinned SHA-256, as Mangatan does. */
export function sha256(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex');
}
export async function isExecutableFile(file: string): Promise<boolean> {
try {
const info = await stat(file);
return info.isFile();
} catch {
return false;
}
}
export interface BundleAsset {
tagName: string;
assetName: string;
downloadUrl: string;
sizeBytes: number;
}
interface GithubRelease {
tag_name?: string;
assets?: Array<{ name?: string; browser_download_url?: string; size?: number }>;
}
/**
* Pick the asset for this platform from the pinned release. Selecting "newest"
* instead would download a release whose checksum we never computed, so every
* upstream publish would break the install with a mismatch.
*/
export function selectBundleAsset(
releases: unknown,
assetName: string,
tagName: string = PINNED_BUNDLE_TAG,
): BundleAsset | null {
// Accepts either a single release (the by-tag endpoint) or a list.
const candidates = Array.isArray(releases)
? releases
: releases && typeof releases === 'object'
? [releases]
: [];
for (const release of candidates as GithubRelease[]) {
if (release.tag_name !== tagName) continue;
const asset = release.assets?.find((candidate) => candidate.name === assetName);
if (asset?.browser_download_url && release.tag_name) {
return {
tagName: release.tag_name,
assetName,
downloadUrl: asset.browser_download_url,
sizeBytes: asset.size ?? 0,
};
}
}
return null;
}
+133
View File
@@ -0,0 +1,133 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import { EventEmitter } from 'node:events';
import type { AddressInfo } from 'node:net';
import type { spawn as spawnType, ChildProcess } from 'node:child_process';
import { allocatePort, startSidecar } from './sidecar-process';
import type { BundleBinaries } from './sidecar-bundle';
const binaries: BundleBinaries = {
javaPath: '/nonexistent/java',
jarPath: '/tmp/MExtensionServer.jar',
};
/**
* A ChildProcess stand-in: an EventEmitter with the bits startSidecar touches.
* A `pid` marks a child that did spawn, which is how a kill failure is told
* apart from a spawn failure.
*/
function fakeChild(onKill?: (child: EventEmitter) => void, pid?: number): ChildProcess {
const child = new EventEmitter();
Object.assign(child, {
stdout: null,
stderr: null,
pid,
kill: () => {
onKill?.(child);
return true;
},
});
return child as unknown as ChildProcess;
}
test('a failed spawn rejects instead of throwing an unhandled error event', async () => {
const port = await allocatePort();
const child = fakeChild();
const spawnImpl = (() => {
// Node emits `error` asynchronously when the binary cannot be executed.
queueMicrotask(() => child.emit('error', new Error('spawn ENOENT')));
return child;
}) as unknown as typeof spawnType;
await assert.rejects(
() => startSidecar({ binaries, port, readyTimeoutMs: 2000, spawnImpl }),
/could not start.*ENOENT/,
);
});
test('a readiness timeout shuts the child down and reports the timeout', async () => {
const port = await allocatePort();
// Never becomes ready, but does go down on the first signal.
const child = fakeChild((emitter) => queueMicrotask(() => emitter.emit('exit', 0, 'SIGTERM')));
const spawnImpl = (() => child) as unknown as typeof spawnType;
await assert.rejects(
() => startSidecar({ binaries, port, readyTimeoutMs: 50, spawnImpl }),
/did not become ready within 50ms/,
);
});
test('a kill that fails without an exit is reported, not counted as a shutdown', async () => {
const port = await allocatePort();
// Signalling fails (EPERM-style) and the child never goes: it may still hold
// the port, so the stop must not report success.
const child = fakeChild(
(emitter) => queueMicrotask(() => emitter.emit('error', new Error('kill EPERM'))),
4242,
);
const spawnImpl = (() => child) as unknown as typeof spawnType;
await assert.rejects(
() => startSidecar({ binaries, port, readyTimeoutMs: 50, stopTimeoutMs: 10, spawnImpl }),
(error: Error) => {
assert.match(error.message, /did not become ready within 50ms/);
assert.match((error.cause as Error).message, /could not be killed.*EPERM/);
return true;
},
);
});
test('an early exit is reported with its code rather than waiting out the deadline', async () => {
const port = await allocatePort();
const child = fakeChild();
const spawnImpl = (() => {
queueMicrotask(() => child.emit('exit', 1, null));
return child;
}) as unknown as typeof spawnType;
await assert.rejects(
() => startSidecar({ binaries, port, readyTimeoutMs: 2000, spawnImpl }),
/exited before becoming ready \(code 1/,
);
});
test('onExit reports a death after readiness, including to late subscribers', async () => {
// Fake the bridge's capabilities endpoint so startSidecar reports ready.
const server = http.createServer((_req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(
JSON.stringify({ mangatanMihonBridge: 1, sourceFactory: true, preferenceCallbacks: true }),
);
});
// Bind first and take the port the OS assigned: allocating one up front and
// binding it after leaves a window for another listener to claim it.
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address() as AddressInfo;
const child = fakeChild();
const spawnImpl = (() => child) as unknown as typeof spawnType;
try {
const handle = await startSidecar({ binaries, port, readyTimeoutMs: 5000, spawnImpl });
const exits: Array<{ code: number | null; signal: NodeJS.Signals | null }> = [];
handle.onExit((info) => exits.push(info));
assert.equal(exits.length, 0);
child.emit('exit', 137, null);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual([...exits], [{ code: 137, signal: null }]);
// A listener attached after the death still hears about it.
handle.onExit((info) => exits.push(info));
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
[...exits],
[
{ code: 137, signal: null },
{ code: 137, signal: null },
],
);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
+180
View File
@@ -0,0 +1,180 @@
import { spawn, type ChildProcess } from 'node:child_process';
import path from 'node:path';
import { createServer } from 'node:net';
import { AnimeBridgeClient } from './bridge-client';
import type { BundleBinaries } from './sidecar-bundle';
/** Cold start includes JVM boot plus AndroidCompat init; be generous. */
export const DEFAULT_READY_TIMEOUT_MS = 30_000;
const READY_POLL_INTERVAL_MS = 500;
/** How long to wait for the child to go after each signal before escalating. */
const STOP_TIMEOUT_MS = 5_000;
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Ask the OS for a free loopback port, then hand it to the JVM. */
export async function allocatePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (address === null || typeof address === 'string') {
server.close();
reject(new Error('Could not allocate a loopback port for the anime bridge.'));
return;
}
const { port } = address;
server.close(() => resolve(port));
});
});
}
export interface SidecarHandle {
baseUrl: string;
port: number;
/**
* Fires once when the child process goes away, however it goes away
* including deliberate stops. Fires immediately for a subscriber that
* attaches after the death, so a caller cannot miss it.
*/
onExit: (
listener: (info: { code: number | null; signal: NodeJS.Signals | null }) => void,
) => void;
client: AnimeBridgeClient;
stop: () => Promise<void>;
}
export interface StartSidecarOptions {
binaries: BundleBinaries;
port?: number;
readyTimeoutMs?: number;
/** How long to wait for the child to go after each stop signal. Tests only. */
stopTimeoutMs?: number;
spawnImpl?: typeof spawn;
onLog?: (line: string) => void;
}
/**
* Launch the bridge and wait until it reports the capabilities this client
* needs. The desktop launch contract is `java -jar MExtensionServer.jar <port>`,
* run from the JAR's own directory.
*/
export async function startSidecar(options: StartSidecarOptions): Promise<SidecarHandle> {
const { binaries } = options;
const port = options.port ?? (await allocatePort());
const baseUrl = `http://127.0.0.1:${port}`;
const spawnProcess = options.spawnImpl ?? spawn;
const child: ChildProcess = spawnProcess(
binaries.javaPath,
['-jar', binaries.jarPath, String(port)],
{
cwd: path.dirname(binaries.jarPath),
stdio: ['ignore', 'pipe', 'pipe'],
},
);
const log = options.onLog;
if (log) {
child.stdout?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
child.stderr?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
}
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
let spawnError: Error | null = null;
let killError: Error | null = null;
const stopTimeoutMs = options.stopTimeoutMs ?? STOP_TIMEOUT_MS;
const hasExited = new Promise<void>((resolve) => {
child.once('exit', (code, signal) => {
exited = { code, signal };
resolve();
});
// A ChildProcess is an EventEmitter: without this listener a failed spawn
// (a missing or non-executable java) throws in the main process instead of
// failing the readiness loop below. `error` never proves the child is gone
// -- only `exit` does -- so it must never set `exited`, or a kill that
// failed would look like a clean shutdown. A spawn failure is told apart
// from a later kill failure by the missing pid.
child.on('error', (error: Error) => {
if (child.pid === undefined) {
spawnError = error;
resolve();
return;
}
killError = error;
});
});
const stop = async (): Promise<void> => {
if (exited !== null) return;
// Nothing was ever spawned, so there is nothing to signal or wait for.
if (spawnError !== null) return;
// Graceful first: the server exposes a shutdown endpoint.
try {
await fetch(`${baseUrl}/stop`, { signal: AbortSignal.timeout(2000) });
} catch {
// Falling through to a signal is fine; the endpoint may already be gone.
}
if (exited !== null) return;
child.kill();
// kill() only sends the signal. Wait for the process to actually go, so a
// restart cannot race the old one still holding the port.
await Promise.race([hasExited, delay(stopTimeoutMs)]);
if (exited === null) {
child.kill('SIGKILL');
await Promise.race([hasExited, delay(stopTimeoutMs)]);
}
// Never report success while the child may still hold the port: a caller
// that restarts on the same port would race the survivor.
if (exited === null) {
const failedKill = killError as Error | null;
if (failedKill !== null) {
throw new Error(`Anime bridge could not be killed: ${failedKill.message}`, {
cause: failedKill,
});
}
throw new Error('Anime bridge did not exit after SIGKILL.');
}
};
const client = new AnimeBridgeClient({ baseUrl });
const deadline = Date.now() + (options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
while (Date.now() < deadline) {
if (spawnError !== null) {
throw new Error(`Anime bridge could not start: ${(spawnError as Error).message}`);
}
if (exited !== null) {
const { code, signal } = exited as { code: number | null; signal: NodeJS.Signals | null };
throw new Error(
`Anime bridge exited before becoming ready (code ${code}, signal ${signal}).`,
);
}
// Cap the probe at the time actually left, so a short readiness budget is
// not overrun by a single stalled capabilities request.
if (await client.isReady(deadline - Date.now())) {
const onExit: SidecarHandle['onExit'] = (listener) => {
void hasExited.then(() => listener(exited ?? { code: null, signal: null }));
};
return { baseUrl, port, client, stop, onExit };
}
// Sleeping past the deadline would only delay the timeout report.
const remaining = deadline - Date.now();
if (remaining <= 0) break;
await delay(Math.min(READY_POLL_INTERVAL_MS, remaining));
}
const timeout = new Error(
`Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`,
);
// A failed shutdown must not mask why startup failed, but it must not be
// dropped either: a surviving child still holds the port, which decides
// whether a caller may retry on it.
await stop().catch((error: unknown) => {
timeout.cause = error;
});
throw timeout;
}
+263
View File
@@ -0,0 +1,263 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import type { AddressInfo } from 'node:net';
import {
findTsSyncOffset,
rewritePlaylistOrigins,
startStreamStripProxy,
TS_PACKET_LENGTH,
} from './stream-strip-proxy';
/** A valid MPEG-TS payload: 0x47 sync byte every 188 bytes. */
function makeTsPackets(count: number): Buffer {
const data = Buffer.alloc(count * TS_PACKET_LENGTH, 0x11);
for (let i = 0; i < count; i++) data[i * TS_PACKET_LENGTH] = 0x47;
return data;
}
/** The disguise seen in the wild: a real PNG header glued before the TS data. */
const PNG_HEADER = Buffer.from([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
]);
test('findTsSyncOffset returns 0 for clean TS data', () => {
assert.equal(findTsSyncOffset(makeTsPackets(6)), 0);
});
test('findTsSyncOffset finds TS data behind a PNG prefix', () => {
const disguised = Buffer.concat([PNG_HEADER, makeTsPackets(6)]);
assert.equal(findTsSyncOffset(disguised), PNG_HEADER.length);
});
test('findTsSyncOffset ignores a lone sync byte in the junk prefix', () => {
const junk = Buffer.alloc(64, 0x00);
junk[10] = 0x47; // decoy: no packet run follows it
const disguised = Buffer.concat([junk, makeTsPackets(6)]);
assert.equal(findTsSyncOffset(disguised), junk.length);
});
test('findTsSyncOffset returns null when no TS run exists', () => {
assert.equal(findTsSyncOffset(Buffer.alloc(4096, 0x42)), null);
});
test('findTsSyncOffset returns null when the run starts past the scan limit', () => {
const disguised = Buffer.concat([Buffer.alloc(600, 0x00), makeTsPackets(6)]);
assert.equal(findTsSyncOffset(disguised, 500), null);
});
test('rewritePlaylistOrigins swaps absolute upstream URLs and keeps relative lines', () => {
const body = [
'#EXTM3U',
'#EXTINF:6.006,',
'/video/aaa.ts',
'#EXTINF:4.463,',
'http://127.0.0.1:41569/video/bbb.ts',
].join('\n');
const rewritten = rewritePlaylistOrigins(body, 'http://127.0.0.1:41569', 'http://127.0.0.1:9999');
assert.ok(rewritten.includes('http://127.0.0.1:9999/video/bbb.ts'));
assert.ok(rewritten.includes('\n/video/aaa.ts\n'));
assert.ok(!rewritten.includes('41569'));
});
/* ---------- proxy end-to-end ---------- */
type Route = { status: number; contentType: string; body: Buffer };
/** Either a static routing table or a handler, for upstreams that need one. */
async function withProxy(
routes: Record<string, Route> | http.RequestListener,
run: (proxyOrigin: string, upstreamOrigin: string) => Promise<void>,
): Promise<void> {
const upstream = http.createServer((req, res) => {
if (typeof routes === 'function') {
routes(req, res);
return;
}
const route = routes[req.url ?? ''];
if (!route) {
res.writeHead(404).end('missing');
return;
}
res.writeHead(route.status, { 'content-type': route.contentType });
res.end(route.body);
});
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
const proxy = await startStreamStripProxy({
upstreamOrigin: () => upstreamOrigin,
retryDelayMs: 5,
});
try {
await run(proxy.origin, upstreamOrigin);
} finally {
await proxy.close();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
}
async function fetchBytes(url: string): Promise<{ status: number; body: Buffer }> {
const response = await fetch(url);
return { status: response.status, body: Buffer.from(await response.arrayBuffer()) };
}
test('proxy strips the PNG disguise off a segment', async () => {
const ts = makeTsPackets(8);
const disguised = Buffer.concat([PNG_HEADER, ts]);
await withProxy(
{ '/video/seg.ts': { status: 200, contentType: 'video/mp2t', body: disguised } },
async (origin) => {
const { status, body } = await fetchBytes(`${origin}/video/seg.ts`);
assert.equal(status, 200);
assert.deepEqual(body, ts);
},
);
});
test('proxy passes a clean segment through unchanged', async () => {
const ts = makeTsPackets(8);
await withProxy(
{ '/video/seg.ts': { status: 200, contentType: 'video/mp2t', body: ts } },
async (origin) => {
const { body } = await fetchBytes(`${origin}/video/seg.ts`);
assert.deepEqual(body, ts);
},
);
});
test('proxy leaves non-TS bodies alone', async () => {
const vtt = Buffer.from('WEBVTT\n\n00:00.000 --> 00:01.000\nhello\n');
await withProxy(
{ '/video/sub.vtt': { status: 200, contentType: 'text/vtt', body: vtt } },
async (origin) => {
const { body } = await fetchBytes(`${origin}/video/sub.vtt`);
assert.deepEqual(body, vtt);
},
);
});
test('proxy rewrites absolute upstream playlist entries to its own origin', async () => {
await withProxy(
(req, res) => {
if (req.url !== '/video/list.m3u8') {
res.writeHead(404).end();
return;
}
// The proxy rewrites the Host header to the upstream it dialled, so this
// is that origin — the one the playlist must not leak to mpv.
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl' });
res.end(`#EXTM3U\n#EXTINF:6,\nhttp://${req.headers.host}/video/abs.ts\n`);
},
async (proxyOrigin, upstreamOrigin) => {
const { body } = await fetchBytes(`${proxyOrigin}/video/list.m3u8`);
const text = body.toString('utf8');
assert.ok(text.includes(`${proxyOrigin}/video/abs.ts`));
assert.ok(!text.includes(upstreamOrigin));
},
);
});
test('proxy strips even when the client asks for a byte range', async () => {
// ffmpeg opens every HLS segment with `Range: bytes=0-`. The proxy drops the
// header, so the upstream answers 200 with the whole body and the strip
// applies; a 206 would have been forwarded untouched.
const ts = makeTsPackets(8);
const disguised = Buffer.concat([PNG_HEADER, ts]);
await withProxy(
(req, res) => {
if (req.headers.range !== undefined) {
res.writeHead(206, {
'content-type': 'image/png',
'content-range': `bytes 0-${disguised.length - 1}/${disguised.length}`,
});
res.end(disguised);
return;
}
res.writeHead(200, { 'content-type': 'image/png' });
res.end(disguised);
},
async (proxyOrigin) => {
const response = await fetch(`${proxyOrigin}/video/seg.ts`, {
headers: { Range: 'bytes=0-' },
});
const body = Buffer.from(await response.arrayBuffer());
assert.equal(response.status, 200);
assert.deepEqual(body, ts);
},
);
});
test('proxy forwards error statuses without touching the body', async () => {
await withProxy(
{ '/video/gone.ts': { status: 404, contentType: 'text/plain', body: Buffer.from('nope') } },
async (origin) => {
const { status, body } = await fetchBytes(`${origin}/video/gone.ts`);
assert.equal(status, 404);
assert.equal(body.toString('utf8'), 'nope');
},
);
});
/**
* Upstream that fails the first `failures` hits per path, then serves the
* route. Mirrors the bridge right after an episode resolve: mpv's immediate
* segment fetch errors, the same fetch a moment later works.
*/
async function withFlakyUpstream(
failures: number,
failStatus: number,
route: Route,
run: (proxyOrigin: string, hits: () => number) => Promise<void>,
): Promise<void> {
let hits = 0;
const upstream = http.createServer((_req, res) => {
hits += 1;
if (hits <= failures) {
res.writeHead(failStatus, { 'content-type': 'text/plain' }).end('not ready');
return;
}
res.writeHead(route.status, { 'content-type': route.contentType });
res.end(route.body);
});
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve));
const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
const proxy = await startStreamStripProxy({
upstreamOrigin: () => upstreamOrigin,
retryDelayMs: 5,
});
try {
await run(proxy.origin, () => hits);
} finally {
await proxy.close();
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
}
test('proxy retries a failed segment fetch once and serves the retry', async () => {
const ts = makeTsPackets(8);
await withFlakyUpstream(
1,
404,
{ status: 200, contentType: 'video/mp2t', body: ts },
async (origin, hits) => {
const { status, body } = await fetchBytes(`${origin}/video/seg.ts`);
assert.equal(status, 200);
assert.deepEqual(body, ts);
assert.equal(hits(), 2);
},
);
});
test('proxy gives up after one retry and forwards the error', async () => {
await withFlakyUpstream(
Infinity,
503,
{ status: 200, contentType: 'video/mp2t', body: makeTsPackets(8) },
async (origin, hits) => {
const { status } = await fetchBytes(`${origin}/video/seg.ts`);
assert.equal(status, 503);
assert.equal(hits(), 2);
},
);
});
+318
View File
@@ -0,0 +1,318 @@
import http from 'node:http';
import type { AddressInfo } from 'node:net';
/**
* Loopback proxy between mpv and the anime bridge that undoes segment
* disguises. Some hosts prepend a real image header (a 1x1 PNG in the wild) to
* every HLS segment so scrapers see "an image"; ffmpeg then probes the segment
* as a picture and playback dies with "no audio or video data played". Aniyomi
* strips this in its player; mpv needs the bytes fixed before it sees them.
*
* Only bridge-origin `.m3u8` streams are routed through here (see
* anime-browser-runtime). Playlist bodies get their absolute upstream origins
* rewritten so segment requests come back through the proxy; segment bodies are
* scanned for the first genuine MPEG-TS packet run and any junk before it is
* dropped. Anything that is not TS (fMP4, VTT, keys) passes through untouched.
*/
export const TS_PACKET_LENGTH = 188;
const TS_SYNC_BYTE = 0x47;
/**
* Sync bytes that must repeat at exact packet spacing before an offset counts
* as TS data. One or two matches happen by chance in binary data; five in a
* row at 188-byte strides do not.
*/
const SYNC_RUN = 5;
/** A disguise prefix is small; give up scanning after this much. */
export const DEFAULT_SCAN_LIMIT_BYTES = 1024 * 1024;
/** Bytes needed to either find a run within the limit or rule one out. */
const DECISION_BYTES = DEFAULT_SCAN_LIMIT_BYTES + (SYNC_RUN - 1) * TS_PACKET_LENGTH + 1;
/**
* First offset at which a confirmed MPEG-TS packet run starts, or null when
* the data does not look like TS at all (within the scan limit).
*/
export function findTsSyncOffset(
data: Buffer,
scanLimit = DEFAULT_SCAN_LIMIT_BYTES,
): number | null {
const lastConfirmable = data.length - (SYNC_RUN - 1) * TS_PACKET_LENGTH - 1;
const end = Math.min(lastConfirmable, scanLimit);
for (let offset = 0; offset <= end; offset++) {
if (data[offset] !== TS_SYNC_BYTE) continue;
let confirmed = true;
for (let packet = 1; packet < SYNC_RUN; packet++) {
if (data[offset + packet * TS_PACKET_LENGTH] !== TS_SYNC_BYTE) {
confirmed = false;
break;
}
}
if (confirmed) return offset;
}
return null;
}
/**
* Point absolute playlist entries at the proxy. Relative entries already
* resolve against whatever origin served the playlist, so they need no help.
*/
export function rewritePlaylistOrigins(
body: string,
upstreamOrigin: string,
proxyOrigin: string,
): string {
return body.split(upstreamOrigin).join(proxyOrigin);
}
export interface StreamStripProxyOptions {
/** Read per request so a bridge restart on a new port keeps working. */
upstreamOrigin: () => string;
log?: (message: string) => void;
/** Pause before the single retry of a failed upstream GET. */
retryDelayMs?: number;
}
const DEFAULT_RETRY_DELAY_MS = 400;
/**
* Socket timeout on the upstream GET, cleared once its headers arrive. Node's
* http client has no deadline of its own, so a host that accepts the
* connection and then says nothing would hang mpv on that segment forever.
*/
const UPSTREAM_TIMEOUT_MS = 15_000;
export interface StreamStripProxyHandle {
origin: string;
port: number;
close: () => Promise<void>;
}
/** Response headers that must not be forwarded verbatim. */
const DROPPED_HEADERS = new Set([
'connection',
'keep-alive',
'transfer-encoding',
'content-length',
]);
function forwardableHeaders(headers: http.IncomingHttpHeaders): http.OutgoingHttpHeaders {
const result: http.OutgoingHttpHeaders = {};
for (const [name, value] of Object.entries(headers)) {
if (value === undefined || DROPPED_HEADERS.has(name.toLowerCase())) continue;
result[name] = value;
}
return result;
}
export function startStreamStripProxy(
options: StreamStripProxyOptions,
): Promise<StreamStripProxyHandle> {
const log = options.log ?? (() => {});
const retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
const server = http.createServer((req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405).end();
return;
}
let upstreamUrl: URL;
try {
upstreamUrl = new URL(req.url ?? '/', options.upstreamOrigin());
} catch {
res.writeHead(502).end();
return;
}
const requestHeaders = forwardableHeaders(req.headers);
delete requestHeaders.host;
// Never forward Range: ffmpeg opens every segment with `bytes=0-`, the
// bridge answers some of those 206, and a partial response cannot be
// stripped (only full 200 bodies are). Byte ranges into a resource whose
// bytes this proxy rewrites would be incoherent anyway.
delete requestHeaders.range;
res.on('error', () => {});
requestUpstream(req, res, upstreamUrl, requestHeaders, 0);
});
/**
* One delayed retry on a failed GET: right after an episode resolve, the
* bridge (or the host behind it) can error on the very first segment
* fetches and be fine a moment later mpv treats a playlist full of failed
* segments as a dead file and gives up for good.
*/
function requestUpstream(
req: http.IncomingMessage,
res: http.ServerResponse,
upstreamUrl: URL,
requestHeaders: http.OutgoingHttpHeaders,
attempt: number,
): void {
const mayRetry = req.method === 'GET' && attempt === 0;
// Once the response is handed off, its headers (and often part of its body)
// are already on the wire: a later upstream error can only be reported by
// killing the connection, never by retrying or writing a 502.
let handedOff = false;
const retry = (): void => {
setTimeout(
() => requestUpstream(req, res, upstreamUrl, requestHeaders, attempt + 1),
retryDelayMs,
);
};
const upstreamRequest = http.request(
upstreamUrl,
{ method: req.method, headers: requestHeaders, timeout: UPSTREAM_TIMEOUT_MS },
(upstream) => {
// Body streaming has its own pace; only the wait for headers is capped.
upstreamRequest.setTimeout(0);
const status = upstream.statusCode ?? 502;
if (status === 404 || status >= 500) {
if (mayRetry) {
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}; retrying once`);
upstream.resume();
retry();
return;
}
log(`[stream-proxy] upstream ${status} for ${upstreamUrl.pathname}`);
}
handedOff = true;
handleUpstreamResponse(req, res, upstream);
},
);
// Destroying with an error routes the stall through the retry/502 path.
upstreamRequest.on('timeout', () => {
upstreamRequest.destroy(new Error(`upstream silent for ${UPSTREAM_TIMEOUT_MS}ms`));
});
upstreamRequest.on('error', (error) => {
if (handedOff) {
log(`[stream-proxy] upstream failed mid-response: ${String(error)}`);
res.destroy();
return;
}
if (mayRetry) {
log(`[stream-proxy] upstream request failed: ${String(error)}; retrying once`);
retry();
return;
}
log(`[stream-proxy] upstream request failed: ${String(error)}`);
if (!res.headersSent) res.writeHead(502);
res.end();
});
upstreamRequest.end();
}
function handleUpstreamResponse(
req: http.IncomingMessage,
res: http.ServerResponse,
upstream: http.IncomingMessage,
): void {
const status = upstream.statusCode ?? 502;
const pathname = (req.url ?? '').split('?', 1)[0] ?? '';
const contentType = String(upstream.headers['content-type'] ?? '');
const isPlaylist = pathname.endsWith('.m3u8') || contentType.includes('mpegurl');
upstream.on('error', () => res.destroy());
// Only a full 200 body is safe to modify; everything else (errors, range
// responses, HEAD) forwards untouched.
if (status !== 200 || req.method === 'HEAD') {
res.writeHead(status, forwardableHeaders(upstream.headers));
upstream.pipe(res);
return;
}
if (isPlaylist) {
const chunks: Buffer[] = [];
let buffered = 0;
upstream.on('data', (chunk: Buffer) => {
buffered += chunk.length;
// A playlist is text and small; anything this large is not one, and it
// has to be held whole in memory to be rewritten.
if (buffered > DECISION_BYTES) {
log(`[stream-proxy] playlist body over ${DECISION_BYTES} bytes; dropping`);
upstream.destroy();
res.destroy();
return;
}
chunks.push(chunk);
});
upstream.on('end', () => {
const body = rewritePlaylistOrigins(
Buffer.concat(chunks).toString('utf8'),
options.upstreamOrigin(),
origin,
);
res.writeHead(status, {
...forwardableHeaders(upstream.headers),
'content-length': Buffer.byteLength(body, 'utf8'),
});
res.end(body);
});
return;
}
stripSegment(res, upstream);
}
/**
* Buffer just enough of the body to find (or rule out) a TS packet run,
* drop everything before it, then stream the rest through untouched.
*/
function stripSegment(res: http.ServerResponse, upstream: http.IncomingMessage): void {
const chunks: Buffer[] = [];
let buffered = 0;
const respond = (data: Buffer, remainderFollows: boolean): void => {
const offset = findTsSyncOffset(data) ?? 0;
if (offset > 0) log(`[stream-proxy] stripped ${offset} disguise bytes off a segment`);
const body = offset > 0 ? data.subarray(offset) : data;
const headers = forwardableHeaders(upstream.headers);
const upstreamLength = Number(upstream.headers['content-length']);
if (remainderFollows) {
if (Number.isFinite(upstreamLength)) headers['content-length'] = upstreamLength - offset;
} else {
headers['content-length'] = body.length;
}
res.writeHead(upstream.statusCode ?? 200, headers);
res.write(body);
};
const onData = (chunk: Buffer): void => {
chunks.push(chunk);
buffered += chunk.length;
if (buffered < DECISION_BYTES) return;
upstream.off('data', onData);
upstream.off('end', onEnd);
respond(Buffer.concat(chunks), true);
upstream.pipe(res);
};
const onEnd = (): void => {
respond(Buffer.concat(chunks), false);
res.end();
};
upstream.on('data', onData);
upstream.on('end', onEnd);
}
let origin = '';
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const { port } = server.address() as AddressInfo;
origin = `http://127.0.0.1:${port}`;
resolve({
origin,
port,
close: () =>
new Promise<void>((resolveClose) => {
server.closeAllConnections?.();
server.close(() => resolveClose());
}),
});
});
});
}
+211
View File
@@ -0,0 +1,211 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import * as path from 'path';
import {
cacheSubtitleTracks,
removeSubtitleCache,
resolveSubtitleExtension,
sniffSubtitleExtension,
subtitleExtensionFromUrl,
type SubtitleCacheIo,
} from './subtitle-cache';
interface FakeIo extends SubtitleCacheIo {
written: Map<string, string>;
removed: string[];
requests: Array<{ url: string; headers: Record<string, string> }>;
}
function fakeIo(bodies: Record<string, string | { status: number }>): FakeIo {
const written = new Map<string, string>();
const removed: string[] = [];
const requests: Array<{ url: string; headers: Record<string, string> }> = [];
return {
written,
removed,
requests,
async fetch(url, init) {
requests.push({ url, headers: init.headers });
const body = bodies[url];
if (body === undefined) throw new Error(`unexpected fetch: ${url}`);
if (typeof body !== 'string') {
return { ok: false, status: body.status, arrayBuffer: async () => new ArrayBuffer(0) };
}
const bytes = Buffer.from(body, 'utf8');
return {
ok: true,
status: 200,
arrayBuffer: async () =>
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer,
};
},
async makeTempDir(prefix) {
return `${prefix}test`;
},
async writeFile(filePath, bytes) {
written.set(filePath, Buffer.from(bytes).toString('utf8'));
},
async removeDir(dir) {
removed.push(dir);
},
};
}
const SRT = '1\n00:00:01,000 --> 00:00:02,000\nこんにちは\n';
const ASS = '[Script Info]\nScriptType: v4.00+\n\n[Events]\n';
test('content decides the extension before the url does', () => {
assert.equal(sniffSubtitleExtension(ASS), 'ass');
assert.equal(sniffSubtitleExtension(SRT), 'srt');
assert.equal(sniffSubtitleExtension('WEBVTT\n\n00:01.000 --> 00:02.000\n'), 'vtt');
assert.equal(sniffSubtitleExtension('nothing recognisable'), null);
// An ASS body served from a .srt URL keeps the extension its parser needs.
assert.equal(resolveSubtitleExtension('http://host/sub.srt', ASS), 'ass');
});
test('a bom or leading whitespace does not hide the format marker', () => {
assert.equal(sniffSubtitleExtension(`${ASS}`), 'ass');
assert.equal(sniffSubtitleExtension(`\n\n${SRT}`), 'srt');
});
test('the url extension is the fallback, and only for formats we know', () => {
assert.equal(subtitleExtensionFromUrl('http://host/a/b.ASS?x=1'), 'ass');
assert.equal(subtitleExtensionFromUrl('http://host/video/token'), null);
assert.equal(subtitleExtensionFromUrl('http://host/a.mp4'), null);
// Nothing to go on: mpv can still probe past a wrong name.
assert.equal(resolveSubtitleExtension('http://host/video/token', 'unknown'), 'srt');
});
test('tracks are downloaded to a temp dir and handed back as file paths', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': SRT, 'http://bridge/sub/en': ASS });
const result = await cacheSubtitleTracks({
tracks: [
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: 'http://bridge/sub/en', lang: 'English' },
],
headers: { Referer: 'https://host/' },
io,
});
assert.ok(result.dir);
assert.deepEqual(
result.tracks.map((track) => path.basename(track.url)),
['track-0.srt', 'track-1.ass'],
);
assert.ok(result.tracks.every((track) => track.local));
assert.equal(io.written.get(result.tracks[0]!.url), SRT);
// The stream's headers ride along; some hosts gate the subtitle URL too.
assert.deepEqual(io.requests[0]!.headers, { Referer: 'https://host/' });
});
test('a failed download keeps its url so the episode still plays', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': SRT, 'http://bridge/sub/en': { status: 404 } });
const logged: string[] = [];
const result = await cacheSubtitleTracks({
tracks: [
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: 'http://bridge/sub/en', lang: 'English' },
],
io,
log: (message) => logged.push(message),
});
assert.equal(result.tracks[0]!.local, true);
assert.equal(result.tracks[1]!.local, false);
assert.equal(result.tracks[1]!.url, 'http://bridge/sub/en');
assert.ok(logged.some((message) => message.includes('404')));
// One track survived, so the directory stays.
assert.ok(result.dir);
assert.deepEqual(io.removed, []);
});
test('an oversized streamed subtitle stops early and falls back to its remote url', async () => {
const io = fakeIo({});
const logged: string[] = [];
let chunksRead = 0;
let buffered = false;
const chunk = new Uint8Array(20 * 1024 * 1024);
const body = new ReadableStream<Uint8Array>(
{
pull(controller) {
chunksRead += 1;
controller.enqueue(chunk);
},
},
{ highWaterMark: 0 },
);
io.fetch = async () => ({
ok: true,
status: 200,
body,
async arrayBuffer() {
buffered = true;
throw new Error('stream should not be buffered');
},
});
const result = await cacheSubtitleTracks({
tracks: [{ url: 'http://bridge/sub/oversized', lang: 'Japanese' }],
io,
log: (message) => logged.push(message),
});
assert.equal(buffered, false);
assert.equal(chunksRead, 2);
assert.equal(result.dir, null);
assert.deepEqual(result.tracks, [
{
url: 'http://bridge/sub/oversized',
lang: 'Japanese',
sourceUrl: 'http://bridge/sub/oversized',
local: false,
},
]);
assert.ok(logged.some((message) => message.includes('response too large')));
});
test('a directory with nothing in it is removed and not reported', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': { status: 500 } });
const result = await cacheSubtitleTracks({
tracks: [{ url: 'http://bridge/sub/ja', lang: 'Japanese' }],
io,
});
assert.equal(result.dir, null);
assert.equal(result.tracks[0]!.local, false);
assert.equal(io.removed.length, 1);
});
test('duplicate and empty urls are dropped before anything is fetched', async () => {
const io = fakeIo({ 'http://bridge/sub/ja': SRT });
const result = await cacheSubtitleTracks({
tracks: [
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: 'http://bridge/sub/ja', lang: 'Japanese' },
{ url: '', lang: 'English' },
],
io,
});
assert.equal(result.tracks.length, 1);
assert.equal(io.requests.length, 1);
});
test('no tracks means no temp directory at all', async () => {
const io = fakeIo({});
const result = await cacheSubtitleTracks({ tracks: [], io });
assert.deepEqual(result, { dir: null, tracks: [] });
assert.equal(io.written.size, 0);
});
test('cleanup is best effort and never throws', async () => {
const io = fakeIo({});
io.removeDir = async () => {
throw new Error('EBUSY');
};
await removeSubtitleCache('/tmp/subminer-anime-subtitles-x', io);
// A null directory is the common case after a source with no subtitles.
await removeSubtitleCache(null, io);
});
+258
View File
@@ -0,0 +1,258 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
/**
* Extension subtitle tracks arrive as URLs on the bridge's loopback proxy, and
* mpv is perfectly happy to stream them. alass is not: it needs a file on disk
* to use as the timing reference, and the subsync path rejects an external
* track whose `external-filename` is not an existing file. So every track is
* downloaded to a temp directory first and mpv is given the local path, the
* same way the Jellyfin preload caches its delivery URLs.
*
* The directory outlives the `sub-add` alass reads it mid-playback and is
* removed when the next episode starts or the runtime shuts down.
*/
/** Extensions mpv and alass both recognise off a filename. */
const KNOWN_SUBTITLE_EXTENSIONS = new Set([
'srt',
'ass',
'ssa',
'vtt',
'sub',
'ttml',
'smi',
'sbv',
]);
/** What an unrecognisable track is named; mpv still probes the content. */
const DEFAULT_SUBTITLE_EXTENSION = 'srt';
const DOWNLOAD_TIMEOUT_MS = 15_000;
/** Anything this large is not a subtitle file, and is not worth buffering. */
const MAX_SUBTITLE_BYTES = 32 * 1024 * 1024;
/** How much of the body is decoded to guess the format. */
const SNIFF_BYTES = 1024;
export interface SubtitleTrackRef {
url: string;
lang: string;
}
export interface CachedSubtitleTrack extends SubtitleTrackRef {
/** Where the track came from, kept for logs. */
sourceUrl: string;
/** False when the download failed and `url` is still the remote URL. */
local: boolean;
}
export interface SubtitleCacheResult {
/** The temp directory to remove later, or null when nothing was cached. */
dir: string | null;
tracks: CachedSubtitleTrack[];
}
interface FetchResponseLike {
ok: boolean;
status: number;
body?: ReadableStream<Uint8Array> | null;
arrayBuffer: () => Promise<ArrayBuffer>;
}
export interface SubtitleCacheIo {
fetch: (
url: string,
init: { headers: Record<string, string>; signal: AbortSignal },
) => Promise<FetchResponseLike>;
makeTempDir: (prefix: string) => Promise<string>;
writeFile: (filePath: string, bytes: Uint8Array) => Promise<void>;
removeDir: (dir: string) => Promise<void>;
}
export interface CacheSubtitleTracksOptions {
tracks: SubtitleTrackRef[];
/** Headers the stream was resolved with; some hosts gate subtitles too. */
headers?: Record<string, string>;
io?: SubtitleCacheIo;
log?: (message: string) => void;
}
export function createSubtitleCacheIo(): SubtitleCacheIo {
return {
fetch: (url, init) => fetch(url, init),
makeTempDir: (prefix) => fs.promises.mkdtemp(prefix),
writeFile: (filePath, bytes) => fs.promises.writeFile(filePath, bytes),
removeDir: (dir) => fs.promises.rm(dir, { recursive: true, force: true }),
};
}
/**
* Guess a subtitle format from the start of the file.
*
* Bridge subtitle URLs are opaque tokens far more often than they are
* filenames, so the content is the only reliable signal. mpv and alass both
* pick their parser off the extension, and a `.srt` holding ASS is a parse
* error rather than a mistimed subtitle.
*/
export function sniffSubtitleExtension(head: string): string | null {
const text = head.replace(/^\uFEFF/, '').trimStart();
if (/^\[(script info|v4\+? styles|events)\]/i.test(text)) return 'ass';
if (/^WEBVTT(\s|$)/.test(text)) return 'vtt';
if (/^<\?xml/i.test(text) && /<tt[\s>]|ttml/i.test(text)) return 'ttml';
// Cue-numbered and bare-timestamp SRT; the `.` separator is a common variant.
if (/^(\d+\s*\r?\n)?\d{1,3}:\d{2}:\d{2}[,.]\d{1,3}\s*-->/.test(text)) return 'srt';
return null;
}
/** The URL's own extension, when it names a format we know. */
export function subtitleExtensionFromUrl(url: string): string | null {
const urlPath = (() => {
try {
return new URL(url).pathname;
} catch {
return url;
}
})();
const extension = path.extname(urlPath).slice(1).toLowerCase();
return KNOWN_SUBTITLE_EXTENSIONS.has(extension) ? extension : null;
}
/** Content first, then the URL, then a guess mpv can still probe past. */
export function resolveSubtitleExtension(url: string, head: string): string {
return (
sniffSubtitleExtension(head) ?? subtitleExtensionFromUrl(url) ?? DEFAULT_SUBTITLE_EXTENSION
);
}
function dedupeByUrl(tracks: SubtitleTrackRef[]): SubtitleTrackRef[] {
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;
});
}
async function downloadTrack(
io: SubtitleCacheIo,
dir: string,
index: number,
track: SubtitleTrackRef,
headers: Record<string, string>,
): Promise<string> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), DOWNLOAD_TIMEOUT_MS);
let bytes: Uint8Array;
try {
const response = await io.fetch(track.url, { headers, signal: controller.signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
bytes = await readBounded(response, MAX_SUBTITLE_BYTES);
} finally {
clearTimeout(timeoutId);
}
if (bytes.byteLength === 0) {
throw new Error('empty response');
}
if (bytes.byteLength > MAX_SUBTITLE_BYTES) {
throw new Error(`response too large (${bytes.byteLength} bytes)`);
}
const head = Buffer.from(bytes.subarray(0, SNIFF_BYTES)).toString('utf8');
const extension = resolveSubtitleExtension(track.url, head);
const filePath = path.join(dir, `track-${index}.${extension}`);
// Written byte for byte: re-encoding would corrupt a non-UTF-8 track that
// mpv's own charset detection would otherwise handle.
await io.writeFile(filePath, bytes);
return filePath;
}
async function readBounded(response: FetchResponseLike, maxBytes: number): Promise<Uint8Array> {
const reader = response.body?.getReader();
if (!reader) {
const bytes = new Uint8Array(await response.arrayBuffer());
if (bytes.byteLength > maxBytes) {
throw new Error(`response too large (${bytes.byteLength} bytes)`);
}
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(`response too large (${total} bytes)`);
}
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;
}
/**
* Download every subtitle track to a fresh temp directory.
*
* A track that fails to download keeps its remote URL, so a dead subtitle
* server costs the alass reference rather than the episode.
*/
export async function cacheSubtitleTracks(
options: CacheSubtitleTracksOptions,
): Promise<SubtitleCacheResult> {
const io = options.io ?? createSubtitleCacheIo();
const tracks = dedupeByUrl(options.tracks);
if (tracks.length === 0) return { dir: null, tracks: [] };
const dir = await io.makeTempDir(path.join(os.tmpdir(), 'subminer-anime-subtitles-'));
const cached = await Promise.all(
tracks.map(async (track, index): Promise<CachedSubtitleTrack> => {
try {
const filePath = await downloadTrack(io, dir, index, track, options.headers ?? {});
return { url: filePath, lang: track.lang, sourceUrl: track.url, local: true };
} catch (error) {
options.log?.(
`[anime-browser] subtitle download failed (${track.lang || 'unknown'}): ` +
describeError(error),
);
return { url: track.url, lang: track.lang, sourceUrl: track.url, local: false };
}
}),
);
if (!cached.some((track) => track.local)) {
await removeSubtitleCache(dir, io);
return { dir: null, tracks: cached };
}
return { dir, tracks: cached };
}
/** Remove a cache directory. Never throws: cleanup is best effort. */
export async function removeSubtitleCache(
dir: string | null,
io: SubtitleCacheIo = createSubtitleCacheIo(),
): Promise<void> {
if (!dir) return;
try {
await io.removeDir(dir);
} catch {}
}
function describeError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
+101
View File
@@ -0,0 +1,101 @@
/**
* Wire types for the M-Extension-Server bridge, which runs Aniyomi
* (`eu.kanade.tachiyomi.animeextension`) APKs on a desktop JVM and exposes
* them over loopback HTTP.
*
* Field names mirror the server's JSON exactly, including Kotlin/OkHttp
* internals that leak into the payload (see `OkHttpHeaders`).
*/
/** Marker key the server uses to select a source inside a SourceFactory APK. */
export const BRIDGE_CONTEXT_KEY = '__mangatan_bridge_context__';
/** Handshake shape from `GET /capabilities`. */
export interface BridgeCapabilities {
mangatanMihonBridge?: number;
sourceFactory?: boolean;
preferenceCallbacks?: boolean;
youtubeResolver?: boolean;
}
/**
* OkHttp serializes `Headers` as a flat alternating name/value array under an
* internal field name. Kept verbatim so parsing stays honest about the source.
*/
export interface OkHttpHeaders {
namesAndValues$okhttp?: string[];
}
export interface BridgeTrack {
url?: string;
lang?: string;
}
/** One playable stream returned by an extension's `getVideoList`. */
export interface BridgeVideo {
/** Page/embed URL the stream was extracted from. */
url?: string;
/** Display label, e.g. "1080p". */
quality?: string;
/**
* Playable media URL. Normally a `/video/<token>` proxy URL on the bridge
* itself, valid only while that server process is alive.
*/
videoUrl?: string;
headers?: OkHttpHeaders;
audioTracks?: BridgeTrack[];
subtitleTracks?: BridgeTrack[];
}
export interface BridgeEpisode {
name?: string;
url?: string;
date_upload?: number;
scanlator?: string;
episode_number?: number;
}
export interface BridgeAnime {
url?: string;
title?: string;
artist?: string;
author?: string;
description?: string;
genres?: string[];
status?: number;
thumbnail_url?: string;
}
/** One source inside an extension APK. A SourceFactory APK yields several. */
export interface BridgeSourceDescriptor {
id?: string | number;
name?: string;
lang?: string;
baseUrl?: string;
}
export interface BridgeAnimePage {
animes?: BridgeAnime[];
hasNextPage?: boolean;
}
/** The server reports failures as HTTP 200 with an error body. */
export interface BridgeErrorBody {
error?: string;
code?: number;
}
/** A source preference entry, passed through to the extension unchanged. */
export interface BridgePreference {
key: string;
[field: string]: unknown;
}
/** Normalized stream, ready to hand to mpv. */
export interface ResolvedStream {
url: string;
quality: string;
headers: Record<string, string>;
subtitles: Array<{ url: string; lang: string }>;
audios: Array<{ url: string; lang: string }>;
}

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