mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-14 01:55:58 -07:00
Compare commits
12 Commits
main
..
48c36aa195
| Author | SHA1 | Date | |
|---|---|---|---|
|
48c36aa195
|
|||
|
105bd86410
|
|||
|
75566a49bf
|
|||
|
38060e0ead
|
|||
|
77badd7a54
|
|||
|
33b9d97d63
|
|||
|
9eb7f93a6b
|
|||
|
88d0fe6b98
|
|||
|
ffae99caa8
|
|||
|
d140ae6e56
|
|||
|
e0dbe5c8cd
|
|||
|
bc8dce870b
|
@@ -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.
|
|
||||||
|
|||||||
+13
@@ -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" "$@"
|
||||||
+13
@@ -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.
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -1,92 +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)
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
|
||||||
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
|
||||||
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Internal changes</summary>
|
|
||||||
|
|
||||||
### Internal
|
|
||||||
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## v0.19.1 (2026-08-01)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
|
||||||
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
|
||||||
|
|
||||||
## v0.19.0 (2026-07-29)
|
|
||||||
|
|
||||||
### Added
|
|
||||||
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
|
|
||||||
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
|
|
||||||
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
|
|
||||||
- Cross-Machine Sync: Added SSH-based syncing of stats and watch history between machines, available from the tray ("Sync Stats & History") or `subminer sync`, with saved devices, per-host sync direction, background auto-sync, connection testing, manual snapshots, and support for Windows remotes.
|
|
||||||
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
|
|
||||||
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
|
|
||||||
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
|
|
||||||
- AppImage Quit Crash: Fixed a "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
|
||||||
- Autoplay Pause Timing: Fixed playback resuming a few seconds before subtitle tokenization warmup finished, most noticeable when resuming mid-episode or when a cue starts within the first two seconds.
|
|
||||||
- Stats Known-Word Count: Fixed stats reporting 0 known words for every session after the known-word cache format changed.
|
|
||||||
- Stats Library Cover Art: Relinking a title to a different AniList entry now updates its cover in the Library grid immediately instead of leaving a stale, mismatched cover cached.
|
|
||||||
- Rofi Prompt Spacing: Rofi menu prompts now keep a space before the input field instead of running into the placeholder text.
|
|
||||||
- Stats Settings & Reliability: Hardened stats settings validation (nested/legacy AnkiConnect config now falls back safely instead of breaking) and stats routes against malformed requests and other edge cases.
|
|
||||||
- Stats Delete Performance: Deleting sessions, episodes, and library entries is now dramatically faster and no longer stalls playback (e.g. a 12-episode title dropped from about a minute to under a second on a large library); the Vocabulary tab also loads much faster. The first launch after upgrading runs a one-time database migration.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Internal changes</summary>
|
|
||||||
|
|
||||||
### Internal
|
|
||||||
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
|
|
||||||
- Consolidated renderer modal state handling into a descriptor registry.
|
|
||||||
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
|
|
||||||
- Removed the unused stats IPC transport and unified stats dashboard HTTP types with the backend contract.
|
|
||||||
- Added a script to verify known-word highlight tiers against live Anki data outside of playback.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## v0.18.0 (2026-07-10)
|
## v0.18.0 (2026-07-10)
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ Local stats dashboard tracking watch time, vocabulary growth, mining throughput,
|
|||||||
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
Browse sibling episode files and the active mpv queue in one overlay modal. Open it with `Ctrl+Alt+P` to append episodes from the current directory, jump to queued items, remove entries, or reorder the playlist without leaving playback.
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Playlist browser modal showing sibling episode files beside the active mpv queue">
|
<img src="docs-site/public/screenshots/playlist-browser.png" width="800" alt="Stats dashboard showing watch time, cards mined, streaks, and tracking data">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -90,14 +90,6 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
<td><b>Jimaku</b></td>
|
<td><b>Jimaku</b></td>
|
||||||
<td>Search and download Japanese subtitles</td>
|
<td>Search and download Japanese subtitles</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
|
||||||
<td><b>TsukiHime</b></td>
|
|
||||||
<td>Search and download subtitles extracted from anime releases, with Japanese and secondary-language tabs (<code>Ctrl+Shift+T</code>) — no API key, requires <code>xz</code> on your <code>PATH</code></td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td><b>AniSkip</b></td>
|
|
||||||
<td>Automatic intro detection with chapter markers and a one-key skip (<code>TAB</code> by default)</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<td><b>alass / ffsubsync</b></td>
|
<td><b>alass / ffsubsync</b></td>
|
||||||
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
<td>Manual subtitle retiming — requires <code>alass</code> or <code>ffsubsync</code> on your <code>PATH</code> (optional; subtitle syncing is disabled without them)</td>
|
||||||
@@ -118,19 +110,18 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
|
|||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
Only **mpv** is required to run SubMiner. Anki + AnkiConnect are required to mine cards, which is the point of the app, but everything else is optional.
|
Only **mpv** and Anki+AnkiConnect are required. Everything else is optional but enhances the experience.
|
||||||
|
|
||||||
| Dependency | Status | What it does |
|
| Dependency | Status | What it does |
|
||||||
| -------------------- | ---------------- | -------------------------------------------------------- |
|
| -------------------- | ----------- | ---------------------------------------- |
|
||||||
| mpv | Required | The video player SubMiner overlays on |
|
| mpv | Required | The video player SubMiner overlays on |
|
||||||
| Anki + AnkiConnect | Required to mine | Card creation from the Yomitan popup |
|
| Anki + AnkiConnect | Required | Card creation from the Yomitan popup |
|
||||||
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
| ffmpeg | Recommended | Audio clips & screenshots for Anki cards |
|
||||||
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
| MeCab + mecab-ipadic | Recommended | More precise annotations and filtering |
|
||||||
| yt-dlp | Optional | YouTube playback |
|
| yt-dlp | Optional | YouTube playback |
|
||||||
| xz | Optional | TsukiHime subtitle downloads (not on Windows by default) |
|
| fzf / rofi | Optional | Video picker in the launcher |
|
||||||
| alass / ffsubsync | Optional | Subtitle sync |
|
| alass / ffsubsync | Optional | Subtitle sync |
|
||||||
| guessit | Optional | Better anime title and episode detection |
|
| guessit | Optional | Better anime title and episode detection |
|
||||||
| fzf / rofi | Optional | Video picker in the `subminer` launcher (Linux/macOS) |
|
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary><b>Platform-specific install commands</b></summary>
|
<summary><b>Platform-specific install commands</b></summary>
|
||||||
@@ -147,23 +138,9 @@ sudo pacman -S --needed mpv ffmpeg mecab mecab-ipadic
|
|||||||
brew install mpv ffmpeg mecab mecab-ipadic
|
brew install mpv ffmpeg mecab mecab-ipadic
|
||||||
```
|
```
|
||||||
|
|
||||||
**Windows:**
|
**Windows:** Install [mpv](https://mpv.io/installation/) and [ffmpeg](https://ffmpeg.org/download.html) and ensure both are on `PATH`.
|
||||||
|
|
||||||
```powershell
|
See the [full requirements list](https://docs.subminer.moe/installation#1-install-requirements) for optional dependencies.
|
||||||
winget install shinchiro.mpv
|
|
||||||
winget install Gyan.FFmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
Then reopen your terminal and check `mpv --version` and `ffmpeg -version`. winget puts `ffmpeg` on `PATH` automatically; mpv uses a regular installer that may not, so if `mpv` is not found, either add its folder (usually `%LOCALAPPDATA%\Programs\mpv`) to `PATH` or set `mpv.executablePath` during first-run setup.
|
|
||||||
|
|
||||||
[Scoop](https://scoop.sh) is the alternative if you want one package manager for everything, since it is the only one that also carries `xz`:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
scoop bucket add extras
|
|
||||||
scoop install extras/mpv main/ffmpeg main/yt-dlp main/xz
|
|
||||||
```
|
|
||||||
|
|
||||||
See the [full requirements list](https://docs.subminer.moe/installation#_1-install-requirements) for optional dependencies.
|
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -189,11 +166,6 @@ paru -S subminer-bin
|
|||||||
mkdir -p ~/.local/bin
|
mkdir -p ~/.local/bin
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/SubMiner.AppImage -O ~/.local/bin/SubMiner.AppImage \
|
||||||
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
&& chmod +x ~/.local/bin/SubMiner.AppImage
|
||||||
```
|
|
||||||
|
|
||||||
The AppImage is all you need. The optional `subminer` command-line launcher runs on [Bun](https://bun.sh), and first-run setup can install both for you. To grab it manually instead, install Bun first, then:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
wget https://github.com/ksyasuda/SubMiner/releases/latest/download/subminer -O ~/.local/bin/subminer \
|
||||||
&& chmod +x ~/.local/bin/subminer
|
&& chmod +x ~/.local/bin/subminer
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"undici": "7.29.0",
|
"undici": "7.28.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -36,18 +36,16 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.15.3",
|
||||||
"brace-expansion": "5.0.9",
|
"brace-expansion": "5.0.8",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.15.3",
|
||||||
"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",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7",
|
"tmp": "0.2.7",
|
||||||
"undici": "7.29.0",
|
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||||
@@ -268,7 +266,7 @@
|
|||||||
|
|
||||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||||
|
|
||||||
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
||||||
|
|
||||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||||
|
|
||||||
@@ -406,7 +404,7 @@
|
|||||||
|
|
||||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
|
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
|
||||||
|
|
||||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
@@ -498,7 +496,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=="],
|
||||||
|
|
||||||
@@ -716,7 +714,7 @@
|
|||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
@@ -756,6 +754,8 @@
|
|||||||
|
|
||||||
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||||
|
|
||||||
|
"@discordjs/rest/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
||||||
|
|
||||||
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||||
|
|
||||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||||
@@ -808,6 +808,8 @@
|
|||||||
|
|
||||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||||
|
|
||||||
|
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
||||||
|
|
||||||
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
||||||
|
|
||||||
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: added
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature) like asbplayer. Enable with `ankiConnect.knownWords.maturityEnabled`; the mature interval threshold (`matureThresholdDays`, default 21) and the four tier colors (`subtitleStyle.knownWordMaturityColors`) are configurable, and a runtime option toggles it in-session. The session help color legend shows the four tier colors while maturity highlighting is on.
|
||||||
|
- Tiers follow Anki's own card counts: the interval tiers exclude cards in the learning/relearning queue, so a lapsed card shows the learning color instead of young (its interval is reset to at least 1 day, which previously made the learning tier unreachable). A note with a mature card alongside a relearning card still shows mature.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Applied configured primary POS exclusions consistently to merged trailing quote-particle tokens, preserved annotations for supplementary-plane kanji, and stopped treating katakana punctuation as kana-only annotation noise.
|
||||||
|
- Kept kanji vocabulary tagged `名詞/非自立` eligible for N+1 highlighting, consistent with frequency, JLPT, and vocabulary persistence.
|
||||||
@@ -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`.
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: shortcuts
|
||||||
|
|
||||||
|
- Made the clipboard-video playlist shortcut configurable through `shortcuts.appendClipboardVideoToQueue`.
|
||||||
@@ -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: anki
|
||||||
|
|
||||||
|
- Prevented video startup from crashing when another process already owns the configured AnkiConnect proxy port, and added a notification explaining how to resolve the conflict.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: app
|
||||||
|
|
||||||
|
- Fixed "Service Crash" desktop notifications (KDE DrKonqi) after closing a video when running the Linux AppImage: the short-lived background bootstrap spawned a Chromium GPU child that outlived it (surviving `app.exit`) and died with SIGBUS at session end when the bootstrap's FUSE mount was finally released. The bootstrap now runs with the GPU in-process so it leaves no children behind, and the detached app's mount remains supervised until its Chromium children finish. Set `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1` to disable the detached-app mount supervisor.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: character dictionary
|
||||||
|
|
||||||
|
- Kept manual AniList overrides active across episodes in the same season directory when filename guesses differ.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed `mpv.pauseUntilOverlayReady` releasing playback seconds before tokenization warmup finished: startup subtitle priming emits the current cue untokenized so the overlay can paint early, and that emission was treated as the autoplay-readiness signal as soon as the overlay window loaded. The autoplay gate now ignores untokenized subtitle payloads while tokenization warmup is pending, so playback resumes only after the first tokenized delivery (or the post-warmup release). Most visible when resuming mid-episode or when a subtitle cue starts within the first two seconds.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Stats reported 0 known words for every session after the known-word cache gained maturity tiers. The stats server carried its own copy of the cache parser that only recognized versions up to 3, so the new v4 file was read as "no cache" rather than as a format it should understand.
|
||||||
|
- The cache format, its parser, and the derived known-word set now live in one module that both the cache manager and the stats server read, and the version dispatch ends in an exhaustive check so a future format bump fails the build instead of silently reporting zero. A cache that exists but does not parse now logs a warning rather than passing for an empty one.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
type: internal
|
||||||
|
area: tokenizer
|
||||||
|
|
||||||
|
- Added a golden-file regression corpus for the tokenizer/annotation pipeline: recorded Yomitan backend responses and MeCab tokens replay through the real tokenizeSubtitle pipeline in bun tests without Electron or dictionaries.
|
||||||
|
- Added `record-tokenizer-fixture:electron` script to capture new fixtures from a live Yomitan/MeCab session, with flags for known words, JLPT levels, and annotation toggles.
|
||||||
|
- Seeded eleven fixtures covering the #147–#156 regression classes (grammar-helper suppression, lexical くれる, kanji non-independent nouns, N+1 targeting, reading collisions, unparsed runs, ordinal/honorific prefixes).
|
||||||
|
- Added `compare-yomitan-api:electron` script that diffs SubMiner tokenization against a stock Yomitan instance via the yomitan-api bridge (segmentation, readings, headword forms).
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: added
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- After a watch-history episode ends or mpv closes, the fzf or rofi launcher returns to that series with options to play the previous episode, rewatch, play the next episode, select another episode, or quit SubMiner. Previous and Next continue across season directories.
|
||||||
|
- The action menu shown right after picking a series from `subminer -H` now also offers the previous episode, matching the menu shown after playback.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: internal
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Consolidated renderer modal state handling into a descriptor registry.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: internal
|
||||||
|
area: release
|
||||||
|
|
||||||
|
- Consolidated pull request, stable release, and prerelease quality checks in one reusable workflow, with Lua mpv plugin tests and blocking high-severity dependency audits running in every gate.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Rofi menu prompts now keep a space between the prompt text and the input field instead of running into the search placeholder.
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
type: fixed
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Validated nested and legacy AnkiConnect settings after splitting the resolver, preserving valid modern overrides while warning and falling back for invalid primitive values.
|
||||||
|
- Hardened stats routes against malformed IDs and static paths, stalled AniList searches, word-mining media collisions, missing Yomitan bridges, and throwing timing observers.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
type: added
|
||||||
|
area: sync
|
||||||
|
|
||||||
|
- Added cross-machine immersion sync for stats and watch history over SSH, available as a window (**Sync Stats & History** in the tray menu, or `subminer sync --ui`) and as a command (`subminer sync <host>`, with `--push` / `--pull` for one-way insert-only transfers). The window keeps saved devices with per-host direction, one-click sync with live stage-by-stage progress and separate merge summaries for each machine, connection testing for first-time setup, cancellable runs while the app/stats server/playback is active, and manual database snapshots (create/merge/reveal/delete, stored in `/tmp/subminer-db-snapshots/` by default). Hosts with auto-sync enabled sync in the background on a configurable interval, including during playback, with results reported as overlay notifications; hosts synced from the CLI are remembered in `<config dir>/sync-hosts.json` and appear in the window automatically.
|
||||||
|
- Merges are an insert-only union keyed on session UUIDs / video keys / series title keys, so re-syncing is idempotent and nothing is double-counted: each side snapshots its database (`VACUUM INTO`) from a consistent WAL point, snapshots are exchanged with `scp`, and each machine merges the other's data transactionally. Lifetime totals and daily/monthly rollups are updated incrementally (history older than the session retention window is preserved), unfinished sessions are excluded until a later sync sees them finalized, and remote-only historical rollups are copied only when they do not conflict with retained local session history. Sync aborts on stats schema version mismatches and refuses to run while the stats daemon or a live mpv session is active (`--force` overrides).
|
||||||
|
- The sync engine runs only inside the app: the sync window and the `subminer sync` command both delegate to `SubMiner --sync-cli` (headless, works over SSH with no display), so neither machine needs bun or the command-line launcher. A remote machine only needs SubMiner itself, found automatically as the app binary or via the launcher proxy.
|
||||||
|
- Windows remotes are supported: sync detects the remote shell (POSIX, cmd, or PowerShell) and manages remote temp files through SubMiner itself (`sync --make-temp` / `--remove-temp`) instead of `mktemp` / `rm`, so a Windows machine with the built-in OpenSSH Server works as a sync remote, found in its default Windows install location automatically.
|
||||||
|
- Added supporting flags: `subminer sync <host> --check` tests the SSH connection and remote launcher availability without syncing, `subminer sync --snapshot <file>` and `--merge <file>` expose the underlying steps for manual transfers, and `subminer sync --json` emits machine-readable NDJSON progress (the protocol the sync window consumes).
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: added
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Added TsukiHime subtitle downloads for the current video, with Japanese primary and configured secondary-language tracks loaded directly into mpv.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: internal
|
||||||
|
area: stats
|
||||||
|
|
||||||
|
- Removed the unused stats IPC data transport and unified the stats dashboard's HTTP wire types with the backend contract.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
type: internal
|
||||||
|
area: tokenizer
|
||||||
|
|
||||||
|
- Added `verify-known-word-highlights:electron` script: tokenizes a real subtitle file through the app's Yomitan/MeCab pipeline with the live known-word cache, prints each line in the configured tier colors, and summarizes the tier counts so highlighting can be checked outside of playback.
|
||||||
|
- Added `--audit`, which re-derives every highlighted tier from live Anki card data (`notesInfo` + `cardsInfo` intervals) and reports each token whose rendered tier disagrees, catching both stale cache entries and tier-classification bugs.
|
||||||
|
- Added `--profile-copy` so the check can run while SubMiner is open (Electron locks the Yomitan userData dir), plus `--refresh`, `--limit`, `--json`, and `--quiet`.
|
||||||
|
- Added `KnownWordCacheManager.getKnownWordMatchNoteIds`, exposing the note ids behind a known-word match so an audit can trace a rendered tier back to the exact Anki notes.
|
||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,10 +605,7 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
}, // Is kiku setting.
|
} // Is kiku setting.
|
||||||
"lapisKiku": {
|
|
||||||
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
|
||||||
} // Lapis kiku setting.
|
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -694,7 +691,7 @@
|
|||||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
||||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||||
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||||
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
|
|||||||
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
||||||
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
||||||
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
||||||
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
|
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.14.0';
|
||||||
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
||||||
const versionLinkOrigin =
|
const versionLinkOrigin =
|
||||||
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
|
optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production';
|
||||||
@@ -306,7 +306,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
|||||||
{ text: 'Usage', link: '/usage' },
|
{ text: 'Usage', link: '/usage' },
|
||||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||||
{ text: 'Feature Demos', link: '/demos' },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ SubMiner monitors playback and triggers an AniList progress update when an episo
|
|||||||
The update flow:
|
The update flow:
|
||||||
|
|
||||||
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
1. **Title detection** -- SubMiner extracts the anime title, season, and episode number from the media filename and path. Season folders such as `Season 2` are treated as a strong season signal. SubMiner tries [`guessit`](https://github.com/guessit-io/guessit) first for accurate parsing, then falls back to an internal filename parser if guessit is unavailable.
|
||||||
2. **AniList search** -- The base title (with any `Season N` / `SN` marker stripped) is searched against the AniList GraphQL API, and SubMiner picks the best match by comparing titles (romaji, English, native, synonyms) and filtering by episode count. AniList has no notion of numbered seasons -- sequels are separate entries with their own titles (`Zoku`, `Kan`, `2nd Season`), so searching `<title> Season 3` finds nothing. For season 2 and later, SubMiner instead walks `SEQUEL` relations from the season 1 entry, preferring the TV line, and falls back to ordering the franchise's TV entries by air date when the relation chain is incomplete. If neither locates the season, SubMiner **skips the update** rather than writing progress to the season 1 entry, and tells you to pin the right entry with a [character dictionary override](/character-dictionary#correcting-anilist-matches).
|
2. **AniList search** -- The detected title is searched against the AniList GraphQL API. For season 2 and later files, SubMiner searches the season-specific title first, then falls back to the base title. SubMiner picks the best match by comparing titles (romaji, English, native) and filtering by episode count.
|
||||||
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
3. **Progress check** -- SubMiner fetches your current list entry for the matched media. The media must already be in Planning or Watching; otherwise SubMiner shows an MPV message explaining that the update is not possible. If your recorded progress already meets or exceeds the detected episode, the update is skipped.
|
||||||
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
|
4. **Mutation** -- A `SaveMediaListEntry` mutation sets the new progress and marks the entry as `CURRENT`, or `COMPLETED` when the watched episode is the final episode of the season (the "already at this progress" skip is bypassed for the final episode so completion still lands).
|
||||||
|
|
||||||
@@ -69,8 +69,6 @@ SubMiner fetches cover art from AniList for display in the stats dashboard. When
|
|||||||
|
|
||||||
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
|
A no-match result is cached for 5 minutes before SubMiner retries, preventing repeated API calls for unrecognized media.
|
||||||
|
|
||||||
If the automatic match is wrong, use **Change AniList Entry** on a title in the stats Library. Relinking rewrites the cached art for every episode of that title, and both the detail view and the Library grid pick up the new cover right away: the grid refetches after a relink, and cover responses carry an ETag and are revalidated on each request instead of being cached for a day.
|
|
||||||
|
|
||||||
## Rate Limiting
|
## Rate Limiting
|
||||||
|
|
||||||
All AniList API calls go through a shared rate limiter that enforces a sliding window of 20 requests per minute. The limiter also reads AniList's `X-RateLimit-Remaining` and `Retry-After` response headers and pauses requests when the server signals throttling. This applies to both episode tracking and cover art fetching.
|
All AniList API calls go through a shared rate limiter that enforces a sliding window of 20 requests per minute. The limiter also reads AniList's `X-RateLimit-Remaining` and `Retry-After` response headers and pauses requests when the server signals throttling. This applies to both episode tracking and cover art fetching.
|
||||||
@@ -96,7 +94,7 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------ |
|
| ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||||
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
| `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) |
|
||||||
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) |
|
||||||
|
|||||||
@@ -289,21 +289,6 @@ Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is c
|
|||||||
|
|
||||||
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
||||||
|
|
||||||
## Word Card Type (Kiku/Lapis)
|
|
||||||
|
|
||||||
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"ankiConnect": {
|
|
||||||
"isKiku": { "enabled": true },
|
|
||||||
"lapisKiku": {
|
|
||||||
"wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
|
|
||||||
|
|
||||||
## Field Grouping (Kiku)
|
## Field Grouping (Kiku)
|
||||||
|
|
||||||
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
||||||
@@ -329,7 +314,7 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
|||||||
### What Gets Merged
|
### What Gets Merged
|
||||||
|
|
||||||
| Field | Merge behavior |
|
| Field | Merge behavior |
|
||||||
| -------- | --------------------------------------------- |
|
| -------- | ---------------------------------------- |
|
||||||
| Sentence | Both cards' sentences kept as grouped entries |
|
| Sentence | Both cards' sentences kept as grouped entries |
|
||||||
| Audio | Both cards' `[sound:...]` entries kept |
|
| Audio | Both cards' `[sound:...]` entries kept |
|
||||||
| Image | Both cards' images kept |
|
| Image | Both cards' images kept |
|
||||||
|
|||||||
@@ -75,8 +75,8 @@ src/
|
|||||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||||
handlers/ # Keyboard/mouse/gamepad interaction modules
|
handlers/ # Keyboard/mouse/gamepad interaction modules
|
||||||
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
||||||
# changelog, character dictionary, playlist browser, subtitle
|
# character dictionary, playlist browser, subtitle sidebar,
|
||||||
# sidebar, YouTube track picker, controller config/debug/select)
|
# YouTube track picker, controller config/debug/select)
|
||||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||||
settings/ # Settings window UI (model, controls, markup)
|
settings/ # Settings window UI (model, controls, markup)
|
||||||
types/ # Domain type modules (anki, config, integrations, ...)
|
types/ # Domain type modules (anki, config, integrations, ...)
|
||||||
|
|||||||
+2
-94
@@ -1,98 +1,6 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## v0.19.3 (2026-08-13)
|
## v0.18.0 (2026-07-10)
|
||||||
|
|
||||||
**Added**
|
|
||||||
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
|
|
||||||
|
|
||||||
**Changed**
|
|
||||||
- Subtitle Tokenization Performance: Reworks subtitle dictionary lookups to cut per-line work roughly in half, cache repeated lookups across lines, and stop tokenization from competing with on-screen subtitle prefetching. Also fixes several accuracy issues along the way: dropped readings on trailing kana, character names being skipped after a dictionary sync, annotations not refreshing after mining a card, and halfwidth katakana character names losing their reading or being swallowed by other words.
|
|
||||||
|
|
||||||
**Fixed**
|
|
||||||
- Character Dictionary Large Imports: Large character dictionaries (e.g. One Piece) no longer fail to install from a fixed timeout budget; the import now scales its time budget to dictionary size and reports detailed progress (page/character counts, image download progress, elapsed time) instead of one static message.
|
|
||||||
- Stats Delete Responsiveness: Deleting sessions, episodes, or library entries no longer freezes the stats page or an active video player; deletes are now batched into a single transaction.
|
|
||||||
- Styled Subtitle Cue Parsing: Heavily typeset subtitles (karaoke, signs) no longer flood the subtitle sidebar with garbage; vector drawing commands are no longer shown as text, and duplicate/animation-burst cues now collapse into one.
|
|
||||||
- X11 mpv Renderer: Fixes an mpv crash on the first fullscreen toggle for X11/XWayland users with `gpu-next` shaders (e.g. ArtCNN), which was caused by X11 mode forcing the legacy OpenGL renderer.
|
|
||||||
- X11 Overlay Display Scaling: Fixes the overlay appearing oversized and offset from mpv on X11/XWayland under fractional or mixed-monitor display scaling.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Internal changes</summary>
|
|
||||||
|
|
||||||
**Internal**
|
|
||||||
- Subtitle text is now decoded from ASS exactly once at ingest, so the renderer, timing tracker, and tokenizer all share one decoded value instead of each re-deriving it.
|
|
||||||
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## v0.19.2 (2026-08-04)
|
|
||||||
|
|
||||||
**Changed**
|
|
||||||
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
|
||||||
|
|
||||||
**Fixed**
|
|
||||||
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
|
||||||
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
|
||||||
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Internal changes</summary>
|
|
||||||
|
|
||||||
**Internal**
|
|
||||||
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## v0.19.1 (2026-08-01)
|
|
||||||
|
|
||||||
**Added**
|
|
||||||
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
|
||||||
|
|
||||||
**Fixed**
|
|
||||||
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
|
||||||
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
|
||||||
|
|
||||||
## v0.19.0 (2026-07-29)
|
|
||||||
|
|
||||||
**Added**
|
|
||||||
- Anki Maturity Highlighting: Known-word subtitle highlights can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Tier thresholds and colors are configurable, with a runtime toggle and an updated help legend.
|
|
||||||
- Post-Playback Menu: After a watch-history episode ends, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit. The pre-playback series menu now offers the previous episode too.
|
|
||||||
- Delete Library Entries: The stats Library detail view can now delete an entire title in one step (episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts). Delete progress is now shown app-wide via a progress bar and status toast instead of disappearing when you switch tabs.
|
|
||||||
- Cross-Machine Sync: Added SSH-based syncing of stats and watch history between machines, available from the tray ("Sync Stats & History") or `subminer sync`, with saved devices, per-host sync direction, background auto-sync, connection testing, manual snapshots, and support for Windows remotes.
|
|
||||||
- TsukiHime Subtitle Downloads: Added subtitle downloads for the current video via TsukiHime, loading Japanese as the primary track and your configured secondary language directly into mpv.
|
|
||||||
|
|
||||||
**Changed**
|
|
||||||
- Clipboard-Video Shortcut: The "append clipboard video to queue" shortcut is now configurable.
|
|
||||||
|
|
||||||
**Fixed**
|
|
||||||
- AniList Season Resolution: Season 2+ files now resolve to the correct AniList entry instead of silently falling back to season 1 (which mismatched character dictionaries and watch progress). Manual overrides now stay scoped per season, fix both the dictionary and progress tracking together, and also correct per-season cover art.
|
|
||||||
- Subtitle Annotation Accuracy: Fixed several annotation edge cases, including inconsistent POS exclusions on merged quote-particle tokens, dropped annotations on supplementary-plane kanji, katakana punctuation wrongly treated as noise, and certain kanji vocabulary losing N+1 highlighting eligibility.
|
|
||||||
- AnkiConnect Proxy Port Conflict: Video startup no longer crashes when another process already holds the configured AnkiConnect proxy port; a notification now explains how to resolve it.
|
|
||||||
- AppImage Quit Crash: Fixed a "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
|
||||||
- Autoplay Pause Timing: Fixed playback resuming a few seconds before subtitle tokenization warmup finished, most noticeable when resuming mid-episode or when a cue starts within the first two seconds.
|
|
||||||
- Stats Known-Word Count: Fixed stats reporting 0 known words for every session after the known-word cache format changed.
|
|
||||||
- Stats Library Cover Art: Relinking a title to a different AniList entry now updates its cover in the Library grid immediately instead of leaving a stale, mismatched cover cached.
|
|
||||||
- Rofi Prompt Spacing: Rofi menu prompts now keep a space before the input field instead of running into the placeholder text.
|
|
||||||
- Stats Settings & Reliability: Hardened stats settings validation (nested/legacy AnkiConnect config now falls back safely instead of breaking) and stats routes against malformed requests and other edge cases.
|
|
||||||
- Stats Delete Performance: Deleting sessions, episodes, and library entries is now dramatically faster and no longer stalls playback (e.g. a 12-episode title dropped from about a minute to under a second on a large library); the Vocabulary tab also loads much faster. The first launch after upgrading runs a one-time database migration.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>Internal changes</summary>
|
|
||||||
|
|
||||||
**Internal**
|
|
||||||
- Added a golden-file regression test corpus for the tokenizer/annotation pipeline, plus scripts to record new fixtures and diff against stock Yomitan.
|
|
||||||
- Consolidated renderer modal state handling into a descriptor registry.
|
|
||||||
- Consolidated CI quality checks (PR, stable, and prerelease) into one reusable workflow with mpv plugin tests and dependency audits.
|
|
||||||
- Removed the unused stats IPC transport and unified stats dashboard HTTP types with the backend contract.
|
|
||||||
- Added a script to verify known-word highlight tiers against live Anki data outside of playback.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## Previous Versions
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>v0.18.x</summary>
|
|
||||||
|
|
||||||
<h2>v0.18.0 (2026-07-10)</h2>
|
|
||||||
|
|
||||||
**Added**
|
**Added**
|
||||||
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
|
- Sentence Audio Normalization: Generated sentence audio is now normalized to -23 LUFS by default, and clips mined from playback mirror mpv's software volume curve with a limiter to prevent clipping. Both behaviors are configurable independently.
|
||||||
@@ -123,7 +31,7 @@
|
|||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
</details>
|
## Previous Versions
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>v0.17.x</summary>
|
<summary>v0.17.x</summary>
|
||||||
|
|||||||
@@ -223,9 +223,7 @@ SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary
|
|||||||
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory **and detected season** define the override scope, so later episodes in the same season keep the selected AniList ID even if their filename guesses differ, while a different season never inherits the override -- including when every season sits in one flat folder. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
SubMiner stores manual selections in `character-dictionaries/anilist-overrides.json`. The episode's parent directory defines the override scope, so later episodes in the same season directory keep the selected AniList ID even if their filename guesses differ. Separate season directories can keep separate overrides and character dictionaries. When you replace a wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
|
||||||
|
|
||||||
An override also pins the entry used for [AniList watch progress](/anilist-integration), so correcting a wrong match once fixes both the character dictionary and progress tracking.
|
|
||||||
|
|
||||||
## Managing Loaded Entries
|
## Managing Loaded Entries
|
||||||
|
|
||||||
|
|||||||
+12
-67
@@ -71,10 +71,6 @@ When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
|
|||||||
|
|
||||||
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
||||||
|
|
||||||
::: warning One value in that file is platform-specific
|
|
||||||
The example is generated with a fixed Linux/macOS socket path so it stays reproducible, so it shows `"socketPath": "/tmp/subminer-socket"`. On Windows the real default is `\\\\.\\pipe\\subminer-socket`. Leave `mpv.socketPath` out of your config entirely unless you need a custom path, and SubMiner picks the right one for your platform.
|
|
||||||
:::
|
|
||||||
|
|
||||||
Generate a fresh default config from the centralized config registry:
|
Generate a fresh default config from the centralized config registry:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -149,13 +145,12 @@ The configuration file includes several main sections:
|
|||||||
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
|
- [**Shared AI Provider**](#shared-ai-provider) - Canonical OpenAI-compatible provider config shared by Anki and YouTube subtitle fixing
|
||||||
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
- [**AnkiConnect**](#ankiconnect) - Automatic Anki card creation with media
|
||||||
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
|
- [**Kiku/Lapis Integration**](#kiku-lapis-integration) - Sentence cards and duplicate handling for Kiku/Lapis note types
|
||||||
- [**N+1 Word Highlighting**](#n-1-word-highlighting) - Known-word cache and single-target highlighting
|
- [**N+1 Word Highlighting**](#n1-word-highlighting) - Known-word cache and single-target highlighting
|
||||||
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
|
- [**Field Grouping Modes**](#field-grouping-modes) - Kiku/Lapis duplicate card merging
|
||||||
|
|
||||||
**External Integrations**
|
**External Integrations**
|
||||||
|
|
||||||
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
- [**Jimaku**](#jimaku) - Jimaku API configuration and defaults
|
||||||
- [**TsukiHime**](#tsukihime) - Multi-language subtitle search and download
|
|
||||||
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
- [**Subtitle Sync**](#subtitle-sync) - Sync current subtitle with `alass`/`ffsubsync`
|
||||||
- [**AniList**](#anilist) - Optional post-watch progress updates
|
- [**AniList**](#anilist) - Optional post-watch progress updates
|
||||||
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
|
- [**Yomitan**](#yomitan) - Reuse an external read-only Yomitan profile
|
||||||
@@ -399,7 +394,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
||||||
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
||||||
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
||||||
@@ -543,8 +538,6 @@ Display a second subtitle track (e.g., English alongside Japanese) in the overla
|
|||||||
|
|
||||||
See `config.example.jsonc` for detailed configuration options.
|
See `config.example.jsonc` for detailed configuration options.
|
||||||
|
|
||||||
Secondary subtitles do **not** auto-load by default. To turn them on for local and Jellyfin playback, set `autoLoadSecondarySub` to `true` and list the language codes you want:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"secondarySub": {
|
"secondarySub": {
|
||||||
@@ -556,15 +549,11 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match |
|
||||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load matching secondary subtitle track |
|
||||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||||
|
|
||||||
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
|
||||||
|
|
||||||
Because the mined-card translation field is filled from the secondary subtitle when one is present, leaving `autoLoadSecondarySub` off means local-file cards fall back to AI translation (when configured) or the original sentence text.
|
|
||||||
|
|
||||||
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
The secondary-subtitle language list also acts as the fallback secondary-language priority for managed startup subtitle selection on local playback and YouTube playback.
|
||||||
|
|
||||||
**Display modes:**
|
**Display modes:**
|
||||||
@@ -874,8 +863,8 @@ When config hot-reload updates shortcut/keybinding/style values, close and reope
|
|||||||
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
Use the runtime options palette to toggle settings live while SubMiner is running. These changes are session-only and reset on restart.
|
||||||
|
|
||||||
Current runtime options cover automatic card updates, known-word highlighting,
|
Current runtime options cover automatic card updates, known-word highlighting,
|
||||||
known-word maturity coloring, N+1 annotation, JLPT underlines, frequency
|
N+1 annotation, JLPT underlines, frequency highlighting, known-word match mode,
|
||||||
highlighting, known-word match mode, and Kiku field grouping mode.
|
and Kiku field grouping mode.
|
||||||
|
|
||||||
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
Annotation toggles only apply to new subtitle lines after the toggle. The currently displayed line is not re-tokenized in place.
|
||||||
|
|
||||||
@@ -1042,8 +1031,6 @@ This example is intentionally compact. The option table below documents availabl
|
|||||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||||
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
|
|
||||||
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
|
||||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||||
@@ -1069,9 +1056,6 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
|||||||
"enabled": true,
|
"enabled": true,
|
||||||
"fieldGrouping": "manual",
|
"fieldGrouping": "manual",
|
||||||
"deleteDuplicateInAuto": true
|
"deleteDuplicateInAuto": true
|
||||||
},
|
|
||||||
"lapisKiku": {
|
|
||||||
"wordCardKind": "word-and-sentence"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -1080,21 +1064,6 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
|||||||
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
|
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
|
||||||
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
|
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
|
||||||
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
||||||
- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
|
|
||||||
|
|
||||||
### Word Card Type
|
|
||||||
|
|
||||||
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
|
|
||||||
|
|
||||||
| Value | Flag set |
|
|
||||||
| ----------------------------- | ----------------------- |
|
|
||||||
| `word-and-sentence` (default) | `IsWordAndSentenceCard` |
|
|
||||||
| `click` | `IsClickCard` |
|
|
||||||
| `sentence` | `IsSentenceCard` |
|
|
||||||
| `audio` | `IsAudioCard` |
|
|
||||||
| `none` | none; flags left as-is |
|
|
||||||
|
|
||||||
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
|
|
||||||
|
|
||||||
### N+1 Word Highlighting
|
### N+1 Word Highlighting
|
||||||
|
|
||||||
@@ -1107,7 +1076,6 @@ Known-word cache policy:
|
|||||||
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
|
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
|
||||||
- The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
|
- The N+1 minimum sentence-word setting controls the token count required before N+1 highlighting can trigger.
|
||||||
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
|
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
|
||||||
- Set `ankiConnect.knownWords.maturityEnabled` to `true` to color known words by Anki card maturity instead, using the four `subtitleStyle.knownWordMaturityColors` tiers. See [Known-Word Maturity Highlighting](/subtitle-annotations#known-word-maturity-highlighting) for how tiers are derived. Changing it or `matureThresholdDays` forces a full cache refresh.
|
|
||||||
- The known-word deck map accepts an object keyed by deck name.
|
- The known-word deck map accepts an object keyed by deck name.
|
||||||
- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
|
- Prefer expression/word fields such as `Expression` or `Word`. Avoid reading-only fields unless you intentionally want homophone readings to count as known words.
|
||||||
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
||||||
@@ -1146,7 +1114,6 @@ When the manual merge popup opens, SubMiner pauses playback and closes any open
|
|||||||
|
|
||||||
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
|
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
|
||||||
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
|
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
|
||||||
<source :src="withBase('/assets/kiku-integration.mp4')" type="video/mp4" />
|
|
||||||
Your browser does not support the video tag.
|
Your browser does not support the video tag.
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
@@ -1172,33 +1139,11 @@ Configure Jimaku API access and defaults:
|
|||||||
|
|
||||||
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
|
Jimaku is rate limited; if you hit a limit, SubMiner will surface the retry delay from the API response.
|
||||||
|
|
||||||
### TsukiHime
|
|
||||||
|
|
||||||
TsukiHime subtitle search works out of the box and needs no account or API key. It does require the `xz` binary on your `PATH`, because TsukiHime serves extracted subtitles xz-compressed.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tsukihime": {
|
|
||||||
"apiBaseUrl": "https://api.tsukihime.org/v1",
|
|
||||||
"maxSearchResults": 10
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Option | Values | Description |
|
|
||||||
| ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
|
|
||||||
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
|
||||||
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
|
||||||
|
|
||||||
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
|
|
||||||
|
|
||||||
See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, language tabs, and troubleshooting.
|
|
||||||
|
|
||||||
### Subtitle Sync
|
### Subtitle Sync
|
||||||
|
|
||||||
Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||||
|
|
||||||
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using another subtitle as reference; it can also take the local video file as reference (alass extracts the audio itself)
|
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using a secondary subtitle as reference
|
||||||
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
|
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -1247,7 +1192,7 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
||||||
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
||||||
@@ -1546,7 +1491,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
|||||||
"executablePath": "",
|
"executablePath": "",
|
||||||
"launchMode": "normal",
|
"launchMode": "normal",
|
||||||
"profile": "",
|
"profile": "",
|
||||||
"socketPath": "/tmp/subminer-socket",
|
"socketPath": "\\\\.\\pipe\\subminer-socket",
|
||||||
"backend": "auto",
|
"backend": "auto",
|
||||||
"autoStartSubMiner": true,
|
"autoStartSubMiner": true,
|
||||||
"pauseUntilOverlayReady": true,
|
"pauseUntilOverlayReady": true,
|
||||||
@@ -1558,7 +1503,7 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
|||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
|
|||||||
</a>
|
</a>
|
||||||
</video>
|
</video>
|
||||||
|
|
||||||
|
::: info VIDEO COMING SOON
|
||||||
|
:::
|
||||||
|
|
||||||
## Subtitle Download & Sync
|
## Subtitle Download & Sync
|
||||||
|
|
||||||
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
|
Search and download subtitles from Jimaku, then retime them with alass or ffsubsync - all from within SubMiner.
|
||||||
|
|||||||
@@ -14,10 +14,6 @@ const ankiIntegrationContents = readFileSync(
|
|||||||
'utf8',
|
'utf8',
|
||||||
);
|
);
|
||||||
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
const configurationContents = readFileSync(new URL('./configuration.md', import.meta.url), 'utf8');
|
||||||
const troubleshootingContents = readFileSync(
|
|
||||||
new URL('./troubleshooting.md', import.meta.url),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
function extractReleaseHeadings(content: string, count: number): string[] {
|
function extractReleaseHeadings(content: string, count: number): string[] {
|
||||||
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
|
return Array.from(content.matchAll(/^## v[^\n]+$/gm))
|
||||||
@@ -62,33 +58,6 @@ test('docs reflect current launcher and release surfaces', () => {
|
|||||||
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('docs document config surfaces that are easy to miss when they ship', () => {
|
|
||||||
// Anki maturity-based known-word highlighting (#172) landed in
|
|
||||||
// subtitle-annotations.md but was missing from the config reference.
|
|
||||||
expect(configurationContents).toContain('ankiConnect.knownWords.maturityEnabled');
|
|
||||||
expect(configurationContents).toContain('ankiConnect.knownWords.matureThresholdDays');
|
|
||||||
|
|
||||||
// Every top-level config block should be reachable from the config reference.
|
|
||||||
expect(configurationContents).toContain('### TsukiHime');
|
|
||||||
expect(configurationContents).toContain('tsukihime.maxSearchResults');
|
|
||||||
|
|
||||||
// xz is a hard runtime dependency of the TsukiHime download path.
|
|
||||||
expect(installationContents).toContain('xz');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('docs state the real secondary-subtitle and Anki field-matching behavior', () => {
|
|
||||||
// secondarySub auto-load is off by default; the config example previously
|
|
||||||
// implied otherwise while youtube-integration.md documented it correctly.
|
|
||||||
expect(configurationContents).toContain('Secondary subtitles do **not** auto-load by default');
|
|
||||||
expect(configurationContents).toContain('default: `false`');
|
|
||||||
|
|
||||||
// Anki field names are matched case-insensitively (src/anki-integration.ts
|
|
||||||
// resolveFieldName: exact match first, then a lowercase comparison).
|
|
||||||
expect(usageContents).not.toContain('exactly (case-sensitive)');
|
|
||||||
expect(troubleshootingContents).not.toContain('exactly (case-sensitive)');
|
|
||||||
expect(ankiIntegrationContents).toContain('case-insensitively');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('docs dev server links version navigation to local dev routes', () => {
|
test('docs dev server links version navigation to local dev routes', () => {
|
||||||
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
|
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
|
||||||
expect(docsPackageContents).toContain(
|
expect(docsPackageContents).toContain(
|
||||||
|
|||||||
@@ -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; `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
|
||||||
@@ -57,8 +57,6 @@ Jellyfin stream URLs are normalized to stable item links before stats titles are
|
|||||||
|
|
||||||
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
|
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
|
||||||
|
|
||||||
Open a title and use **Delete Entry** in its header to remove a mistakenly tracked show outright. This deletes every episode of that title along with their sessions, subtitle lines, rollups and cover art, drops the words and kanji that were only seen there, and removes the card from the Library grid. Individual episodes and sessions can still be deleted on their own from the episode list and session rows. Entry deletion is refused while that title is the one currently playing.
|
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
#### Trends
|
#### Trends
|
||||||
@@ -125,34 +123,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:
|
||||||
|
|||||||
+17
-109
@@ -12,23 +12,20 @@ Three steps to get started:
|
|||||||
|
|
||||||
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
Only **mpv** is strictly required to run SubMiner. Everything else enhances the experience but is optional.
|
||||||
|
|
||||||
Several entries below exist only for the `subminer` command-line launcher, which is Linux and macOS only. On Windows you launch playback with the **SubMiner mpv** shortcut instead, so you can ignore those rows.
|
| Dependency | Status | What it does |
|
||||||
|
| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Dependency | Status | Platforms | What it does |
|
| mpv | Required | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
||||||
| -------------------- | ----------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ffmpeg | Recommended | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
||||||
| mpv | Required | All | The video player SubMiner overlays on. Must support `--input-ipc-server`. |
|
| MeCab + mecab-ipadic | Recommended | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
||||||
| ffmpeg | Recommended | All | Audio extraction and screenshots for Anki cards. Without it SubMiner still runs, but media fields will be empty. |
|
| yt-dlp | Optional | YouTube playback and subtitle extraction. |
|
||||||
| MeCab + mecab-ipadic | Recommended | All | Part-of-speech filtering for more precise N+1, JLPT, and frequency annotations. Without it annotations still render, but POS-based filtering is less accurate. |
|
| fzf | Optional | Terminal-based video picker in the launcher. |
|
||||||
| yt-dlp | Optional | All | YouTube playback and subtitle extraction. |
|
| rofi | Optional | GUI-based video picker (Linux). |
|
||||||
| xz | Optional | All | Required for TsukiHime subtitle downloads (subtitles are served xz-compressed). Preinstalled on most Linux distros; not present on Windows by default. |
|
| chafa | Optional | Thumbnail previews in fzf. |
|
||||||
| guessit | Optional | All | Better AniSkip title/season/episode parsing. |
|
| ffmpegthumbnailer | Optional | Video thumbnail generation for the picker. |
|
||||||
| alass | Optional | All | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
| guessit | Optional | Better AniSkip title/season/episode parsing. |
|
||||||
| ffsubsync | Optional | All | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
| alass | Optional | Subtitle sync engine (preferred). Disabled without alass or ffsubsync. |
|
||||||
| fzf | Optional | Linux, macOS | Terminal-based video picker in the `subminer` launcher. |
|
| ffsubsync | Optional | Audio-based subtitle sync engine. Disabled without alass or ffsubsync. |
|
||||||
| rofi | Optional | Linux | GUI-based video picker in the `subminer` launcher. |
|
| fuse2 | Linux only | Required to run the AppImage. |
|
||||||
| chafa | Optional | Linux, macOS | Thumbnail previews in the fzf picker. |
|
|
||||||
| ffmpegthumbnailer | Optional | Linux, macOS | Video thumbnail generation for the pickers. |
|
|
||||||
| fuse2 | Required | Linux | Needed to run the AppImage. |
|
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
@@ -112,98 +109,9 @@ pip install ffsubsync
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
Windows 10 or later. No compositor tools or window helpers are needed - native window tracking is built in.
|
Windows 10 or later. Install [`mpv`](https://mpv.io/installation/) and [`ffmpeg`](https://ffmpeg.org/download.html) and ensure both are on `PATH`. Optionally install [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary.
|
||||||
|
|
||||||
You need **mpv** (required) and **ffmpeg** (strongly recommended, for card audio and screenshots), and both must be on your `PATH`.
|
No compositor tools or window helpers are needed - native window tracking is built in.
|
||||||
|
|
||||||
::: tip What is PATH?
|
|
||||||
`PATH` is the list of folders Windows searches when a program asks to run another program by name. SubMiner runs `mpv` and `ffmpeg` by name, so if their folders are not on `PATH`, SubMiner cannot find them even though they are installed. The routes below mostly handle `PATH` for you; the manual route explains how to add a folder yourself.
|
|
||||||
:::
|
|
||||||
|
|
||||||
You can install these with a package manager or by hand. Coverage differs, so pick based on what you need:
|
|
||||||
|
|
||||||
| Dependency | winget | Scoop |
|
|
||||||
| ---------------- | --------------- | ------------- |
|
|
||||||
| mpv (required) | `shinchiro.mpv` | `extras/mpv` |
|
|
||||||
| ffmpeg | `Gyan.FFmpeg` | `main/ffmpeg` |
|
|
||||||
| yt-dlp (YouTube) | `yt-dlp.yt-dlp` | `main/yt-dlp` |
|
|
||||||
| xz (TsukiHime) | not packaged | `main/xz` |
|
|
||||||
|
|
||||||
Use **winget** if you want Microsoft's first-party tool and don't need TsukiHime subtitle downloads. Use **Scoop** if you want one package manager to cover everything, since it is the only one that also packages `xz`.
|
|
||||||
|
|
||||||
#### Recommended: winget
|
|
||||||
|
|
||||||
[winget](https://learn.microsoft.com/windows/package-manager/winget/) is Microsoft's own package manager and ships with Windows 11 and current Windows 10 (it comes with **App Installer** from the Microsoft Store). In **PowerShell** or **Command Prompt**:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
winget install shinchiro.mpv
|
|
||||||
winget install Gyan.FFmpeg
|
|
||||||
```
|
|
||||||
|
|
||||||
Close and reopen your terminal, then check that both are found:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
mpv --version
|
|
||||||
ffmpeg -version
|
|
||||||
```
|
|
||||||
|
|
||||||
`ffmpeg` is installed as a portable package, so winget links it into a folder that is already on your `PATH` and it should work right away.
|
|
||||||
|
|
||||||
`mpv` uses a regular installer, and depending on the version it may **not** add itself to `PATH`. If `mpv --version` says `not recognized`, you have two easy options:
|
|
||||||
|
|
||||||
- Note where it installed (usually `%LOCALAPPDATA%\Programs\mpv`) and add that folder to `PATH` using the manual steps below, or
|
|
||||||
- Skip `PATH` entirely and set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup.
|
|
||||||
|
|
||||||
Once `mpv --version` works, or you have the full path to `mpv.exe` ready, continue to [step 2](#_2-install-subminer).
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Alternative: Scoop (covers every dependency, no admin rights)</b></summary>
|
|
||||||
|
|
||||||
[Scoop](https://scoop.sh) installs into your user profile, needs no administrator prompt, and always puts commands on `PATH`. It is the only Windows package manager that carries all of SubMiner's optional dependencies, including `xz`, so it is the best choice if you want a single tool to manage everything.
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# One-time Scoop setup (skip if you already have it)
|
|
||||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
|
||||||
Invoke-RestMethod -Uri https://get.scoop.sh | Invoke-Expression
|
|
||||||
|
|
||||||
# mpv lives in the "extras" bucket; everything else is in "main"
|
|
||||||
scoop bucket add extras
|
|
||||||
scoop install extras/mpv main/ffmpeg
|
|
||||||
|
|
||||||
# Optional: yt-dlp for YouTube playback, xz for TsukiHime subtitle downloads
|
|
||||||
scoop install main/yt-dlp main/xz
|
|
||||||
```
|
|
||||||
|
|
||||||
Close and reopen your terminal, then verify with `mpv --version` and `ffmpeg -version`.
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Manual install (download the zips yourself)</b></summary>
|
|
||||||
|
|
||||||
1. Download mpv from [mpv.io/installation](https://mpv.io/installation/) (the Windows builds link) and ffmpeg from [ffmpeg.org/download.html](https://ffmpeg.org/download.html).
|
|
||||||
2. Unzip each one somewhere permanent, for example `C:\Tools\mpv` and `C:\Tools\ffmpeg`. Note the folder that actually contains `mpv.exe` and the one containing `ffmpeg.exe` (for ffmpeg this is usually a `bin` subfolder).
|
|
||||||
3. Press `Win`, type **Edit the system environment variables**, and open it. Click **Environment Variables…**, select **Path** under **User variables**, click **Edit…**, then use **New** to add each of those two folders. Confirm with **OK** on every dialog. Microsoft documents this in more detail under [environment variables](https://learn.microsoft.com/windows/deployment/usmt/usmt-recognized-environment-variables).
|
|
||||||
4. Close and reopen your terminal, since `PATH` changes only apply to newly opened windows. Then check:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
mpv --version
|
|
||||||
ffmpeg -version
|
|
||||||
```
|
|
||||||
|
|
||||||
If you see `not recognized as the name of a cmdlet`, the folder you added is not the one holding the `.exe`. Reopen the Path editor and double-check.
|
|
||||||
|
|
||||||
::: tip mpv can skip PATH, ffmpeg cannot
|
|
||||||
If you would rather not edit `PATH` for mpv, set `mpv.executablePath` to the full path of `mpv.exe` during first-run setup instead.
|
|
||||||
|
|
||||||
There is no equivalent setting for ffmpeg: SubMiner invokes it by bare name when generating card audio and screenshots, so ffmpeg has to be on `PATH`. Without it, cards are still created but their audio and image fields come out empty. (`subsync.ffmpeg_path` only affects subtitle sync, not card media.)
|
|
||||||
:::
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
**Optional extras:** [MeCab for Windows](https://taku910.github.io/mecab/#download) with the UTF-8 dictionary improves annotation accuracy; it is not in any package manager, so install it from that page. `xz` is needed only for [TsukiHime](/tsukihime-integration) subtitle downloads and is not packaged by winget or Chocolatey, so use `scoop install main/xz` or download [XZ Utils](https://tukaani.org/xz/) and add its folder to `PATH`.
|
|
||||||
|
|
||||||
The `subminer` command-line launcher and its picker tools (`fzf`, `rofi`, `chafa`, `ffmpegthumbnailer`) are Linux/macOS only; on Windows you use the **SubMiner mpv** shortcut instead.
|
|
||||||
|
|
||||||
## 2. Install SubMiner
|
## 2. Install SubMiner
|
||||||
|
|
||||||
@@ -370,7 +278,7 @@ Run the built-in diagnostic to confirm everything is working:
|
|||||||
subminer doctor
|
subminer doctor
|
||||||
```
|
```
|
||||||
|
|
||||||
This checks for the app binary, mpv, ffmpeg, yt-dlp, fzf, rofi, your config file, and the mpv socket path. Only the app binary and mpv are hard failures; the rest are reported as optional. Fix any hard failures before continuing.
|
This checks for the app binary, mpv, ffmpeg, config file, and socket path. Fix any failures before continuing.
|
||||||
|
|
||||||
## Anki Setup (Recommended)
|
## Anki Setup (Recommended)
|
||||||
|
|
||||||
|
|||||||
@@ -112,5 +112,5 @@ Verify mpv is running and connected via IPC. SubMiner loads the subtitle by issu
|
|||||||
## Related
|
## Related
|
||||||
|
|
||||||
- [Configuration Reference](/configuration#jimaku) - full config options
|
- [Configuration Reference](/configuration#jimaku) - full config options
|
||||||
- [Mining Workflow](/mining-workflow#related-features) - how Jimaku fits into the sentence mining loop
|
- [Mining Workflow](/mining-workflow#jimaku-subtitle-search) - how Jimaku fits into the sentence mining loop
|
||||||
- [Troubleshooting](/troubleshooting#jimaku) - additional error guidance
|
- [Troubleshooting](/troubleshooting#jimaku) - additional error guidance
|
||||||
|
|||||||
@@ -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 |
|
||||||
@@ -178,7 +177,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
## Options
|
## Options
|
||||||
|
|
||||||
| Flag | Description |
|
| Flag | Description |
|
||||||
| --------------------- | ---------------------------------------------------------------------------- |
|
| --------------------- | --------------------------------------------------------------------------- |
|
||||||
| `-d, --directory` | Video search directory (default: cwd) |
|
| `-d, --directory` | Video search directory (default: cwd) |
|
||||||
| `-r, --recursive` | Search directories recursively |
|
| `-r, --recursive` | Search directories recursively |
|
||||||
| `-R, --rofi` | Use rofi instead of fzf |
|
| `-R, --rofi` | Use rofi instead of fzf |
|
||||||
@@ -190,7 +189,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
|||||||
| `-T, --no-texthooker` | Disable texthooker server |
|
| `-T, --no-texthooker` | Disable texthooker server |
|
||||||
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
| `-p, --profile` | mpv profile name (no default; omitted unless set) |
|
||||||
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
| `-a, --args` | Pass additional mpv arguments as a quoted string |
|
||||||
| `-b, --backend` | Force window backend (`auto`, `hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
|
||||||
| `--settings` | Open the SubMiner settings window |
|
| `--settings` | Open the SubMiner settings window |
|
||||||
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
|
||||||
|
|
||||||
|
|||||||
@@ -1,132 +0,0 @@
|
|||||||
import { expect, test } from 'bun:test';
|
|
||||||
import { readdirSync, readFileSync } from 'node:fs';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
const docsSiteDir = fileURLToPath(new URL('.', import.meta.url));
|
|
||||||
|
|
||||||
// Mirrors VitePress' heading slugifier (vitepress/dist/node, `rControl` + `rSpecial`).
|
|
||||||
// Note that a *run* of special characters collapses to a single `-`, so
|
|
||||||
// "KDE Plasma & other" becomes "kde-plasma-other", not "kde-plasma--other".
|
|
||||||
const rControl = new RegExp('[\\u0000-\\u001f]', 'g');
|
|
||||||
const rSpecial = /[\s~`!@#$%^&*()\-_+=[\]{}|\\;:"'“”‘’<>,.?/]+/g;
|
|
||||||
|
|
||||||
function slugify(heading: string): string {
|
|
||||||
return heading
|
|
||||||
.replace(rControl, '')
|
|
||||||
.replace(rSpecial, '-')
|
|
||||||
.replace(/-{2,}/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.replace(/^(\d)/, '_$1')
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const EXCLUDED_PAGES = new Set(['README.md']);
|
|
||||||
const PUBLIC_PREFIXES = ['/assets/', '/screenshots/', '/config.example.jsonc', '/favicon'];
|
|
||||||
|
|
||||||
function loadPages(): Map<string, string> {
|
|
||||||
const pages = new Map<string, string>();
|
|
||||||
for (const file of readdirSync(docsSiteDir)) {
|
|
||||||
if (!file.endsWith('.md') || EXCLUDED_PAGES.has(file)) continue;
|
|
||||||
const route = `/${file.replace(/\.md$/, '')}`;
|
|
||||||
pages.set(route, readFileSync(`${docsSiteDir}${file}`, 'utf8'));
|
|
||||||
}
|
|
||||||
return pages;
|
|
||||||
}
|
|
||||||
|
|
||||||
function anchorsFor(contents: string): Set<string> {
|
|
||||||
const anchors = new Set<string>();
|
|
||||||
for (const match of contents.matchAll(/^#{1,6}\s+(.+?)\s*$/gm)) {
|
|
||||||
let heading = match[1]!;
|
|
||||||
const explicitId = heading.match(/\{#([^}]+)\}\s*$/);
|
|
||||||
if (explicitId) {
|
|
||||||
anchors.add(explicitId[1]!);
|
|
||||||
heading = heading.replace(/\{#[^}]+\}\s*$/, '');
|
|
||||||
}
|
|
||||||
anchors.add(slugify(heading.replace(/`/g, '')));
|
|
||||||
}
|
|
||||||
return anchors;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRoute(target: string, fromRoute: string): string {
|
|
||||||
if (target === '') return fromRoute;
|
|
||||||
if (target.startsWith('./')) return `/${target.slice(2).replace(/\.md$/, '')}`;
|
|
||||||
const normalized = target.replace(/\.md$/, '').replace(/\/$/, '');
|
|
||||||
return normalized === '' ? '/index' : normalized;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = loadPages();
|
|
||||||
const anchors = new Map([...pages].map(([route, body]) => [route, anchorsFor(body)]));
|
|
||||||
|
|
||||||
test('every internal docs link resolves to an existing page', () => {
|
|
||||||
const broken: string[] = [];
|
|
||||||
|
|
||||||
for (const [route, body] of pages) {
|
|
||||||
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*)\)/g)) {
|
|
||||||
const link = match[1]!;
|
|
||||||
const target = link.split('#')[0]!;
|
|
||||||
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
|
|
||||||
|
|
||||||
const resolved = resolveRoute(target, route);
|
|
||||||
if (resolved !== '/index' && !pages.has(resolved)) {
|
|
||||||
broken.push(`${route.slice(1)}.md -> ${link}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(broken).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('every internal docs anchor matches a real heading slug', () => {
|
|
||||||
const broken: string[] = [];
|
|
||||||
|
|
||||||
for (const [route, body] of pages) {
|
|
||||||
for (const match of body.matchAll(/\]\((\/[^)\s]*|\.\/[^)\s]*|#[^)\s]*)\)/g)) {
|
|
||||||
const link = match[1]!;
|
|
||||||
const hashIndex = link.indexOf('#');
|
|
||||||
if (hashIndex < 0) continue;
|
|
||||||
|
|
||||||
const target = link.slice(0, hashIndex);
|
|
||||||
const anchor = link.slice(hashIndex + 1);
|
|
||||||
if (PUBLIC_PREFIXES.some((prefix) => target.startsWith(prefix))) continue;
|
|
||||||
|
|
||||||
const resolved = resolveRoute(target, route);
|
|
||||||
const pageAnchors = anchors.get(resolved);
|
|
||||||
if (!pageAnchors || pageAnchors.has(anchor)) continue;
|
|
||||||
|
|
||||||
broken.push(`${route.slice(1)}.md -> ${link}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(broken).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('slugify matches the VitePress cases these docs actually rely on', () => {
|
|
||||||
// Regression guards for the anchors that were previously wrong.
|
|
||||||
expect(slugify('N+1 Word Highlighting')).toBe('n-1-word-highlighting');
|
|
||||||
expect(slugify('KDE Plasma & other Wayland compositors')).toBe(
|
|
||||||
'kde-plasma-other-wayland-compositors',
|
|
||||||
);
|
|
||||||
expect(slugify('Proxy Mode Setup (Yomitan / Texthooker)')).toBe(
|
|
||||||
'proxy-mode-setup-yomitan-texthooker',
|
|
||||||
);
|
|
||||||
expect(slugify('Kiku/Lapis Integration')).toBe('kiku-lapis-integration');
|
|
||||||
expect(slugify('Secondary Subtitles')).toBe('secondary-subtitles');
|
|
||||||
expect(slugify('2. Install SubMiner')).toBe('_2-install-subminer');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('every docs page is reachable from the sidebar', async () => {
|
|
||||||
const { default: config } = await import('./.vitepress/config');
|
|
||||||
const sidebar = config.themeConfig?.sidebar as Array<{
|
|
||||||
items?: Array<{ text: string; link?: string }>;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
const linked = new Set<string>();
|
|
||||||
for (const group of sidebar) {
|
|
||||||
for (const item of group.items ?? []) {
|
|
||||||
if (item.link) linked.add(item.link === '/' ? '/index' : item.link);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const orphans = [...pages.keys()].filter((route) => !linked.has(route));
|
|
||||||
expect(orphans).toEqual([]);
|
|
||||||
});
|
|
||||||
@@ -161,13 +161,10 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
|
|||||||
|
|
||||||
1. Open the subsync modal from the overlay.
|
1. Open the subsync modal from the overlay.
|
||||||
2. Select the sync engine (alass or ffsubsync).
|
2. Select the sync engine (alass or ffsubsync).
|
||||||
3. For alass, pick the **reference** - the subtitle with correct timing. This defaults to the secondary subtitle track. The loaded video file can also be used as the reference (alass extracts the audio itself), but it is never the default.
|
3. For alass, select a reference subtitle track from the video.
|
||||||
4. Pick the **out-of-sync subtitle** - the track that gets retimed. This defaults to the active primary subtitle track and applies to both engines.
|
4. SubMiner runs the sync and reloads the corrected subtitle.
|
||||||
5. SubMiner runs the sync and reloads the corrected subtitle into the slot the out-of-sync track came from: retiming the secondary track keeps it secondary and leaves the primary track selected.
|
|
||||||
|
|
||||||
The reference and the out-of-sync subtitle must be different tracks; the reference list hides whichever track is selected as the target.
|
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
|
||||||
|
|
||||||
For remote streams, including Jellyfin playback, the modal only offers alass with a subtitle reference. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync and the video-file reference need direct access to the local media file and are unavailable for stream URLs.
|
|
||||||
|
|
||||||
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
||||||
|
|
||||||
@@ -186,7 +183,7 @@ If you want to build your own browser client, websocket consumer, or automation
|
|||||||
These features support the mining loop but have their own dedicated pages:
|
These features support the mining loop but have their own dedicated pages:
|
||||||
|
|
||||||
- **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
|
- **[Jimaku subtitle search](/jimaku-integration)** - search and download anime subtitle files directly from the overlay (`Ctrl+Shift+J` by default), then load them into mpv.
|
||||||
- **[N+1 word highlighting](/subtitle-annotations#n-1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
- **[N+1 word highlighting](/subtitle-annotations#n1-word-highlighting)** - cross-reference your Anki decks to highlight known words, making true N+1 sentences (exactly one unknown word) easy to spot during immersion.
|
||||||
- **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
|
- **[Immersion tracking](/immersion-tracking)** - log watching and mining activity to a local database and view session times, words seen, and cards mined in the built-in stats dashboard.
|
||||||
|
|
||||||
Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
|
Next: [Anki Integration](/anki-integration) - field mapping, media generation, and card enrichment configuration.
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
||||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
||||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts links.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@catppuccin/vitepress": "^0.1.2",
|
"@catppuccin/vitepress": "^0.1.2",
|
||||||
|
|||||||
@@ -523,7 +523,7 @@
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
// AnkiConnect Integration
|
// AnkiConnect Integration
|
||||||
// Automatic Anki updates and media generation options.
|
// Automatic Anki updates and media generation options.
|
||||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||||
// Most other AnkiConnect settings still require restart.
|
// Most other AnkiConnect settings still require restart.
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -605,10 +605,7 @@
|
|||||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||||
}, // Is kiku setting.
|
} // Is kiku setting.
|
||||||
"lapisKiku": {
|
|
||||||
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
|
||||||
} // Lapis kiku setting.
|
|
||||||
}, // Automatic Anki updates and media generation options.
|
}, // Automatic Anki updates and media generation options.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -694,7 +691,7 @@
|
|||||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||||
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
"profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile.
|
||||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
"socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||||
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||||
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||||
|
|||||||
@@ -251,9 +251,7 @@ test('dev docs version links use local targets for version route testing', async
|
|||||||
delete process.env.SUBMINER_DOCS_CHANNEL;
|
delete process.env.SUBMINER_DOCS_CHANNEL;
|
||||||
delete process.env.SUBMINER_DOCS_BASE;
|
delete process.env.SUBMINER_DOCS_BASE;
|
||||||
delete process.env.SUBMINER_DOCS_VERSION;
|
delete process.env.SUBMINER_DOCS_VERSION;
|
||||||
// Set explicitly (like the sibling version-nav tests) so this assertion stays
|
delete process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||||
// pinned to the manifest under test instead of the config's fallback constant.
|
|
||||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
|
||||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||||
latestStable: 'v0.14.0',
|
latestStable: 'v0.14.0',
|
||||||
|
|||||||
+92
-112
@@ -16,6 +16,90 @@ SubMiner retries the connection automatically with increasing delays (200 ms, 50
|
|||||||
|
|
||||||
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
|
If the overlay never appears at all, see [Playback Startup Flow](./architecture#playback-startup-flow) for how a managed launch starts mpv and brings up the overlay.
|
||||||
|
|
||||||
|
## Logging and App Mode
|
||||||
|
|
||||||
|
- Default log output is `warn`.
|
||||||
|
- Use `--log-level` for more/less output.
|
||||||
|
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
|
||||||
|
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
|
||||||
|
|
||||||
|
## Performance and Resource Impact
|
||||||
|
|
||||||
|
### At a glance
|
||||||
|
|
||||||
|
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
|
||||||
|
- Common spikes come from:
|
||||||
|
- first subtitle parse/tokenization bursts
|
||||||
|
- media generation (`ffmpeg` audio/image and AVIF paths)
|
||||||
|
- media sync and subtitle tooling (`alass`, `ffsubsync`)
|
||||||
|
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
||||||
|
|
||||||
|
### If playback feels sluggish
|
||||||
|
|
||||||
|
1. Reduce overlay workload:
|
||||||
|
|
||||||
|
- set secondary subtitles hidden:
|
||||||
|
- `secondarySub.defaultMode: "hidden"`
|
||||||
|
- disable optional enrichment:
|
||||||
|
- `subtitleStyle.enableJlpt: false`
|
||||||
|
- `subtitleStyle.frequencyDictionary.enabled: false`
|
||||||
|
|
||||||
|
2. Reduce rendering pressure:
|
||||||
|
|
||||||
|
- lower `subtitleStyle.css["font-size"]`
|
||||||
|
- keep overlay complexity minimal during heavy CPU periods
|
||||||
|
|
||||||
|
3. Reduce media overhead:
|
||||||
|
|
||||||
|
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
|
||||||
|
- lower `ankiConnect.media.imageQuality`
|
||||||
|
- reduce `ankiConnect.media.maxMediaDuration`
|
||||||
|
|
||||||
|
4. Lower integration cost:
|
||||||
|
|
||||||
|
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
|
||||||
|
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
|
||||||
|
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
|
||||||
|
|
||||||
|
### Practical low-impact profile
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"css": {
|
||||||
|
"font-size": "30px"
|
||||||
|
},
|
||||||
|
"enableJlpt": false,
|
||||||
|
"frequencyDictionary": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"secondarySub": {
|
||||||
|
"defaultMode": "hidden"
|
||||||
|
},
|
||||||
|
"ankiConnect": {
|
||||||
|
"media": {
|
||||||
|
"imageType": "static",
|
||||||
|
"imageQuality": 80,
|
||||||
|
"maxMediaDuration": 12
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"immersionTracking": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### If usage is still high
|
||||||
|
|
||||||
|
- Confirm only one SubMiner instance is running.
|
||||||
|
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
|
||||||
|
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
|
||||||
|
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
|
||||||
|
|
||||||
**"Failed to parse MPV message"**
|
**"Failed to parse MPV message"**
|
||||||
|
|
||||||
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
|
Logged when a malformed JSON line arrives from the mpv socket. Usually harmless - SubMiner skips the bad line and continues. If it happens constantly, check that nothing else is writing to the same socket path.
|
||||||
@@ -64,7 +148,7 @@ SubMiner retries with exponential backoff (up to 5 s) and suppresses repeated er
|
|||||||
|
|
||||||
**Cards are created but fields are empty**
|
**Cards are created but fields are empty**
|
||||||
|
|
||||||
Field names in your config must name a field that exists on your Anki note type. Matching is case-insensitive (`sentenceaudio` finds `SentenceAudio`), but the spelling must otherwise match, and unknown fields are skipped silently. Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
|
Field names in your config must match your Anki note type exactly (case-sensitive). Check `ankiConnect.fields` - for example, if your note type uses `SentenceAudio` but your config says `Audio`, the field will not be populated.
|
||||||
|
|
||||||
See [Anki Integration](/anki-integration) for the full field mapping reference.
|
See [Anki Integration](/anki-integration) for the full field mapping reference.
|
||||||
|
|
||||||
@@ -171,9 +255,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.
|
||||||
@@ -229,120 +311,22 @@ Install ffsubsync or configure the path:
|
|||||||
|
|
||||||
If subtitle sync fails (the error message is prefixed with the engine name):
|
If subtitle sync fails (the error message is prefixed with the engine name):
|
||||||
|
|
||||||
- Ensure a reference is selected (alass needs either a second subtitle track or the local video file, and it cannot be the same track that is being retimed).
|
- Ensure the reference subtitle track exists in the video (alass requires a source track).
|
||||||
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
||||||
- 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).
|
||||||
|
|
||||||
## TsukiHime
|
|
||||||
|
|
||||||
**"xz binary not found"**
|
|
||||||
|
|
||||||
TsukiHime serves extracted subtitles xz-compressed, so SubMiner shells out to `xz` to decompress them. Install it:
|
|
||||||
|
|
||||||
- **Arch Linux**: `sudo pacman -S xz`
|
|
||||||
- **Ubuntu/Debian**: `sudo apt install xz-utils`
|
|
||||||
- **Fedora**: `sudo dnf install xz`
|
|
||||||
- **macOS**: `brew install xz`
|
|
||||||
- **Windows**: neither winget nor Chocolatey packages `xz`. Use `scoop install main/xz`, or download XZ Utils from [tukaani.org/xz](https://tukaani.org/xz/) and add the folder containing `xz.exe` to your `PATH`. Restart SubMiner afterwards.
|
|
||||||
|
|
||||||
Most Linux distributions ship it already. See [TsukiHime Integration](/tsukihime-integration#troubleshooting) for the other TsukiHime error messages.
|
|
||||||
|
|
||||||
## Jimaku
|
## Jimaku
|
||||||
|
|
||||||
**"Jimaku request failed" or HTTP 429**
|
**"Jimaku request failed" or HTTP 429**
|
||||||
|
|
||||||
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
|
The Jimaku API has rate limits. If you see 429 errors, wait for the retry duration shown in the OSD message and try again. If you have a Jimaku API key, set it in `jimaku.apiKey` or `jimaku.apiKeyCommand` to get higher rate limits.
|
||||||
|
|
||||||
## Logging and App Mode
|
|
||||||
|
|
||||||
- Default log output is `warn`.
|
|
||||||
- Use `--log-level` for more/less output.
|
|
||||||
- Use `--dev`/`--debug` only to force app/dev mode (for example to get dev behavior from the overlay/app); they do not change log verbosity.
|
|
||||||
- You can combine both, for example `SubMiner.AppImage --start --dev --log-level debug`, when you need maximum diagnostics.
|
|
||||||
|
|
||||||
## Performance and Resource Impact
|
|
||||||
|
|
||||||
### At a glance
|
|
||||||
|
|
||||||
- Baseline: `SubMiner --start` is usually lightweight for normal playback.
|
|
||||||
- Common spikes come from:
|
|
||||||
- first subtitle parse/tokenization bursts
|
|
||||||
- media generation (`ffmpeg` audio/image and AVIF paths)
|
|
||||||
- media sync and subtitle tooling (`alass`, `ffsubsync`)
|
|
||||||
- `ankiConnect` enrichment (plus polling overhead when proxy mode is disabled)
|
|
||||||
|
|
||||||
### If playback feels sluggish
|
|
||||||
|
|
||||||
1. Reduce overlay workload:
|
|
||||||
|
|
||||||
- set secondary subtitles hidden:
|
|
||||||
- `secondarySub.defaultMode: "hidden"`
|
|
||||||
- disable optional enrichment:
|
|
||||||
- `subtitleStyle.enableJlpt: false`
|
|
||||||
- `subtitleStyle.frequencyDictionary.enabled: false`
|
|
||||||
|
|
||||||
2. Reduce rendering pressure:
|
|
||||||
|
|
||||||
- lower `subtitleStyle.css["font-size"]`
|
|
||||||
- keep overlay complexity minimal during heavy CPU periods
|
|
||||||
|
|
||||||
3. Reduce media overhead:
|
|
||||||
|
|
||||||
- keep `ankiConnect.media.imageType` set to `static` (avoid animated AVIF unless needed)
|
|
||||||
- lower `ankiConnect.media.imageQuality`
|
|
||||||
- reduce `ankiConnect.media.maxMediaDuration`
|
|
||||||
|
|
||||||
4. Lower integration cost:
|
|
||||||
|
|
||||||
- disable AI translation when not needed (`ankiConnect.ai.enabled: false`)
|
|
||||||
- if needed, run immersion telemetry with lower duration expectations (`immersionTracking.enabled: false` for constrained sessions)
|
|
||||||
- favor the default lightweight YouTube subtitle startup settings on low-resource systems
|
|
||||||
|
|
||||||
### Practical low-impact profile
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"subtitleStyle": {
|
|
||||||
"css": {
|
|
||||||
"font-size": "30px"
|
|
||||||
},
|
|
||||||
"enableJlpt": false,
|
|
||||||
"frequencyDictionary": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"secondarySub": {
|
|
||||||
"defaultMode": "hidden"
|
|
||||||
},
|
|
||||||
"ankiConnect": {
|
|
||||||
"media": {
|
|
||||||
"imageType": "static",
|
|
||||||
"imageQuality": 80,
|
|
||||||
"maxMediaDuration": 12
|
|
||||||
},
|
|
||||||
"ai": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"immersionTracking": {
|
|
||||||
"enabled": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### If usage is still high
|
|
||||||
|
|
||||||
- Confirm only one SubMiner instance is running.
|
|
||||||
- Check whether bottlenecks are `ffmpeg`, `yt-dlp`, or sync tooling in system monitor.
|
|
||||||
- Keep the default `warn` level for normal use; raise to `info` or `debug` only for targeted diagnosis.
|
|
||||||
- Reproduce once with `SubMiner.AppImage --start --log-level debug` and open DevTools (`y` then `d`) if freezes recur.
|
|
||||||
|
|
||||||
## Platform-Specific
|
## Platform-Specific
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma-other-wayland-compositors)).
|
- **Wayland (Hyprland/Sway only)**: Native Wayland support is limited to Hyprland and Sway. Window tracking uses compositor-specific commands (`hyprctl` / `swaymsg`). If these are not on `PATH`, tracking will fail silently. Other Wayland compositors (KDE Plasma, GNOME, …) are not supported natively - both mpv and SubMiner must run under X11 or Xwayland instead. On those sessions SubMiner forces XWayland automatically for itself and for every mpv it launches (see [KDE Plasma & other Wayland compositors](#kde-plasma--other-wayland-compositors)).
|
||||||
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
|
- **X11 / Xwayland**: Requires `xdotool`, `xprop`, and `xwininfo`. If missing, the overlay cannot track the mpv window position. This is the required backend for any Wayland compositor other than Hyprland or Sway - both mpv and SubMiner must be running under X11/Xwayland for window tracking _and_ for the overlay to stay above mpv (Wayland forbids clients from controlling window stacking). SubMiner uses a managed X11 overlay while mpv is windowed, switches to an override-redirect X11 overlay while tracked mpv is fullscreen, and hides/releases that overlay when another X11/Xwayland app takes focus. The visible overlay stays hidden until SubMiner has tracked mpv geometry, so startup should not create a display-sized fallback overlay while tokenization warms up.
|
||||||
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
|
- **Tray icon missing**: SubMiner creates an Electron tray icon in `--background` mode, but Linux trays require a StatusNotifier/AppIndicator host. Hyprland does not provide one by itself; enable a tray in Waybar, Hyprpanel, or another panel. If Electron cannot register the tray, SubMiner logs a warning that mentions the missing tray host.
|
||||||
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
|
- **Mouse passthrough**: On Linux X11/Xwayland, SubMiner uses `xdotool` to poll the cursor and only enables overlay input while the cursor is over subtitle or popup regions. Outside those regions, pointer input passes through to mpv. Native Wayland compositors other than Hyprland/Sway cannot provide the stacking control SubMiner needs.
|
||||||
@@ -407,9 +391,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 +406,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).
|
||||||
|
|
||||||
@@ -445,11 +428,8 @@ Feature-specific issues are covered in each feature's own page:
|
|||||||
- [Character Dictionary](/character-dictionary) - AniList character name matching and inline portraits
|
- [Character Dictionary](/character-dictionary) - AniList character name matching and inline portraits
|
||||||
- [Jellyfin Integration](/jellyfin-integration) - remote playback and library connection
|
- [Jellyfin Integration](/jellyfin-integration) - remote playback and library connection
|
||||||
- [Jimaku Integration](/jimaku-integration) - subtitle fetching and API rate limits
|
- [Jimaku Integration](/jimaku-integration) - subtitle fetching and API rate limits
|
||||||
- [TsukiHime Integration](/tsukihime-integration) - multi-language subtitle download and `xz` decompression
|
|
||||||
- [YouTube Integration](/youtube-integration) - subtitle generation and playback
|
- [YouTube Integration](/youtube-integration) - subtitle generation and playback
|
||||||
- [Immersion Tracking](/immersion-tracking) - telemetry, session logging, and the stats dashboard
|
- [Immersion Tracking](/immersion-tracking) - telemetry and session logging
|
||||||
- [Launcher Script](/launcher-script) - `subminer` commands, pickers, watch history, and cross-machine sync
|
|
||||||
- [MPV Plugin](/mpv-plugin) - in-player chords, script-opts, and binary auto-detection
|
|
||||||
- [WebSocket / Texthooker API](/websocket-texthooker-api) - external texthooker clients
|
- [WebSocket / Texthooker API](/websocket-texthooker-api) - external texthooker clients
|
||||||
- [Subtitle Annotations](/subtitle-annotations) - N+1, frequency, JLPT, and name-match layers
|
- [Subtitle Annotations](/subtitle-annotations) - N+1, frequency, JLPT, and name-match layers
|
||||||
- [Subtitle Sidebar](/subtitle-sidebar) - sidebar navigation and behavior
|
- [Subtitle Sidebar](/subtitle-sidebar) - sidebar navigation and behavior
|
||||||
|
|||||||
+114
-101
@@ -33,7 +33,7 @@ If you want sentence, audio, and screenshot fields on your Anki cards, add this
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Field names must match a field on your Anki note type. Matching is case-insensitive (an exact match wins, then a lowercase comparison), but the spelling must otherwise match. See [Anki Integration](/anki-integration) for the full reference.
|
Field names must match your Anki note type exactly (case-sensitive). See [Anki Integration](/anki-integration) for the full reference.
|
||||||
:::
|
:::
|
||||||
|
|
||||||
## How It Works
|
## How It Works
|
||||||
@@ -56,106 +56,136 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
|
|||||||
|
|
||||||
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
The mpv plugin is always available - it's bundled with SubMiner and injected at runtime. On Linux, normal `subminer` playback auto-installs the launcher-managed runtime plugin copy from the bundled app if that managed copy is missing, so no separate plugin install is needed for standard launcher usage. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect.
|
||||||
|
|
||||||
|
## Live Config Reload
|
||||||
|
|
||||||
|
While SubMiner is running, it watches your active config file and applies safe updates automatically.
|
||||||
|
|
||||||
|
Live-updated settings include:
|
||||||
|
|
||||||
|
- `subtitleStyle`
|
||||||
|
- `keybindings`
|
||||||
|
- `shortcuts`
|
||||||
|
- `secondarySub.defaultMode`
|
||||||
|
- `subtitleSidebar`
|
||||||
|
- `notifications`
|
||||||
|
- `logging`
|
||||||
|
- `jimaku`, `subsync`
|
||||||
|
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
|
||||||
|
- `stats.toggleKey`, `stats.markWatchedKey`
|
||||||
|
- `youtube.primarySubLanguages`
|
||||||
|
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
|
||||||
|
|
||||||
|
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
|
||||||
|
For restart-required sections, SubMiner shows a restart-needed notification.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
These are the commands you will actually use day to day. The full inventory of subcommands and flags lives in [Launcher Script](/launcher-script#subcommands).
|
On Windows, replace `SubMiner.AppImage` with `SubMiner.exe` in the direct packaged-app examples below.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
subminer video.mkv # Play a specific file
|
# Browse and play videos
|
||||||
subminer # Browse the current directory (fzf picker)
|
subminer # Current directory (uses fzf)
|
||||||
subminer -R # Browse with the rofi picker instead
|
subminer -R # Use rofi instead of fzf
|
||||||
subminer -d ~/Anime -r # Browse a specific directory, recursively
|
subminer -d ~/Videos # Specific directory
|
||||||
subminer -H # Browse watch history, then replay/next/previous
|
subminer -r -d ~/Anime # Recursive search
|
||||||
|
subminer video.mkv # Play specific file (overlay auto-starts)
|
||||||
|
subminer --start video.mkv # Explicit overlay start (use when mpv.autoStartSubMiner is false in config)
|
||||||
|
subminer -S video.mkv # Also force the visible overlay on start (--start-overlay)
|
||||||
subminer https://youtu.be/... # Play a YouTube URL
|
subminer https://youtu.be/... # Play a YouTube URL
|
||||||
subminer stats # Open the immersion stats dashboard
|
subminer ytsearch:"jp news" # Play first YouTube search result
|
||||||
subminer doctor # Check dependencies, config, and the mpv socket
|
subminer -H # Browse history, then choose previous/replay/next after playback
|
||||||
subminer settings # Open the SubMiner settings window
|
subminer app --setup # Open first-run setup popup
|
||||||
subminer app --setup # Re-open first-run setup
|
subminer --version # Print the launcher's version
|
||||||
subminer -u # Check for updates
|
subminer -v # Same as above
|
||||||
```
|
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
||||||
|
subminer --log-level warn video.mkv # Set logging level explicitly
|
||||||
|
subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args
|
||||||
|
|
||||||
On **Windows** there is no `subminer` launcher. Use the **SubMiner mpv** shortcut for playback (see [Windows mpv Shortcut](#windows-mpv-shortcut)), and run `SubMiner.exe` directly for everything else.
|
# Options
|
||||||
|
subminer -T video.mkv # Disable texthooker server
|
||||||
Two flags are worth knowing early:
|
subminer -b x11 video.mkv # Force X11 backend
|
||||||
|
subminer video.mkv # No mpv profile passed by default
|
||||||
- `-a/--args` passes extra arguments straight to mpv, for example `subminer --args "--ao=alsa --volume=80" video.mkv`.
|
|
||||||
- `--log-level debug` turns on verbose logging when something is not working.
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary><b>Less common launcher commands</b></summary>
|
|
||||||
|
|
||||||
```bash
|
|
||||||
subminer --start video.mkv # Explicit overlay start (when mpv.autoStartSubMiner is false)
|
|
||||||
subminer -S video.mkv # Also force the visible overlay on start
|
|
||||||
subminer -T video.mkv # Disable the texthooker server
|
|
||||||
subminer -b x11 video.mkv # Force a window backend
|
|
||||||
subminer -p gpu-hq video.mkv # Use a specific mpv profile
|
subminer -p gpu-hq video.mkv # Use a specific mpv profile
|
||||||
subminer ytsearch:"jp news" # Play the first YouTube search result
|
subminer jellyfin # Open Jellyfin setup window (subcommand form)
|
||||||
subminer texthooker # Texthooker-only mode (-o also opens the browser)
|
subminer jellyfin -l --server http://127.0.0.1:8096 --username me --password 'secret'
|
||||||
|
subminer jellyfin --logout # Clear stored Jellyfin token/session data
|
||||||
|
subminer jellyfin -p # Interactive Jellyfin library/item picker + playback
|
||||||
|
subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app)
|
||||||
|
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
|
||||||
|
subminer doctor # Dependency + config + socket diagnostics
|
||||||
|
subminer logs -e # Export a sanitized log ZIP and print its path
|
||||||
|
subminer config path # Print active config path
|
||||||
|
subminer config show # Print active config contents
|
||||||
|
subminer mpv socket # Print active mpv socket path
|
||||||
|
subminer mpv status # Exit 0 if socket is ready, else exit 1
|
||||||
|
subminer mpv idle # Launch detached idle mpv with SubMiner defaults
|
||||||
|
subminer sync media-box # Sync stats/watch history with an SSH host
|
||||||
|
subminer sync media-box --push # Merge this machine's stats into the host only
|
||||||
|
subminer sync media-box --pull # Merge the host's stats into this machine only
|
||||||
|
subminer sync media-box --check # Verify SSH and remote SubMiner without syncing
|
||||||
|
subminer sync media-box --json # Emit machine-readable NDJSON progress
|
||||||
|
subminer sync --ui # Open the Sync Stats & History window
|
||||||
|
subminer sync --snapshot ~/subminer-snapshot.sqlite # Write a local DB snapshot
|
||||||
|
subminer sync --merge ~/subminer-snapshot.sqlite # Merge a snapshot into the local DB
|
||||||
|
subminer sync --make-temp # Create an internal sync temp directory
|
||||||
|
subminer sync --remove-temp /tmp/subminer-sync-123 # Remove an internal sync temp directory
|
||||||
|
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import)
|
||||||
|
subminer dictionary --candidates /path/to/file.mkv
|
||||||
|
subminer dictionary --select 21355 /path/to/file.mkv
|
||||||
|
subminer texthooker # Launch texthooker-only mode
|
||||||
|
subminer texthooker -o # Launch texthooker and open it in your browser
|
||||||
|
subminer stats # Start the local stats server (see Immersion Tracking)
|
||||||
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 app --anilist-setup # Pass args directly to SubMiner binary (example: AniList login flow)
|
||||||
subminer stats cleanup -d --dry-run # Preview cleanup of repeated typeset subtitle lines
|
|
||||||
subminer stats cleanup -d --lookback-days 30 # Clean only lines recorded in the last 30 days
|
|
||||||
subminer stats rebuild # Rebuild rollup data
|
|
||||||
subminer doctor --refresh-known-words # Refresh the known-word cache
|
|
||||||
subminer logs -e # Export a sanitized log ZIP and print its path
|
|
||||||
subminer config path # Print the active config path
|
|
||||||
subminer config show # Print the active config contents
|
|
||||||
subminer mpv socket # Print the active mpv socket path
|
|
||||||
subminer mpv status # Exit 0 if the socket is ready, else exit 1
|
|
||||||
subminer mpv idle # Launch a detached idle mpv with SubMiner defaults
|
|
||||||
subminer app --stop # Stop the background app
|
|
||||||
subminer --version # Print the launcher's version
|
|
||||||
```
|
|
||||||
|
|
||||||
`stats cleanup` runs one mode per invocation: `-v`/`--vocab` (the default), `-l`/`--lifetime`, or `-d`/`--duplicate-lines`; explicitly selected modes cannot be combined. `--dry-run` and `--lookback-days <days>` apply to `--duplicate-lines` only and are rejected without it; `--lookback-days` must be at least one day, and leaving it off scans all history.
|
# Direct packaged app control
|
||||||
|
|
||||||
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>
|
|
||||||
<summary><b>Direct packaged-app flags (advanced)</b></summary>
|
|
||||||
|
|
||||||
These call the app binary directly rather than going through the launcher. On Windows, replace `SubMiner.AppImage` with `SubMiner.exe`.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
|
SubMiner.AppImage --background # Start in background (tray + IPC wait, minimal logs)
|
||||||
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
SubMiner.AppImage --start --texthooker # Start overlay with texthooker
|
||||||
SubMiner.AppImage --texthooker # Texthooker only (no overlay window)
|
SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
|
||||||
SubMiner.AppImage --setup # Open first-run setup
|
SubMiner.AppImage --texthooker --open-browser # Launch texthooker and open browser
|
||||||
|
SubMiner.AppImage --setup # Open first-run setup popup
|
||||||
SubMiner.AppImage --stop # Stop overlay
|
SubMiner.AppImage --stop # Stop overlay
|
||||||
SubMiner.AppImage --start --toggle # Start mpv IPC + toggle visibility
|
SubMiner.AppImage --start --toggle # Start MPV IPC + toggle visibility
|
||||||
SubMiner.AppImage --show-visible-overlay # Force show the visible overlay
|
SubMiner.AppImage --show-visible-overlay # Force show visible overlay
|
||||||
SubMiner.AppImage --hide-visible-overlay # Force hide the visible overlay
|
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
|
||||||
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle the primary subtitle bar
|
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
|
||||||
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
|
SubMiner.AppImage --toggle-subtitle-sidebar # Toggle the subtitle sidebar
|
||||||
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
SubMiner.AppImage --open-tsukihime # Open TsukiHime subtitle search
|
||||||
|
SubMiner.AppImage --start --dev # Enable app/dev mode only
|
||||||
|
SubMiner.AppImage --start --debug # Alias for --dev
|
||||||
|
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
|
||||||
|
SubMiner.AppImage --playback-feedback "your feedback" # Route playback feedback through the configured feedback surface
|
||||||
SubMiner.AppImage --yomitan # Open Yomitan settings
|
SubMiner.AppImage --yomitan # Open Yomitan settings
|
||||||
SubMiner.AppImage --settings # Open the SubMiner settings window
|
SubMiner.AppImage --settings # Open SubMiner settings window
|
||||||
SubMiner.AppImage --jellyfin # Open the Jellyfin setup window
|
SubMiner.AppImage --jellyfin # Open Jellyfin setup window
|
||||||
SubMiner.AppImage --dictionary # Generate a character dictionary ZIP
|
SubMiner.AppImage --jellyfin-login --jellyfin-server http://127.0.0.1:8096 --jellyfin-username me --jellyfin-password 'secret'
|
||||||
SubMiner.AppImage --start --dev # Enable app/dev mode
|
SubMiner.AppImage --jellyfin-logout # Clear stored Jellyfin token/session data
|
||||||
SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
|
SubMiner.AppImage --jellyfin-libraries
|
||||||
|
SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-search anime --jellyfin-limit 20
|
||||||
|
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start)
|
||||||
|
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
|
||||||
|
SubMiner.AppImage --sync-cli --help # Show the packaged app's headless sync help
|
||||||
|
SubMiner.AppImage --sync-cli sync media-box # Run the sync engine directly in headless mode
|
||||||
|
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime
|
||||||
|
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
|
||||||
|
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series
|
||||||
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`.
|
`--check` performs connection and version checks without changing data. `--json` emits the NDJSON event protocol used by the sync window. `--ui` opens that window in a detached app process and returns the shell immediately; closing a standalone-launched Sync window exits that app instance. `--make-temp` and `--remove-temp` are internal remote-transfer helpers and should normally be left to SubMiner. The packaged app's `--sync-cli` flag selects its headless sync-compatible entrypoint; the `subminer sync` launcher command proxies to it automatically.
|
||||||
|
|
||||||
</details>
|
The previous `--open-animetosho` flag remains accepted as a deprecated alias for `--open-tsukihime`.
|
||||||
|
|
||||||
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
|
The tray menu includes `Export Logs`, which creates the same sanitized local-date log ZIP as `subminer logs -e` and shows the archive path when complete. Export sanitization masks common PII and secrets, including home-directory usernames, IP addresses, emails, auth/cookie headers, yt-dlp cookie arguments, URL credentials, token/key/password fields, and signed YouTube media URL query strings. The exported copy is sanitized; source log files remain unredacted on disk.
|
||||||
|
|
||||||
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
||||||
|
|
||||||
The tray menu also includes `View Changelog`, which opens the in-app changelog modal. It fetches the changelog from the newest published release, so you see release notes for versions newer than the one you run; if the download fails it falls back to the changelog bundled with your install and says so. Versions in the current `0.x` line are expanded by default and older lines are folded, matching this site's [Changelog](/changelog). A badge marks the version you have installed, and newer versions are tagged `New`. The same modal opens from the `What's New` button on the update-available overlay notification.
|
|
||||||
|
|
||||||
### Logging and App Mode
|
### Logging and App Mode
|
||||||
|
|
||||||
- `--log-level` controls logger verbosity.
|
- `--log-level` controls logger verbosity.
|
||||||
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
||||||
- `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
|
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
||||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
||||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||||
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
||||||
@@ -187,11 +217,18 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
|
|||||||
|
|
||||||
### Launcher Subcommands
|
### Launcher Subcommands
|
||||||
|
|
||||||
The launcher groups related work under subcommands: `jellyfin` (aliased `jf`), `stats`, `sync`, `dictionary` (aliased `dict`), `texthooker`, `doctor`, `settings`, `config`, `mpv`, `logs`, and `app` (aliased `bin`) for passing arguments straight to the SubMiner binary.
|
- `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||||
|
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
||||||
Every subcommand has its own help page, for example `subminer jellyfin -h`. See [Launcher Script - Subcommands](/launcher-script#subcommands) for the full table, and [Sync Between Machines](/launcher-script#sync-between-machines) for the SSH stats/history sync.
|
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
|
||||||
|
- `subminer logs -e`: export a sanitized ZIP of today's local-date logs, or the most recent logs when no current-day log exists. The exported copy masks common PII and secrets; on-disk logs are unchanged.
|
||||||
A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
- `subminer config`: config file helpers (`path`, `show`).
|
||||||
|
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||||
|
- `subminer sync <host>`: sync immersion stats and watch history with another machine over SSH. The host is the SSH destination (`user@host` or an SSH config alias). Use `--push` to merge only this machine's data into the host, or `--pull` to merge only the host's data into this machine; both remain insert-only and do not make either database an exact mirror. Remote launcher checks include standard SubMiner and Bun paths even when SSH omits them from `PATH`. Use `--snapshot <file>` to write a consistent local stats DB snapshot, `--merge <file>` to merge a snapshot into the local stats DB, and `--force` to skip the running stats/mpv safety check. Advanced options: `--db <file>` overrides the local stats DB path, and `--remote-cmd <cmd>` overrides the `subminer` command used on the remote host.
|
||||||
|
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||||
|
- Use `subminer dictionary --candidates <path>` and `subminer dictionary --select <id> <path>` to correct AniList character-dictionary matches for a whole series.
|
||||||
|
- `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`). A _texthooker_ is a web page that displays the current subtitle line as selectable text, so browser-based dictionary extensions and other tools can read along with playback.
|
||||||
|
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
|
||||||
|
- Subcommand help pages are available (for example `subminer jellyfin -h`).
|
||||||
|
|
||||||
### First-Run Setup
|
### First-Run Setup
|
||||||
|
|
||||||
@@ -288,28 +325,6 @@ Notes:
|
|||||||
|
|
||||||
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
For local video files, SubMiner uses the same config-driven language priorities to auto-select the primary and secondary subtitle tracks from internal and external subtitle sources.
|
||||||
|
|
||||||
## Live Config Reload
|
|
||||||
|
|
||||||
While SubMiner is running, it watches your active config file and applies safe updates automatically.
|
|
||||||
|
|
||||||
Live-updated settings include:
|
|
||||||
|
|
||||||
- `subtitleStyle`
|
|
||||||
- `keybindings`
|
|
||||||
- `shortcuts`
|
|
||||||
- `secondarySub.defaultMode`
|
|
||||||
- `subtitleSidebar`
|
|
||||||
- `notifications`
|
|
||||||
- `logging`
|
|
||||||
- `jimaku`, `subsync`
|
|
||||||
- `mpv.aniskipEnabled`, `mpv.aniskipButtonKey`
|
|
||||||
- `stats.toggleKey`, `stats.markWatchedKey`
|
|
||||||
- `youtube.primarySubLanguages`
|
|
||||||
- most `ankiConnect.*` settings (including `ankiConnect.ai`)
|
|
||||||
|
|
||||||
Invalid config edits are rejected; SubMiner keeps the previous valid runtime config and shows an error notification.
|
|
||||||
For restart-required sections, SubMiner shows a restart-needed notification.
|
|
||||||
|
|
||||||
## Controller Support
|
## Controller Support
|
||||||
|
|
||||||
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
SubMiner supports gamepad/controller input for couch-friendly usage via the Chrome Gamepad API. Controller input drives the overlay while keyboard-only mode is enabled.
|
||||||
@@ -374,8 +389,6 @@ Press `V` to cycle the primary SubMiner subtitle bar through hidden → visible
|
|||||||
|
|
||||||
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
|
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
|
||||||
|
|
||||||
The changelog modal (tray > `View Changelog`) works the same way: it renders over mpv when a video is playing and in its own window otherwise. Use `J`/`K` or the arrow keys to move between versions, `Enter` to fold or unfold one, `R` to refetch, and `Esc` to close.
|
|
||||||
|
|
||||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
||||||
|
|
||||||
### Drag-and-Drop
|
### Drag-and-Drop
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as
|
|||||||
- **Client auth:** none
|
- **Client auth:** none
|
||||||
- **Reconnects:** client-managed
|
- **Reconnects:** client-managed
|
||||||
|
|
||||||
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes. Annotation-only upgrades do not repeat the same line on this basic stream.
|
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes.
|
||||||
|
|
||||||
#### Message shape
|
#### Message shape
|
||||||
|
|
||||||
@@ -96,8 +96,6 @@ Use the annotation websocket for custom clients that want the same structured to
|
|||||||
|
|
||||||
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
||||||
|
|
||||||
On a tokenization cache miss, this stream first sends the cue as plain text with an empty `tokens` array, then sends the annotated replacement when tokenization finishes. Treat each message as the complete current state, replacing the previous payload.
|
|
||||||
|
|
||||||
#### Message shape
|
#### Message shape
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -379,4 +377,4 @@ ws.on('message', async (raw) => {
|
|||||||
- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
|
- [Mining Workflow - Texthooker](/mining-workflow#texthooker)
|
||||||
- [MPV Plugin](/mpv-plugin)
|
- [MPV Plugin](/mpv-plugin)
|
||||||
- [Launcher Script](/launcher-script)
|
- [Launcher Script](/launcher-script)
|
||||||
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan-texthooker)
|
- [Anki Integration](/anki-integration#proxy-mode-setup-yomitan--texthooker)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t
|
|||||||
|
|
||||||
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
||||||
|
|
||||||
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
|
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata.
|
||||||
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
||||||
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
||||||
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
||||||
@@ -153,6 +153,6 @@ These settings come from `config.jsonc` (or built-in defaults); there are no CLI
|
|||||||
|
|
||||||
- [Usage --- YouTube Playback](/usage#youtube-playback)
|
- [Usage --- YouTube Playback](/usage#youtube-playback)
|
||||||
- [Configuration --- YouTube Playback Settings](/configuration#youtube-playback-settings)
|
- [Configuration --- YouTube Playback Settings](/configuration#youtube-playback-settings)
|
||||||
- [Configuration --- Secondary Subtitles](/configuration#secondary-subtitles)
|
- [Configuration --- Secondary Subtitle](/configuration#secondary-subtitle)
|
||||||
- [Keyboard Shortcuts](/shortcuts)
|
- [Keyboard Shortcuts](/shortcuts)
|
||||||
- [Jellyfin Integration](/jellyfin-integration)
|
- [Jellyfin Integration](/jellyfin-integration)
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -260,7 +257,7 @@ 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% |
|
||||||
@@ -270,19 +267,16 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
|
|||||||
## 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)
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ 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; expensive deletion and summary rebuilds run in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and lifetime rebuild.
|
|
||||||
- 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-*`
|
||||||
- Window trackers: `src/window-trackers/`
|
- Window trackers: `src/window-trackers/`
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# Subtitle Overlay Priming
|
# Subtitle Overlay Priming
|
||||||
|
|
||||||
Status: active
|
Status: active
|
||||||
Last verified: 2026-08-04
|
Last verified: 2026-06-14
|
||||||
Owner: Kyle Yasuda
|
Owner: Kyle Yasuda
|
||||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||||
|
|
||||||
@@ -47,43 +47,18 @@ subtitles do not draw.
|
|||||||
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
|
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
|
||||||
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
||||||
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
||||||
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
|
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
|
||||||
synchronously, then replaces it with the tokenized payload when ready.
|
payload.
|
||||||
|
|
||||||
Both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchService` and then call
|
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
|
||||||
the matching `subtitleProcessingController` method, giving the visible overlay priority over
|
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
|
||||||
background prefetch work. Prefetch is not re-centered here: restarting the run per line
|
`subtitleProcessingController` method. This gives the visible overlay priority over background
|
||||||
(`onSeek`) discarded the in-flight tokenization every time the subtitle changed, so only real
|
prefetch work and re-centers prefetch around the live playback time.
|
||||||
seeks restart it (see `onTimePosUpdate` in `src/main.ts`).
|
|
||||||
|
|
||||||
On an uncached autoplay prime the raw payload is emitted here and reported to the controller with
|
|
||||||
`notePlainSubtitleEmitted`, so the controller skips its own plain emit for that line and the
|
|
||||||
overlay receives one plain payload followed by the annotated one.
|
|
||||||
|
|
||||||
The pause is released by the controller's `onProcessingSettled` callback, which fires once it has
|
|
||||||
no work left. Emits do not release it: the first emit for an uncached line is the plain payload
|
|
||||||
that precedes tokenization, and a run can finish without emitting at all (a suppressed duplicate,
|
|
||||||
a failed tokenization). Both controller methods return whether processing is now pending, and the
|
|
||||||
caller resumes immediately when it is not — a repeated subtitle schedules no work, so no settle is
|
|
||||||
coming and prefetching would otherwise idle for the rest of the cue.
|
|
||||||
|
|
||||||
## Live Cue Delivery
|
|
||||||
|
|
||||||
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
|
||||||
live work does not contend for Yomitan state.
|
|
||||||
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
|
|
||||||
clear payload is emitted immediately. The older tokenization result is dropped before it can
|
|
||||||
replace the current cue.
|
|
||||||
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
|
|
||||||
or character images, but cue visibility does not wait for that work.
|
|
||||||
|
|
||||||
## Emitted State
|
## Emitted State
|
||||||
|
|
||||||
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
|
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
|
||||||
websocket listeners receive both the immediate plain cue and its later annotation upgrade.
|
annotated subtitle payload to overlay windows and subtitle websocket listeners.
|
||||||
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
|
|
||||||
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
|
|
||||||
when text and cue timing match.
|
|
||||||
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
||||||
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
||||||
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
# 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
|
||||||
|
|
||||||
@@ -17,10 +17,10 @@ Read when: finding internal docs or checking verification status
|
|||||||
| 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 1–7).
|
||||||
|
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.`
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
|
||||||
```
|
|
||||||
@@ -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`
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,9 +134,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: [],
|
||||||
@@ -188,9 +185,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: [],
|
||||||
@@ -235,9 +229,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: [],
|
||||||
@@ -280,9 +271,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: [],
|
||||||
|
|||||||
@@ -162,8 +162,6 @@ 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,
|
||||||
@@ -260,11 +258,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 (
|
||||||
|
|||||||
@@ -37,9 +37,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 +53,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(
|
||||||
@@ -182,9 +169,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 +269,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 +289,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;
|
||||||
@@ -524,9 +483,6 @@ export function parseCliPrograms(
|
|||||||
statsCleanup,
|
statsCleanup,
|
||||||
statsCleanupVocab,
|
statsCleanupVocab,
|
||||||
statsCleanupLifetime,
|
statsCleanupLifetime,
|
||||||
statsCleanupDuplicateLines,
|
|
||||||
statsCleanupDryRun,
|
|
||||||
statsCleanupLookbackDays,
|
|
||||||
statsLogLevel,
|
statsLogLevel,
|
||||||
syncTriggered,
|
syncTriggered,
|
||||||
syncCliTokens,
|
syncCliTokens,
|
||||||
|
|||||||
@@ -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',
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
|||||||
line_id INTEGER NOT NULL,
|
line_id INTEGER NOT NULL,
|
||||||
word_id INTEGER NOT NULL,
|
word_id INTEGER NOT NULL,
|
||||||
occurrence_count INTEGER NOT NULL,
|
occurrence_count INTEGER NOT NULL,
|
||||||
seen_ms INTEGER,
|
|
||||||
PRIMARY KEY(line_id, word_id),
|
PRIMARY KEY(line_id, word_id),
|
||||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
|
FOREIGN KEY(word_id) REFERENCES imm_words(id) ON DELETE CASCADE
|
||||||
@@ -194,7 +193,6 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
|||||||
line_id INTEGER NOT NULL,
|
line_id INTEGER NOT NULL,
|
||||||
kanji_id INTEGER NOT NULL,
|
kanji_id INTEGER NOT NULL,
|
||||||
occurrence_count INTEGER NOT NULL,
|
occurrence_count INTEGER NOT NULL,
|
||||||
seen_ms INTEGER,
|
|
||||||
PRIMARY KEY(line_id, kanji_id),
|
PRIMARY KEY(line_id, kanji_id),
|
||||||
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
FOREIGN KEY(line_id) REFERENCES imm_subtitle_lines(line_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
|
FOREIGN KEY(kanji_id) REFERENCES imm_kanji(id) ON DELETE CASCADE
|
||||||
@@ -315,8 +313,8 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
|||||||
CREATE INDEX idx_subtitle_lines_session_line ON imm_subtitle_lines(session_id, line_index);
|
CREATE INDEX idx_subtitle_lines_session_line ON imm_subtitle_lines(session_id, line_index);
|
||||||
CREATE INDEX idx_subtitle_lines_video_line ON imm_subtitle_lines(video_id, line_index);
|
CREATE INDEX idx_subtitle_lines_video_line ON imm_subtitle_lines(video_id, line_index);
|
||||||
CREATE INDEX idx_subtitle_lines_anime_line ON imm_subtitle_lines(anime_id, line_index);
|
CREATE INDEX idx_subtitle_lines_anime_line ON imm_subtitle_lines(anime_id, line_index);
|
||||||
CREATE INDEX idx_word_line_occurrences_word_seen ON imm_word_line_occurrences(word_id, seen_ms, occurrence_count, line_id);
|
CREATE INDEX idx_word_line_occurrences_word ON imm_word_line_occurrences(word_id, line_id);
|
||||||
CREATE INDEX idx_kanji_line_occurrences_kanji_seen ON imm_kanji_line_occurrences(kanji_id, seen_ms, occurrence_count, line_id);
|
CREATE INDEX idx_kanji_line_occurrences_kanji ON imm_kanji_line_occurrences(kanji_id, line_id);
|
||||||
CREATE INDEX idx_media_art_cover_blob_hash ON imm_media_art(cover_blob_hash);
|
CREATE INDEX idx_media_art_cover_blob_hash ON imm_media_art(cover_blob_hash);
|
||||||
CREATE INDEX idx_media_art_anilist_id ON imm_media_art(anilist_id);
|
CREATE INDEX idx_media_art_anilist_id ON imm_media_art(anilist_id);
|
||||||
CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);
|
CREATE INDEX idx_media_art_cover_url ON imm_media_art(cover_url);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
+5
-11
@@ -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.0-beta.3",
|
||||||
"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",
|
||||||
@@ -84,18 +84,16 @@
|
|||||||
"overrides": {
|
"overrides": {
|
||||||
"@xmldom/xmldom": "0.8.13",
|
"@xmldom/xmldom": "0.8.13",
|
||||||
"app-builder-lib": "26.15.3",
|
"app-builder-lib": "26.15.3",
|
||||||
"brace-expansion": "5.0.9",
|
"brace-expansion": "5.0.8",
|
||||||
"electron-builder-squirrel-windows": "26.15.3",
|
"electron-builder-squirrel-windows": "26.15.3",
|
||||||
"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",
|
||||||
"tar": "7.5.21",
|
"tar": "7.5.21",
|
||||||
"tmp": "0.2.7",
|
"tmp": "0.2.7"
|
||||||
"undici": "7.29.0"
|
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"anki",
|
"anki",
|
||||||
@@ -127,7 +125,7 @@
|
|||||||
"@types/ws": "^8.18.1",
|
"@types/ws": "^8.18.1",
|
||||||
"electron": "42.6.0",
|
"electron": "42.6.0",
|
||||||
"electron-builder": "26.15.3",
|
"electron-builder": "26.15.3",
|
||||||
"undici": "7.29.0",
|
"undici": "7.28.0",
|
||||||
"esbuild": "^0.25.12",
|
"esbuild": "^0.25.12",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
@@ -260,10 +258,6 @@
|
|||||||
{
|
{
|
||||||
"from": "dist/launcher/subminer",
|
"from": "dist/launcher/subminer",
|
||||||
"to": "launcher/subminer"
|
"to": "launcher/subminer"
|
||||||
},
|
|
||||||
{
|
|
||||||
"from": "CHANGELOG.md",
|
|
||||||
"to": "CHANGELOG.md"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
Executable
+171
@@ -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
|
||||||
Executable
+537
@@ -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
|
||||||
@@ -17,17 +17,6 @@
|
|||||||
- **Post-Playback History Menu**
|
- **Post-Playback History Menu**
|
||||||
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
|
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
|
||||||
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
|
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
|
||||||
- The menu shown right after picking a series from `subminer -H` now offers the previous episode too, matching the post-playback menu.
|
|
||||||
|
|
||||||
- **Known-Word Highlighting by Anki Maturity**
|
|
||||||
- Subtitle highlights for known words can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Enable it with `ankiConnect.knownWords.maturityEnabled`, or toggle it live during a session.
|
|
||||||
- The mature-interval threshold and the four tier colors are configurable, and the in-session help legend shows the active tier colors while maturity highlighting is on.
|
|
||||||
- Tiers follow Anki's own card state: a lapsed card correctly shows as learning rather than young, and a note is treated as mature if any of its cards are mature. Stats and other known-word tools stay accurate with this new data.
|
|
||||||
|
|
||||||
- **Stats Library Entry Deletion**
|
|
||||||
- Added a "Delete Entry" action in the stats Library detail view that removes an entire title in one step: every episode, session, subtitle line, rollup, cover, and vocabulary count derived from it. Previously a mistaken entry had to be cleared episode by episode and still lingered in the Library.
|
|
||||||
- Delete progress (session, session group, episode, or full entry) now shows app-wide as a progress bar plus a status toast, staying visible across tabs and windows instead of disappearing when you switch away.
|
|
||||||
- Deletes are dramatically faster on large libraries, and opening the Vocabulary tab no longer stalls; the first launch after upgrading migrates the stats database in place to support this.
|
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
@@ -39,10 +28,6 @@
|
|||||||
- **Word Highlighting Accuracy**
|
- **Word Highlighting Accuracy**
|
||||||
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
|
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
|
||||||
|
|
||||||
- **AniList Season Resolution**
|
|
||||||
- Season 2 and later episodes now resolve to the correct AniList entry by walking sequel relations instead of guessing from the title, so watch progress, the character dictionary, and cover art for later seasons no longer silently fall back to season 1.
|
|
||||||
- Manual AniList overrides now stay in effect for every episode in the same season (by folder and detected season), and setting an override now fixes both the character dictionary and AniList watch progress together instead of needing separate corrections.
|
|
||||||
|
|
||||||
- **Startup Playback Pausing Too Early**
|
- **Startup Playback Pausing Too Early**
|
||||||
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
|
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
|
||||||
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
|
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
|
||||||
@@ -55,16 +40,9 @@
|
|||||||
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
|
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
|
||||||
|
|
||||||
- **Stats & Settings Reliability**
|
- **Stats & Settings Reliability**
|
||||||
- Fixed session stats reporting zero known words after the known-word cache gained maturity tiers.
|
|
||||||
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
|
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
|
||||||
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
|
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
|
||||||
|
|
||||||
- **Stats Library Cover After Relink**
|
|
||||||
- Relinking a title to a different AniList entry now updates its cover art in the stats Library grid, not just the detail view, so unrelated titles no longer end up sharing the wrong cover.
|
|
||||||
|
|
||||||
- **Rofi Menu Prompt Spacing**
|
|
||||||
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
|
|
||||||
|
|
||||||
## What's Changed
|
## What's Changed
|
||||||
|
|
||||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
||||||
@@ -72,9 +50,6 @@
|
|||||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
||||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
||||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
||||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
|
||||||
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
|
|
||||||
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
|
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
## Highlights
|
|
||||||
### Added
|
|
||||||
- **In-App Changelog**
|
|
||||||
- View release notes without leaving the app, from the tray menu ("View Changelog") or the "What's New" button on update notifications.
|
|
||||||
- Shows notes for the latest published release even when it's newer than your installed build, and falls back to the notes bundled with your install if the download fails.
|
|
||||||
- Older versions fold automatically, your installed version is badged, and newer ones are tagged "New"; navigate with `J`/`K` or the arrow keys, `Enter` to expand/collapse, `R` to refresh, and `Esc` to close.
|
|
||||||
|
|
||||||
### Changed
|
|
||||||
- **Faster Subtitle Tokenization**
|
|
||||||
- Subtitle lines are parsed and looked up roughly twice as efficiently, with results cached across lines so repeated words and grammar no longer re-query the dictionary.
|
|
||||||
- Enabling a character dictionary no longer slows subtitle scanning as much, since name lookups now only check positions where a known name can actually start.
|
|
||||||
- Fixed related accuracy issues along the way: readings that could go missing on certain word endings, subtitle text that stayed unannotated after mining a card, character names that could drop out of disambiguation rules, and halfwidth-katakana character names that weren't recognized or read correctly.
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
- **Large Character Dictionary Generation**
|
|
||||||
- Big character dictionaries (long-running series like One Piece) no longer fail to install with a timeout error; the import time budget now scales with dictionary size instead of using a fixed 7-second limit.
|
|
||||||
- The "Generating character dictionary" notification now shows real progress (character/page counts, image download progress with an ETA, name-processing progress) and an elapsed-time clock, so a long-running import no longer looks frozen.
|
|
||||||
- **Stats Deletion Responsiveness**
|
|
||||||
- Deleting sessions, episodes, or library entries on the stats page no longer freezes the page or an active video player; deletes are now batched into a single transaction.
|
|
||||||
- **Subtitle Sidebar Clutter from Styled Subtitles**
|
|
||||||
- Heavily typeset subtitles (karaoke openings/endings, stylized signs) no longer flood the subtitle sidebar with garbled vector-drawing text or duplicate "shadow" copies of the same line.
|
|
||||||
- Subtitle text is now decoded consistently in one place, matching what mpv actually renders on screen, so it can no longer diverge or get cached inconsistently.
|
|
||||||
- **X11/XWayland Playback and Overlay Fixes**
|
|
||||||
- Fixed a crash on the first fullscreen toggle when using an mpv `gpu-next` shader (e.g. ArtCNN) in X11/XWayland mode; SubMiner no longer forces mpv onto its older OpenGL renderer.
|
|
||||||
- Fixed the overlay appearing oversized and offset from the video under fractional or mixed-monitor display scaling in X11/XWayland mode.
|
|
||||||
|
|
||||||
## What's Changed
|
|
||||||
|
|
||||||
- perf(tokenizer): single-pass Yomitan scan with cross-line caching and prefetch fixes by @ksyasuda in #185
|
|
||||||
- fix(subtitles): collapse duplicate ASS events and decode text once by @ksyasuda in #186
|
|
||||||
- feat(overlay): add in-app changelog modal by @ksyasuda in #187
|
|
||||||
- fix(playback): stop forcing legacy OpenGL renderer on X11 mpv backend by @ksyasuda in #188
|
|
||||||
- fix(dictionary): stop large character dictionaries from timing out by @ksyasuda in #189
|
|
||||||
- fix(overlay): handle X11 display scaling across monitors by @ksyasuda in #193
|
|
||||||
- fix(stats): batch deletes off the main thread by @ksyasuda in #194
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
See the README and docs/installation guide for full setup steps.
|
|
||||||
|
|
||||||
## Assets
|
|
||||||
|
|
||||||
- Linux: `SubMiner.AppImage`
|
|
||||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
|
||||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
|
||||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
|
||||||
|
|
||||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -224,19 +224,13 @@ function readPersistedCacheScope(cachePath: string): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ANKI_REQUEST_TIMEOUT_MS = 30_000;
|
|
||||||
|
|
||||||
function createAnkiClient(url: string) {
|
function createAnkiClient(url: string) {
|
||||||
const request = async (action: string, params: unknown): Promise<unknown> => {
|
const request = async (action: string, params: unknown): Promise<unknown> => {
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ action, version: 6, params }),
|
body: JSON.stringify({ action, version: 6, params }),
|
||||||
signal: AbortSignal.timeout(ANKI_REQUEST_TIMEOUT_MS),
|
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error(`AnkiConnect ${action}: HTTP ${response.status} ${response.statusText}`);
|
|
||||||
}
|
|
||||||
const payload = (await response.json()) as { result: unknown; error: string | null };
|
const payload = (await response.json()) as { result: unknown; error: string | null };
|
||||||
if (payload.error) {
|
if (payload.error) {
|
||||||
throw new Error(`AnkiConnect ${action}: ${payload.error}`);
|
throw new Error(`AnkiConnect ${action}: ${payload.error}`);
|
||||||
|
|||||||
@@ -152,6 +152,5 @@ export async function createYomitanRuntimeStateWithSearch(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No usable manifest at the resolved path, so let the loader search on its own.
|
return createYomitanRuntimeState(userDataPath, resolvedExtensionPath ?? undefined);
|
||||||
return createYomitanRuntimeState(userDataPath);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-7
@@ -22,12 +22,10 @@ import { MediaGenerator } from './media-generator';
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
import {
|
import {
|
||||||
AnkiConnectConfig,
|
AnkiConnectConfig,
|
||||||
type CardKind,
|
|
||||||
KikuDuplicateCardInfo,
|
KikuDuplicateCardInfo,
|
||||||
KikuFieldGroupingChoice,
|
KikuFieldGroupingChoice,
|
||||||
KikuMergePreviewResponse,
|
KikuMergePreviewResponse,
|
||||||
NotificationOptions,
|
NotificationOptions,
|
||||||
type WordCardKind,
|
|
||||||
} from './types/anki';
|
} from './types/anki';
|
||||||
import { AiConfig } from './types/integrations';
|
import { AiConfig } from './types/integrations';
|
||||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||||
@@ -52,7 +50,6 @@ import {
|
|||||||
withUpdateProgress,
|
withUpdateProgress,
|
||||||
UiFeedbackState,
|
UiFeedbackState,
|
||||||
} from './anki-integration/ui-feedback';
|
} from './anki-integration/ui-feedback';
|
||||||
import { applyCardKindFlagFields, resolveWordCardKindSetting } from './anki-integration/card-kinds';
|
|
||||||
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
||||||
import { PollingRunner } from './anki-integration/polling';
|
import { PollingRunner } from './anki-integration/polling';
|
||||||
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||||
@@ -86,6 +83,8 @@ interface NoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||||
|
|
||||||
function trimToNonEmptyString(value: unknown): string | null {
|
function trimToNonEmptyString(value: unknown): string | null {
|
||||||
if (typeof value !== 'string') return null;
|
if (typeof value !== 'string') return null;
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
@@ -841,7 +840,6 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
wordCardKind: WordCardKind;
|
|
||||||
} {
|
} {
|
||||||
const lapis = this.getLapisConfig();
|
const lapis = this.getLapisConfig();
|
||||||
const kiku = this.getKikuConfig();
|
const kiku = this.getKikuConfig();
|
||||||
@@ -854,7 +852,6 @@ export class AnkiIntegration {
|
|||||||
kikuEnabled: kiku.enabled,
|
kikuEnabled: kiku.enabled,
|
||||||
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
||||||
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1318,9 +1315,79 @@ export class AnkiIntegration {
|
|||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: CardKind,
|
cardKind: CardKind,
|
||||||
): void {
|
): void {
|
||||||
applyCardKindFlagFields(updatedFields, cardKind, (preferredName) =>
|
const audioFlagNames = ['IsAudioCard'];
|
||||||
this.resolveFieldName(availableFieldNames, preferredName),
|
|
||||||
|
if (cardKind === 'word-and-sentence') {
|
||||||
|
const wordAndSentenceFlag = this.resolveFieldName(
|
||||||
|
availableFieldNames,
|
||||||
|
'IsWordAndSentenceCard',
|
||||||
);
|
);
|
||||||
|
if (!wordAndSentenceFlag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updatedFields[wordAndSentenceFlag] = 'x';
|
||||||
|
|
||||||
|
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||||
|
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[sentenceFlag] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const audioFlagName of audioFlagNames) {
|
||||||
|
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cardKind === 'sentence') {
|
||||||
|
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||||
|
if (sentenceFlag) {
|
||||||
|
updatedFields[sentenceFlag] = 'x';
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const audioFlagName of audioFlagNames) {
|
||||||
|
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
||||||
|
if (resolved && resolved !== sentenceFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordAndSentenceFlag = this.resolveFieldName(
|
||||||
|
availableFieldNames,
|
||||||
|
'IsWordAndSentenceCard',
|
||||||
|
);
|
||||||
|
if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) {
|
||||||
|
updatedFields[wordAndSentenceFlag] = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedAudioFlags = Array.from(
|
||||||
|
new Set(
|
||||||
|
audioFlagNames
|
||||||
|
.map((name) => this.resolveFieldName(availableFieldNames, name))
|
||||||
|
.filter((name): name is string => Boolean(name)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const audioFlagName = resolvedAudioFlags[0] || null;
|
||||||
|
if (audioFlagName) {
|
||||||
|
updatedFields[audioFlagName] = 'x';
|
||||||
|
}
|
||||||
|
for (const extraAudioFlag of resolvedAudioFlags.slice(1)) {
|
||||||
|
updatedFields[extraAudioFlag] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||||
|
if (sentenceFlag && sentenceFlag !== audioFlagName) {
|
||||||
|
updatedFields[sentenceFlag] = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard');
|
||||||
|
if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) {
|
||||||
|
updatedFields[wordAndSentenceFlag] = '';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async showNotification(
|
private async showNotification(
|
||||||
|
|||||||
@@ -4,23 +4,29 @@ import test from 'node:test';
|
|||||||
import { CardCreationService } from './card-creation';
|
import { CardCreationService } from './card-creation';
|
||||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||||
import type { MediaInput } from '../media-generator';
|
import type { MediaInput } from '../media-generator';
|
||||||
import type { AnkiConnectConfig, CardKind } from '../types/anki';
|
import type { AnkiConnectConfig } from '../types/anki';
|
||||||
import { applyCardKindFlagFields } from './card-kinds';
|
|
||||||
|
|
||||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||||
|
|
||||||
function setCardTypeFields(
|
function setWordAndSentenceCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: CardKind,
|
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
|
||||||
): void {
|
): void {
|
||||||
applyCardKindFlagFields(
|
if (cardKind !== 'word-and-sentence') return;
|
||||||
updatedFields,
|
|
||||||
cardKind,
|
const resolveFieldName = (preferredName: string): string | null =>
|
||||||
(preferredName) =>
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||||
null,
|
if (!wordAndSentenceFlag) return;
|
||||||
);
|
|
||||||
|
updatedFields[wordAndSentenceFlag] = 'x';
|
||||||
|
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
const resolved = resolveFieldName(flagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||||
@@ -211,7 +217,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
|||||||
kikuFieldGrouping: 'disabled',
|
kikuFieldGrouping: 'disabled',
|
||||||
kikuDeleteDuplicateInAuto: false,
|
kikuDeleteDuplicateInAuto: false,
|
||||||
}),
|
}),
|
||||||
setCardTypeFields,
|
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||||
});
|
});
|
||||||
|
|
||||||
await service.updateLastAddedFromClipboard('字幕');
|
await service.updateLastAddedFromClipboard('字幕');
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
getConfiguredWordFieldName,
|
getConfiguredWordFieldName,
|
||||||
getPreferredWordValueFromExtractedFields,
|
getPreferredWordValueFromExtractedFields,
|
||||||
} from '../anki-field-config';
|
} from '../anki-field-config';
|
||||||
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
|
import { AnkiConnectConfig } from '../types/anki';
|
||||||
import { createLogger } from '../logger';
|
import { createLogger } from '../logger';
|
||||||
import type { MediaInput } from '../media-input';
|
import type { MediaInput } from '../media-input';
|
||||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
resolveAudioStreamIndexForMediaGeneration,
|
resolveAudioStreamIndexForMediaGeneration,
|
||||||
type MediaGenerationInputResolverOptions,
|
type MediaGenerationInputResolverOptions,
|
||||||
} from './media-source';
|
} from './media-source';
|
||||||
import { resolveWordCardKind } from './note-field-utils';
|
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||||
|
|
||||||
@@ -42,6 +42,8 @@ export interface CardCreationNoteInfo {
|
|||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||||
|
|
||||||
interface CardCreationClient {
|
interface CardCreationClient {
|
||||||
addNote(
|
addNote(
|
||||||
deck: string,
|
deck: string,
|
||||||
@@ -134,7 +136,6 @@ interface CardCreationDeps {
|
|||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
kikuDeleteDuplicateInAuto: boolean;
|
kikuDeleteDuplicateInAuto: boolean;
|
||||||
wordCardKind?: WordCardKind;
|
|
||||||
};
|
};
|
||||||
getFallbackDurationSeconds: () => number;
|
getFallbackDurationSeconds: () => number;
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||||
@@ -273,9 +274,12 @@ export class CardCreationService {
|
|||||||
if (sentenceField) {
|
if (sentenceField) {
|
||||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||||
if (wordCardKind) {
|
this.deps.setCardTypeFields(
|
||||||
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
updatedFields,
|
||||||
|
Object.keys(noteInfo.fields),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { applyCardKindFlagFields } from './card-kinds';
|
|
||||||
|
|
||||||
function resolverFor(availableFieldNames: string[]) {
|
|
||||||
return (preferredName: string): string | null =>
|
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const KIKU_FLAG_FIELDS = ['IsWordAndSentenceCard', 'IsClickCard', 'IsSentenceCard', 'IsAudioCard'];
|
|
||||||
|
|
||||||
test('flags the requested card kind and clears the others', () => {
|
|
||||||
const fields: Record<string, string> = {};
|
|
||||||
|
|
||||||
applyCardKindFlagFields(fields, 'click', resolverFor(KIKU_FLAG_FIELDS));
|
|
||||||
|
|
||||||
assert.deepEqual(fields, {
|
|
||||||
IsClickCard: 'x',
|
|
||||||
IsWordAndSentenceCard: '',
|
|
||||||
IsSentenceCard: '',
|
|
||||||
IsAudioCard: '',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('matches flag fields case-insensitively', () => {
|
|
||||||
const fields: Record<string, string> = {};
|
|
||||||
|
|
||||||
applyCardKindFlagFields(fields, 'word-and-sentence', resolverFor(['iswordandsentencecard']));
|
|
||||||
|
|
||||||
assert.deepEqual(fields, { iswordandsentencecard: 'x' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('leaves flags untouched when the note type has no flag for a word card kind', () => {
|
|
||||||
const fields: Record<string, string> = {};
|
|
||||||
|
|
||||||
applyCardKindFlagFields(
|
|
||||||
fields,
|
|
||||||
'click',
|
|
||||||
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(fields, {});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('clears stale flags for explicit mine actions even without the target flag', () => {
|
|
||||||
const fields: Record<string, string> = {};
|
|
||||||
|
|
||||||
applyCardKindFlagFields(
|
|
||||||
fields,
|
|
||||||
'audio',
|
|
||||||
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(fields, { IsWordAndSentenceCard: '', IsSentenceCard: '' });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('does not blank the target flag it just set', () => {
|
|
||||||
const fields: Record<string, string> = {};
|
|
||||||
|
|
||||||
applyCardKindFlagFields(fields, 'sentence', resolverFor(['IsSentenceCard']));
|
|
||||||
|
|
||||||
assert.deepEqual(fields, { IsSentenceCard: 'x' });
|
|
||||||
});
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
import type { CardKind, WordCardKind } from '../types/anki';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Kiku/Lapis note types decide which card a note generates from mutually exclusive
|
|
||||||
* `Is...Card` flag fields. Setting one always means clearing the others.
|
|
||||||
*/
|
|
||||||
export const CARD_KIND_FLAG_FIELDS: Record<CardKind, string> = {
|
|
||||||
'word-and-sentence': 'IsWordAndSentenceCard',
|
|
||||||
click: 'IsClickCard',
|
|
||||||
sentence: 'IsSentenceCard',
|
|
||||||
audio: 'IsAudioCard',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const WORD_CARD_KINDS: readonly WordCardKind[] = [
|
|
||||||
'word-and-sentence',
|
|
||||||
'click',
|
|
||||||
'sentence',
|
|
||||||
'audio',
|
|
||||||
'none',
|
|
||||||
];
|
|
||||||
|
|
||||||
export const DEFAULT_WORD_CARD_KIND: WordCardKind = 'word-and-sentence';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Card kinds SubMiner marks on its own initiative (word cards). They are only applied
|
|
||||||
* when the note type actually carries the matching flag field, so plain note types keep
|
|
||||||
* their fields untouched.
|
|
||||||
*/
|
|
||||||
const IMPLICIT_CARD_KINDS = new Set<CardKind>(['word-and-sentence', 'click']);
|
|
||||||
|
|
||||||
export function isWordCardKind(value: unknown): value is WordCardKind {
|
|
||||||
return typeof value === 'string' && WORD_CARD_KINDS.includes(value as WordCardKind);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveWordCardKindSetting(value: unknown): WordCardKind {
|
|
||||||
return isWordCardKind(value) ? value : DEFAULT_WORD_CARD_KIND;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flags `cardKind` on the note and clears every other card-kind flag it has, so the note
|
|
||||||
* never ends up claiming to be two kinds of card at once.
|
|
||||||
*/
|
|
||||||
export function applyCardKindFlagFields(
|
|
||||||
updatedFields: Record<string, string>,
|
|
||||||
cardKind: CardKind,
|
|
||||||
resolveFieldName: (preferredName: string) => string | null,
|
|
||||||
): void {
|
|
||||||
const targetFlag = resolveFieldName(CARD_KIND_FLAG_FIELDS[cardKind]);
|
|
||||||
if (!targetFlag && IMPLICIT_CARD_KINDS.has(cardKind)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (targetFlag) {
|
|
||||||
updatedFields[targetFlag] = 'x';
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [kind, flagName] of Object.entries(CARD_KIND_FLAG_FIELDS)) {
|
|
||||||
if (kind === cardKind) continue;
|
|
||||||
const resolved = resolveFieldName(flagName);
|
|
||||||
if (resolved && resolved !== targetFlag) {
|
|
||||||
updatedFields[resolved] = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -69,18 +69,6 @@ function createMaturityHarness(config: AnkiConnectConfig): {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The four queries a maturity refresh issues, in one place so a query-string
|
|
||||||
// change lands in a single spot.
|
|
||||||
function setTierQueries(
|
|
||||||
clientState: { findNotesByQuery: Map<string, number[]> },
|
|
||||||
tiers: { all: number[]; mature: number[]; young: number[]; learning: number[] },
|
|
||||||
): void {
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining"', tiers.all);
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', tiers.mature);
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', tiers.young);
|
|
||||||
clientState.findNotesByQuery.set('deck:"Mining" is:learn', tiers.learning);
|
|
||||||
}
|
|
||||||
|
|
||||||
function maturityConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
|
function maturityConfig(overrides: Partial<AnkiConnectConfig> = {}): AnkiConnectConfig {
|
||||||
return {
|
return {
|
||||||
deck: 'Mining',
|
deck: 'Mining',
|
||||||
@@ -143,7 +131,10 @@ test('refresh fetches tier sets and getKnownWordTier classifies notes', async ()
|
|||||||
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, calls, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [1, 2, 3, 4], mature: [1], young: [2], learning: [3] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3, 4]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [3]);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
||||||
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
||||||
@@ -171,7 +162,10 @@ test('a note with cards in several tiers counts as its most mature card', async
|
|||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [1], mature: [1], young: [1], learning: [1] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
|
||||||
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
|
clientState.notesInfoResult = [{ noteId: 1, fields: { Word: { value: '猫' } } }];
|
||||||
|
|
||||||
await manager.refresh(true);
|
await manager.refresh(true);
|
||||||
@@ -186,7 +180,10 @@ test('a word matched by several notes takes the most mature note tier', async ()
|
|||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [1, 2], mature: [], young: [2], learning: [1] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [1]);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
||||||
{ noteId: 2, fields: { Word: { value: '猫' } } },
|
{ noteId: 2, fields: { Word: { value: '猫' } } },
|
||||||
@@ -204,7 +201,10 @@ test('tiers are reading-aware for words with several readings', async () => {
|
|||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
|
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
|
||||||
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
|
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
|
||||||
@@ -227,7 +227,10 @@ test('reading-only fallback resolves tiers unless opted out', async () => {
|
|||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [1], mature: [1], young: [], learning: [] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', []);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
|
{ noteId: 1, fields: { Word: { value: '警告' }, Reading: { value: 'けいこく' } } },
|
||||||
];
|
];
|
||||||
@@ -302,7 +305,10 @@ test('tiers persist to v4 state and reload without refetching', async () => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
Date.now = () => 120_000;
|
Date.now = () => 120_000;
|
||||||
setTierQueries(clientState, { all: [1, 2], mature: [1], young: [], learning: [2] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [2]);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
{ noteId: 1, fields: { Word: { value: '猫' } } },
|
||||||
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
{ noteId: 2, fields: { Word: { value: '犬' } } },
|
||||||
@@ -353,7 +359,10 @@ test('appendFromNoteInfo preserves an existing maturity tier', async () => {
|
|||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [7, 8], mature: [7], young: [], learning: [8] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [7, 8]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [7]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', []);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [8]);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 7, fields: { Word: { value: '猫' } } },
|
{ noteId: 7, fields: { Word: { value: '猫' } } },
|
||||||
{ noteId: 8, fields: { Word: { value: '犬' } } },
|
{ noteId: 8, fields: { Word: { value: '犬' } } },
|
||||||
@@ -380,7 +389,10 @@ test('getKnownWordMatchNoteIds reports the notes behind a tier', async () => {
|
|||||||
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
const { manager, clientState, cleanup } = createMaturityHarness(maturityConfig());
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setTierQueries(clientState, { all: [1, 2, 3], mature: [1], young: [2], learning: [3] });
|
clientState.findNotesByQuery.set('deck:"Mining"', [1, 2, 3]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=21 -is:learn', [1]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" prop:ivl>=1 prop:ivl<21 -is:learn', [2]);
|
||||||
|
clientState.findNotesByQuery.set('deck:"Mining" is:learn', [3]);
|
||||||
clientState.notesInfoResult = [
|
clientState.notesInfoResult = [
|
||||||
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
|
{ noteId: 1, fields: { Word: { value: '床' }, Reading: { value: 'とこ' } } },
|
||||||
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
|
{ noteId: 2, fields: { Word: { value: '床' }, Reading: { value: 'ゆか' } } },
|
||||||
|
|||||||
@@ -1,118 +0,0 @@
|
|||||||
import assert from 'node:assert/strict';
|
|
||||||
import test from 'node:test';
|
|
||||||
|
|
||||||
import { resolveWordCardKind, type NoteFieldValueInfo } from './note-field-utils';
|
|
||||||
|
|
||||||
function kikuNote(values: Record<string, string> = {}): NoteFieldValueInfo {
|
|
||||||
const defaults: Record<string, string> = {
|
|
||||||
Expression: '単語',
|
|
||||||
Sentence: '',
|
|
||||||
IsWordAndSentenceCard: '',
|
|
||||||
IsClickCard: '',
|
|
||||||
IsSentenceCard: '',
|
|
||||||
IsAudioCard: '',
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
fields: Object.fromEntries(
|
|
||||||
Object.entries({ ...defaults, ...values }).map(([name, value]) => [name, { value }]),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
test('marks word-and-sentence cards by default when Kiku is enabled', () => {
|
|
||||||
assert.equal(
|
|
||||||
resolveWordCardKind(kikuNote(), { lapisEnabled: false, kikuEnabled: true }),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('honors the configured word card kind', () => {
|
|
||||||
assert.equal(
|
|
||||||
resolveWordCardKind(kikuNote(), {
|
|
||||||
lapisEnabled: false,
|
|
||||||
kikuEnabled: true,
|
|
||||||
wordCardKind: 'click',
|
|
||||||
}),
|
|
||||||
'click',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('marks nothing when neither Kiku nor Lapis is enabled', () => {
|
|
||||||
assert.equal(
|
|
||||||
resolveWordCardKind(kikuNote(), {
|
|
||||||
lapisEnabled: false,
|
|
||||||
kikuEnabled: false,
|
|
||||||
wordCardKind: 'click',
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('marks nothing when the word card kind is "none"', () => {
|
|
||||||
assert.equal(
|
|
||||||
resolveWordCardKind(kikuNote(), {
|
|
||||||
lapisEnabled: true,
|
|
||||||
kikuEnabled: false,
|
|
||||||
wordCardKind: 'none',
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to the default kind for an unrecognized setting', () => {
|
|
||||||
assert.equal(
|
|
||||||
resolveWordCardKind(kikuNote(), {
|
|
||||||
lapisEnabled: false,
|
|
||||||
kikuEnabled: true,
|
|
||||||
wordCardKind: 'bogus' as never,
|
|
||||||
}),
|
|
||||||
'word-and-sentence',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('marks nothing when the note type lacks the configured flag field', () => {
|
|
||||||
const note: NoteFieldValueInfo = {
|
|
||||||
fields: { Expression: { value: '単語' }, Sentence: { value: '' } },
|
|
||||||
};
|
|
||||||
|
|
||||||
assert.equal(
|
|
||||||
resolveWordCardKind(note, { lapisEnabled: false, kikuEnabled: true, wordCardKind: 'click' }),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('leaves cards already mined as sentence or audio cards alone', () => {
|
|
||||||
for (const flagField of ['IsSentenceCard', 'IsAudioCard']) {
|
|
||||||
assert.equal(
|
|
||||||
resolveWordCardKind(kikuNote({ [flagField]: 'x' }), {
|
|
||||||
lapisEnabled: false,
|
|
||||||
kikuEnabled: true,
|
|
||||||
wordCardKind: 'click',
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
flagField,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('re-affirms the configured kind when the note already carries its flag', () => {
|
|
||||||
assert.equal(
|
|
||||||
resolveWordCardKind(kikuNote({ IsSentenceCard: 'x' }), {
|
|
||||||
lapisEnabled: false,
|
|
||||||
kikuEnabled: true,
|
|
||||||
wordCardKind: 'sentence',
|
|
||||||
}),
|
|
||||||
'sentence',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('overrides a differently flagged word card', () => {
|
|
||||||
assert.equal(
|
|
||||||
resolveWordCardKind(kikuNote({ IsWordAndSentenceCard: 'x' }), {
|
|
||||||
lapisEnabled: false,
|
|
||||||
kikuEnabled: true,
|
|
||||||
wordCardKind: 'click',
|
|
||||||
}),
|
|
||||||
'click',
|
|
||||||
);
|
|
||||||
});
|
|
||||||
@@ -1,13 +1,3 @@
|
|||||||
import type { CardKind, WordCardKind } from '../types/anki';
|
|
||||||
import { createLogger } from '../logger';
|
|
||||||
import {
|
|
||||||
CARD_KIND_FLAG_FIELDS,
|
|
||||||
DEFAULT_WORD_CARD_KIND,
|
|
||||||
resolveWordCardKindSetting,
|
|
||||||
} from './card-kinds';
|
|
||||||
|
|
||||||
const log = createLogger('anki').child('integration.note-fields');
|
|
||||||
|
|
||||||
export interface NoteFieldValueInfo {
|
export interface NoteFieldValueInfo {
|
||||||
fields: Record<string, { value: string }>;
|
fields: Record<string, { value: string }>;
|
||||||
}
|
}
|
||||||
@@ -26,57 +16,22 @@ export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: s
|
|||||||
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flags set only by an explicit mine action; a note carrying one is not a word card. */
|
export function shouldMarkWordAndSentenceCard(
|
||||||
const EXPLICIT_CARD_FLAG_FIELDS = [CARD_KIND_FLAG_FIELDS.sentence, CARD_KIND_FLAG_FIELDS.audio];
|
|
||||||
|
|
||||||
const warnedMissingFlagFields = new Set<CardKind>();
|
|
||||||
|
|
||||||
function warnMissingFlagFieldOnce(wordCardKind: CardKind, flagField: string): void {
|
|
||||||
if (wordCardKind === DEFAULT_WORD_CARD_KIND || warnedMissingFlagFields.has(wordCardKind)) {
|
|
||||||
// The default kind is also the fallback for plain note types, so its absence is expected.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
warnedMissingFlagFields.add(wordCardKind);
|
|
||||||
log.warn(
|
|
||||||
`Word card type "${wordCardKind}" is configured but the note has no ${flagField} field; leaving card type flags unchanged.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Card kind to flag when SubMiner fills a word card's sentence, or null to leave the
|
|
||||||
* card-kind flags alone. Kiku/Lapis only: other note types have no such fields.
|
|
||||||
*/
|
|
||||||
export function resolveWordCardKind(
|
|
||||||
noteInfo: NoteFieldValueInfo,
|
noteInfo: NoteFieldValueInfo,
|
||||||
sentenceCardConfig: {
|
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
|
||||||
lapisEnabled: boolean;
|
): boolean {
|
||||||
kikuEnabled: boolean;
|
|
||||||
wordCardKind?: WordCardKind;
|
|
||||||
},
|
|
||||||
): CardKind | null {
|
|
||||||
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wordCardKind = resolveWordCardKindSetting(sentenceCardConfig.wordCardKind);
|
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
|
||||||
if (wordCardKind === 'none') {
|
if (wordAndSentenceValue === null) {
|
||||||
return null;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (wordAndSentenceValue.trim().length > 0) {
|
||||||
const flagField = CARD_KIND_FLAG_FIELDS[wordCardKind];
|
return true;
|
||||||
const flagValue = getNoteFieldValue(noteInfo, flagField);
|
|
||||||
if (flagValue === null) {
|
|
||||||
// Note type has no flag field for the configured kind.
|
|
||||||
warnMissingFlagFieldOnce(wordCardKind, flagField);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
if (flagValue.trim().length > 0) {
|
return (
|
||||||
return wordCardKind;
|
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
|
||||||
}
|
|
||||||
|
|
||||||
const alreadyExplicitCard = EXPLICIT_CARD_FLAG_FIELDS.some(
|
|
||||||
(fieldName) =>
|
|
||||||
fieldName.toLowerCase() !== flagField.toLowerCase() && hasNoteFieldValue(noteInfo, fieldName),
|
|
||||||
);
|
);
|
||||||
return alreadyExplicitCard ? null : wordCardKind;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,21 +6,26 @@ import {
|
|||||||
type NoteUpdateWorkflowNoteInfo,
|
type NoteUpdateWorkflowNoteInfo,
|
||||||
} from './note-update-workflow';
|
} from './note-update-workflow';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
import type { CardKind } from '../types/anki';
|
|
||||||
import { applyCardKindFlagFields } from './card-kinds';
|
|
||||||
|
|
||||||
function setCardTypeFields(
|
function setWordAndSentenceCardTypeFields(
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: CardKind,
|
cardKind: 'word-and-sentence',
|
||||||
): void {
|
): void {
|
||||||
applyCardKindFlagFields(
|
assert.equal(cardKind, 'word-and-sentence');
|
||||||
updatedFields,
|
const resolveFieldName = (preferredName: string): string | null =>
|
||||||
cardKind,
|
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||||
(preferredName) =>
|
|
||||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||||
null,
|
if (!wordAndSentenceFlag) return;
|
||||||
);
|
|
||||||
|
updatedFields[wordAndSentenceFlag] = 'x';
|
||||||
|
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||||
|
const resolved = resolveFieldName(flagName);
|
||||||
|
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||||
|
updatedFields[resolved] = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWorkflowHarness() {
|
function createWorkflowHarness() {
|
||||||
@@ -74,7 +79,7 @@ function createWorkflowHarness() {
|
|||||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||||
false,
|
false,
|
||||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||||
setCardTypeFields,
|
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||||
if (!preferred) return null;
|
if (!preferred) return null;
|
||||||
const names = Object.keys(noteInfo.fields);
|
const names = Object.keys(noteInfo.fields);
|
||||||
@@ -178,73 +183,6 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
|
|
||||||
const harness = createWorkflowHarness();
|
|
||||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
|
||||||
sentenceField: 'Sentence',
|
|
||||||
lapisEnabled: false,
|
|
||||||
kikuEnabled: true,
|
|
||||||
kikuFieldGrouping: 'manual',
|
|
||||||
wordCardKind: 'click',
|
|
||||||
});
|
|
||||||
harness.deps.client.notesInfo = async () =>
|
|
||||||
[
|
|
||||||
{
|
|
||||||
noteId: 42,
|
|
||||||
fields: {
|
|
||||||
Expression: { value: 'taberu' },
|
|
||||||
Sentence: { value: '' },
|
|
||||||
IsWordAndSentenceCard: { value: 'x' },
|
|
||||||
IsClickCard: { value: '' },
|
|
||||||
IsSentenceCard: { value: '' },
|
|
||||||
IsAudioCard: { value: '' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
|
||||||
|
|
||||||
await harness.workflow.execute(42);
|
|
||||||
|
|
||||||
assert.equal(harness.updates.length, 1);
|
|
||||||
assert.deepEqual(harness.updates[0]?.fields, {
|
|
||||||
Sentence: 'subtitle-text',
|
|
||||||
IsClickCard: 'x',
|
|
||||||
IsWordAndSentenceCard: '',
|
|
||||||
IsSentenceCard: '',
|
|
||||||
IsAudioCard: '',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
|
|
||||||
const harness = createWorkflowHarness();
|
|
||||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
|
||||||
sentenceField: 'Sentence',
|
|
||||||
lapisEnabled: false,
|
|
||||||
kikuEnabled: true,
|
|
||||||
kikuFieldGrouping: 'manual',
|
|
||||||
wordCardKind: 'none',
|
|
||||||
});
|
|
||||||
harness.deps.client.notesInfo = async () =>
|
|
||||||
[
|
|
||||||
{
|
|
||||||
noteId: 42,
|
|
||||||
fields: {
|
|
||||||
Expression: { value: 'taberu' },
|
|
||||||
Sentence: { value: '' },
|
|
||||||
IsWordAndSentenceCard: { value: '' },
|
|
||||||
IsSentenceCard: { value: '' },
|
|
||||||
IsAudioCard: { value: '' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
|
||||||
|
|
||||||
await harness.workflow.execute(42);
|
|
||||||
|
|
||||||
assert.equal(harness.updates.length, 1);
|
|
||||||
assert.deepEqual(harness.updates[0]?.fields, {
|
|
||||||
Sentence: 'subtitle-text',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||||
const harness = createWorkflowHarness();
|
const harness = createWorkflowHarness();
|
||||||
harness.deps.client.notesInfo = async () =>
|
harness.deps.client.notesInfo = async () =>
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||||
import type { CardKind, WordCardKind } from '../types/anki';
|
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||||
import { resolveWordCardKind } from './note-field-utils';
|
|
||||||
|
|
||||||
export interface NoteUpdateWorkflowNoteInfo {
|
export interface NoteUpdateWorkflowNoteInfo {
|
||||||
noteId: number;
|
noteId: number;
|
||||||
@@ -40,7 +39,6 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
lapisEnabled: boolean;
|
lapisEnabled: boolean;
|
||||||
kikuEnabled: boolean;
|
kikuEnabled: boolean;
|
||||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||||
wordCardKind?: WordCardKind;
|
|
||||||
};
|
};
|
||||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||||
@@ -69,7 +67,7 @@ export interface NoteUpdateWorkflowDeps {
|
|||||||
setCardTypeFields: (
|
setCardTypeFields: (
|
||||||
updatedFields: Record<string, string>,
|
updatedFields: Record<string, string>,
|
||||||
availableFieldNames: string[],
|
availableFieldNames: string[],
|
||||||
cardKind: CardKind,
|
cardKind: 'word-and-sentence',
|
||||||
) => void;
|
) => void;
|
||||||
resolveConfiguredFieldName: (
|
resolveConfiguredFieldName: (
|
||||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||||
@@ -209,9 +207,12 @@ export class NoteUpdateWorkflow {
|
|||||||
if (sentenceField && currentSubtitleText) {
|
if (sentenceField && currentSubtitleText) {
|
||||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||||
updatedFields[sentenceField] = processedSentence;
|
updatedFields[sentenceField] = processedSentence;
|
||||||
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||||
if (wordCardKind) {
|
this.deps.setCardTypeFields(
|
||||||
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
updatedFields,
|
||||||
|
Object.keys(noteInfo.fields),
|
||||||
|
'word-and-sentence',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
updatePerformed = true;
|
updatePerformed = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,10 +116,6 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
|
|||||||
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
||||||
...(config.isKiku ?? {}),
|
...(config.isKiku ?? {}),
|
||||||
},
|
},
|
||||||
lapisKiku: {
|
|
||||||
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
|
|
||||||
...(config.lapisKiku ?? {}),
|
|
||||||
},
|
|
||||||
} as AnkiConnectConfig;
|
} as AnkiConnectConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,10 +205,6 @@ export class AnkiIntegrationRuntime {
|
|||||||
patch.isKiku !== undefined
|
patch.isKiku !== undefined
|
||||||
? { ...this.config.isKiku, ...patch.isKiku }
|
? { ...this.config.isKiku, ...patch.isKiku }
|
||||||
: this.config.isKiku,
|
: this.config.isKiku,
|
||||||
lapisKiku:
|
|
||||||
patch.lapisKiku !== undefined
|
|
||||||
? { ...this.config.lapisKiku, ...patch.lapisKiku }
|
|
||||||
: this.config.lapisKiku,
|
|
||||||
};
|
};
|
||||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||||
this.deps.onConfigChanged?.(this.config);
|
this.deps.onConfigChanged?.(this.config);
|
||||||
|
|||||||
@@ -399,30 +399,6 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(statsLifetimeRebuild.statsCleanupLifetime, true);
|
assert.equal(statsLifetimeRebuild.statsCleanupLifetime, true);
|
||||||
assert.equal(statsLifetimeRebuild.statsCleanupVocab, false);
|
assert.equal(statsLifetimeRebuild.statsCleanupVocab, false);
|
||||||
|
|
||||||
assert.throws(
|
|
||||||
() =>
|
|
||||||
parseArgs([
|
|
||||||
'--stats',
|
|
||||||
'--stats-cleanup',
|
|
||||||
'--stats-cleanup-duplicate-lines',
|
|
||||||
'--stats-cleanup-lookback-days',
|
|
||||||
'0.5',
|
|
||||||
]),
|
|
||||||
/at least one day/,
|
|
||||||
);
|
|
||||||
assert.equal(
|
|
||||||
parseArgs([
|
|
||||||
'--stats',
|
|
||||||
'--stats-cleanup',
|
|
||||||
'--stats-cleanup-duplicate-lines',
|
|
||||||
'--stats-cleanup-lookback-days',
|
|
||||||
'1.5',
|
|
||||||
]).statsCleanupLookbackDays,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
assert.equal(parseArgs(['--stats-cleanup-lookback-days=30']).statsCleanupLookbackDays, 30);
|
|
||||||
assert.throws(() => parseArgs(['--stats-cleanup-lookback-days=30=oops']), /at least one day/);
|
|
||||||
|
|
||||||
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
|
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
|
||||||
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
||||||
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
||||||
|
|||||||
+1
-22
@@ -64,9 +64,6 @@ export interface CliArgs {
|
|||||||
statsCleanup?: boolean;
|
statsCleanup?: boolean;
|
||||||
statsCleanupVocab?: boolean;
|
statsCleanupVocab?: boolean;
|
||||||
statsCleanupLifetime?: boolean;
|
statsCleanupLifetime?: boolean;
|
||||||
statsCleanupDuplicateLines?: boolean;
|
|
||||||
statsCleanupDryRun?: boolean;
|
|
||||||
statsCleanupLookbackDays?: number;
|
|
||||||
statsResponsePath?: string;
|
statsResponsePath?: string;
|
||||||
jellyfin: boolean;
|
jellyfin: boolean;
|
||||||
jellyfinLogin: boolean;
|
jellyfinLogin: boolean;
|
||||||
@@ -112,14 +109,6 @@ export interface CliArgs {
|
|||||||
|
|
||||||
export type CliCommandSource = 'initial' | 'second-instance';
|
export type CliCommandSource = 'initial' | 'second-instance';
|
||||||
|
|
||||||
function parseStatsCleanupLookbackDays(value: string | undefined): number {
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseArgs(argv: string[]): CliArgs {
|
export function parseArgs(argv: string[]): CliArgs {
|
||||||
const args: CliArgs = {
|
const args: CliArgs = {
|
||||||
background: false,
|
background: false,
|
||||||
@@ -178,8 +167,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
statsCleanup: false,
|
statsCleanup: false,
|
||||||
statsCleanupVocab: false,
|
statsCleanupVocab: false,
|
||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
statsCleanupDuplicateLines: false,
|
|
||||||
statsCleanupDryRun: false,
|
|
||||||
jellyfin: false,
|
jellyfin: false,
|
||||||
jellyfinLogin: false,
|
jellyfinLogin: false,
|
||||||
jellyfinLogout: false,
|
jellyfinLogout: false,
|
||||||
@@ -381,15 +368,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
} else if (arg === '--stats-cleanup') args.statsCleanup = true;
|
} else if (arg === '--stats-cleanup') args.statsCleanup = true;
|
||||||
else if (arg === '--stats-cleanup-vocab') args.statsCleanupVocab = true;
|
else if (arg === '--stats-cleanup-vocab') args.statsCleanupVocab = true;
|
||||||
else if (arg === '--stats-cleanup-lifetime') args.statsCleanupLifetime = true;
|
else if (arg === '--stats-cleanup-lifetime') args.statsCleanupLifetime = true;
|
||||||
else if (arg === '--stats-cleanup-duplicate-lines') args.statsCleanupDuplicateLines = true;
|
else if (arg.startsWith('--stats-response-path=')) {
|
||||||
else if (arg === '--stats-cleanup-dry-run') args.statsCleanupDryRun = true;
|
|
||||||
else if (arg.startsWith('--stats-cleanup-lookback-days=')) {
|
|
||||||
args.statsCleanupLookbackDays = parseStatsCleanupLookbackDays(
|
|
||||||
arg.slice('--stats-cleanup-lookback-days='.length),
|
|
||||||
);
|
|
||||||
} else if (arg === '--stats-cleanup-lookback-days') {
|
|
||||||
args.statsCleanupLookbackDays = parseStatsCleanupLookbackDays(readValue(argv[i + 1]));
|
|
||||||
} else if (arg.startsWith('--stats-response-path=')) {
|
|
||||||
const value = arg.split('=', 2)[1];
|
const value = arg.split('=', 2)[1];
|
||||||
if (value) args.statsResponsePath = value;
|
if (value) args.statsResponsePath = value;
|
||||||
} else if (arg === '--stats-response-path') {
|
} else if (arg === '--stats-response-path') {
|
||||||
|
|||||||
@@ -2738,43 +2738,6 @@ test('ignores deprecated isLapis sentence-card field overrides', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
|
|
||||||
const dir = makeTempDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(dir, 'config.jsonc'),
|
|
||||||
`{
|
|
||||||
"ankiConnect": {
|
|
||||||
"isKiku": { "enabled": true },
|
|
||||||
"lapisKiku": { "wordCardKind": "click" }
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const service = new ConfigService(dir);
|
|
||||||
assert.equal(service.getConfig().ankiConnect.lapisKiku.wordCardKind, 'click');
|
|
||||||
assert.equal(service.getWarnings().length, 0);
|
|
||||||
|
|
||||||
const invalidDir = makeTempDir();
|
|
||||||
fs.writeFileSync(
|
|
||||||
path.join(invalidDir, 'config.jsonc'),
|
|
||||||
`{
|
|
||||||
"ankiConnect": {
|
|
||||||
"lapisKiku": { "wordCardKind": "isClickCard" }
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
|
|
||||||
const invalidService = new ConfigService(invalidDir);
|
|
||||||
assert.equal(invalidService.getConfig().ankiConnect.lapisKiku.wordCardKind, 'word-and-sentence');
|
|
||||||
assert.ok(
|
|
||||||
invalidService
|
|
||||||
.getWarnings()
|
|
||||||
.some((warning) => warning.path === 'ankiConnect.lapisKiku.wordCardKind'),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('accepts valid ankiConnect knownWords deck object', () => {
|
test('accepts valid ankiConnect knownWords deck object', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user