Compare commits

..

15 Commits

Author SHA1 Message Date
sudacode e241aa8c86 Fix PR #60 CI failures and address CodeRabbit feedback
- Restore raw tokensSeen for session summaries; keep filtered counts for aggregates/known-words
- Fix missing headword binding in insertFilteredWordOccurrence test fixture
- Page vocabulary stats until enough visible rows collected after post-query filtering
- Use lifetime totals for library/detail word counts instead of partial retained-session sums
- Prefer stored rollup totals over recomputed session counts when recomputation is partial
- Emit flat known-word timeline points for line indexes with no occurrences
- Roll back local excluded-word state and throw on failed persistence
- Reset initialized flag on load failure to allow retry on next call
- Restore globalThis.localStorage after each excluded-words test
2026-05-03 20:00:10 -07:00
sudacode 25d0aa47db Persist stats exclusions in DB and fix word metrics filtering
- Stats vocabulary exclusions stored in `imm_stats_excluded_words` (schema v18); seeded from localStorage on first load
- Session, overview, trends, and library word metrics use filtered persisted occurrences with raw fallback
- Session known-word % chart uses filtered persisted totals as denominator for both known and total
- JLPT subtitle styling changed to underline-only; no longer overrides text color
2026-05-03 19:40:54 -07:00
sudacode db30c61327 [codex] Fix Jellyfin setup and discovery toggle (#59) 2026-05-02 19:56:10 -07:00
sudacode 27f5b2bb58 Polish changelog fragments with claude -p at release time
- Replace `renderGroupedChanges` with `polishFragmentsWithClaude` that pipes fragments through `claude -p --model sonnet` to merge related items, drop housekeeping noise, and produce user-facing release notes
- Internal fragments kept in CHANGELOG.md under a `<details>` collapse; dropped from GitHub release notes entirely
- CI no longer auto-runs `changelog:build` on tag-based releases — fails fast with a clear error if `changes/*.md` fragments are still pending; build locally and commit before tagging
- Add `runClaude` dep-injection seam to test surface; add failure-mode coverage (missing binary, empty output, missing headers, missing `<details>` wrapper)
- Delete implemented design doc; update `changes/README.md` and `docs/RELEASING.md` with claude CLI prerequisite and new workflow
2026-05-02 19:52:48 -07:00
sudacode baabdb6d30 Add design doc for AI-polished changelog workflow
- Capture decisions from brainstorming: replace bullet renderer with `claude -p`, write straight to disk, hard-fail on missing/failed claude, drop internal section from release notes but keep collapsed in CHANGELOG.md
- Document prompt input/output contract, affected files, test plan, and CI guard that fails tag-based releases when changelog fragments are still pending
- Set scope boundaries (no caching, no SDK fallback, no `--no-polish` escape hatch)
2026-05-02 19:52:13 -07:00
sudacode 3a67e23bc3 feat: open texthooker from cli and tray 2026-05-02 19:37:44 -07:00
sudacode 13e2b5f8c8 Handle mpv reload buffering as same media
- Keep overlay alive across same-media mpv reloads
- Avoid rearming startup gate and repeating AniSkip lookups
- Add regression coverage for reload/end-file/file-loaded sequence
2026-05-02 15:42:54 -07:00
sudacode 53aa58d044 Route stats background mode through isolated daemon and defer in-app startup to live daemon (#58) 2026-04-26 19:26:01 -07:00
sudacode d8934647a9 Restore multi-copy digit capture and add AniList selection (#56) 2026-04-25 21:44:55 -07:00
sudacode 7ac51cd5e9 chore(release): prepare v0.12.0 2026-04-11 21:54:00 -07:00
sudacode 52bab1d611 Windows update (#49) 2026-04-11 21:45:52 -07:00
sudacode 49e46e6b9b chore(repo): update vendor and backlog tasks 2026-04-11 14:53:06 -07:00
sudacode c1c40c8d40 fix(immersion-tracker): preserve timestamps under Bun libsql 2026-04-11 14:49:54 -07:00
sudacode c71482cb44 fix(mpv-plugin): restore Lua parser compatibility 2026-04-11 14:49:46 -07:00
sudacode 05cf4a6fe5 feat(stats): dashboard updates (#50) 2026-04-10 02:46:50 -07:00
347 changed files with 20609 additions and 3199 deletions
+42 -25
View File
@@ -32,9 +32,9 @@ jobs:
node_modules node_modules
stats/node_modules stats/node_modules
vendor/subminer-yomitan/node_modules vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-bun- ${{ runner.os }}-${{ runner.arch }}-bun-
- name: Install dependencies - name: Install dependencies
run: | run: |
@@ -50,6 +50,9 @@ jobs:
- name: Test suite (source) - name: Test suite (source)
run: bun run test:fast run: bun run test:fast
- name: Environment suite
run: bun run test:env
- name: Coverage suite (maintained source lane) - name: Coverage suite (maintained source lane)
run: bun run test:coverage:src run: bun run test:coverage:src
@@ -103,9 +106,9 @@ jobs:
stats/node_modules stats/node_modules
vendor/texthooker-ui/node_modules vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-bun- ${{ runner.os }}-${{ runner.arch }}-bun-
- name: Install dependencies - name: Install dependencies
run: | run: |
@@ -137,6 +140,7 @@ jobs:
with: with:
name: appimage name: appimage
path: release/*.AppImage path: release/*.AppImage
if-no-files-found: error
build-macos: build-macos:
needs: [quality-gate] needs: [quality-gate]
@@ -161,9 +165,9 @@ jobs:
stats/node_modules stats/node_modules
vendor/texthooker-ui/node_modules vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-bun- ${{ runner.os }}-${{ runner.arch }}-bun-
- name: Validate macOS signing/notarization secrets - name: Validate macOS signing/notarization secrets
run: | run: |
@@ -212,6 +216,7 @@ jobs:
path: | path: |
release/*.dmg release/*.dmg
release/*.zip release/*.zip
if-no-files-found: error
build-windows: build-windows:
needs: [quality-gate] needs: [quality-gate]
@@ -236,9 +241,9 @@ jobs:
stats/node_modules stats/node_modules
vendor/texthooker-ui/node_modules vendor/texthooker-ui/node_modules
vendor/subminer-yomitan/node_modules vendor/subminer-yomitan/node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/texthooker-ui/package.json', 'vendor/subminer-yomitan/package-lock.json') }}
restore-keys: | restore-keys: |
${{ runner.os }}-bun- ${{ runner.os }}-${{ runner.arch }}-bun-
- name: Install dependencies - name: Install dependencies
run: | run: |
@@ -304,9 +309,9 @@ jobs:
path: | path: |
~/.bun/install/cache ~/.bun/install/cache
node_modules node_modules
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock') }} key: ${{ runner.os }}-${{ runner.arch }}-bun-${{ hashFiles('bun.lock') }}
restore-keys: | restore-keys: |
${{ runner.os }}-bun- ${{ runner.os }}-${{ runner.arch }}-bun-
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: bun install --frozen-lockfile
@@ -339,7 +344,12 @@ jobs:
echo "No release artifacts found for checksum generation." echo "No release artifacts found for checksum generation."
exit 1 exit 1
fi fi
sha256sum "${files[@]}" > release/SHA256SUMS.txt : > release/SHA256SUMS.txt
for file in "${files[@]}"; do
printf '%s %s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \
"${file##*/}" >> release/SHA256SUMS.txt
done
- name: Get version from tag - name: Get version from tag
id: version id: version
@@ -354,20 +364,6 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
gh release edit "${{ steps.version.outputs.VERSION }}" \
--draft=false \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/prerelease-notes.md
else
gh release create "${{ steps.version.outputs.VERSION }}" \
--latest=false \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/prerelease-notes.md
fi
shopt -s nullglob shopt -s nullglob
artifacts=( artifacts=(
release/*.AppImage release/*.AppImage
@@ -384,6 +380,27 @@ jobs:
exit 1 exit 1
fi fi
if gh release view "${{ steps.version.outputs.VERSION }}" >/dev/null 2>&1; then
gh release edit "${{ steps.version.outputs.VERSION }}" \
--draft \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/prerelease-notes.md
else
gh release create "${{ steps.version.outputs.VERSION }}" \
--draft \
--latest=false \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/prerelease-notes.md
fi
for asset in "${artifacts[@]}"; do for asset in "${artifacts[@]}"; do
gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber gh release upload "${{ steps.version.outputs.VERSION }}" "$asset" --clobber
done done
gh release edit "${{ steps.version.outputs.VERSION }}" \
--draft=false \
--prerelease \
--title "${{ steps.version.outputs.VERSION }}" \
--notes-file release/prerelease-notes.md
+9 -5
View File
@@ -340,18 +340,22 @@ jobs:
echo "No release artifacts found for checksum generation." echo "No release artifacts found for checksum generation."
exit 1 exit 1
fi fi
sha256sum "${files[@]}" > release/SHA256SUMS.txt : > release/SHA256SUMS.txt
for file in "${files[@]}"; do
printf '%s %s\n' \
"$(sha256sum "$file" | awk '{print $1}')" \
"${file##*/}" >> release/SHA256SUMS.txt
done
- name: Get version from tag - name: Get version from tag
id: version id: version
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
- name: Build changelog artifacts for release - name: Guard against pending changelog fragments
run: | run: |
if find changes -maxdepth 1 -name '*.md' -not -name README.md -print -quit | grep -q .; then if find changes -maxdepth 1 -name '*.md' -not -name README.md -print -quit | grep -q .; then
bun run changelog:build --version "${{ steps.version.outputs.VERSION }}" echo "::error::Pending changelog fragments detected. Run 'bun run changelog:build --version ${{ steps.version.outputs.VERSION }}' locally and commit the polished CHANGELOG.md before tagging. CI no longer auto-builds the changelog because the polish step requires the local 'claude' CLI."
else exit 1
echo "No pending changelog fragments found."
fi fi
- name: Verify changelog is ready for tagged release - name: Verify changelog is ready for tagged release
+38
View File
@@ -1,5 +1,43 @@
# Changelog # Changelog
## v0.12.0 (2026-04-11)
### Changed
- Overlay: Added configurable overlay shortcuts for session help, controller select, and controller debug actions.
- Overlay: Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path.
- Overlay: Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser.
- Overlay: Fixed controller configuration and controller debug shortcut opens so configured bindings bring up their modals again instead of tripping renderer recovery.
- Stats: Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
- Stats: Trends add a 365-day range next to the existing 7d/30d/90d/all options.
- Stats: Library detail view gets a delete-episode action that removes the video and all its sessions.
- Stats: Vocabulary Top 50 tightens the word/reading column so katakana entries no longer push the scores off screen.
- Stats: Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
- Stats: Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
- Stats: Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
- Stats: Session timeline no longer plots seek-forward/seek-backward markers — they were too noisy on sessions with lots of rewinds.
- Stats: Replaced the "Library — Per Day" section on the Stats → Trends page with a "Library — Summary" section. The new section shows a top-10 watch-time leaderboard chart and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector.
### Fixed
- Overlay: Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible.
- Overlay: Addressed the latest CodeRabbit follow-ups on PR #49, including generation-scoped Lua session binding names, stricter session command validation, session-help shortcut visibility, the numeric-selection key guard, stats-overlay startup classification, and safer session-binding persistence.
- Overlay: Addressed the latest CodeRabbit follow-ups on the Windows overlay flow, including exact mpv target resolution, lower-overlay helper arguments, Win32 failure detection, and overlay cleanup on tracker loss.
- Overlay: Fixed Windows overlay z-order so the visible subtitle overlay stops staying above unrelated apps after mpv loses focus.
- Overlay: Fixed Windows overlay tracking to use native window polling and owner/z-order binding, which keeps the subtitle overlay aligned to the active mpv window more reliably.
- Overlay: Fixed Windows overlay hide/restore behavior so minimizing mpv immediately hides the overlay and restoring mpv brings it back on top of the mpv window without requiring a click.
- Overlay: Fixed stats overlay layering so the in-player stats page now stays above mpv and the subtitle overlay while it is open.
- Overlay: Fixed Windows subtitle overlay stability so transient tracker misses and restore events keep the current subtitle visible instead of waiting for the next subtitle line.
- Overlay: Fixed Windows focus handoff from the interactive subtitle overlay back to mpv so the overlay no longer drops behind mpv and briefly disappears.
- Overlay: Fixed Windows visible-overlay startup so it no longer briefly opens as an interactive or opaque surface before the tracked transparent overlay state settles.
- Overlay: Fixed spurious auto-pause after overlay visibility recovery and window resize so the overlay no longer pauses mpv until the pointer genuinely re-enters the subtitle area.
- Overlay: Fixed Windows secondary subtitle hover mode so the expanded hover hit area no longer blocks the native minimize, maximize, and close buttons.
- Overlay: Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv.
- Stats: Fixed immersion-tracker timestamp handling under Bun/libsql so library rows, session timelines, and lifetime summaries keep real wall-clock millisecond values instead of truncating to invalid negative timestamps.
- Mpv Plugin: Fixed the mpv Lua plugin so hover and environment modules no longer use the `goto continue` pattern that can fail to parse on some user Lua runtimes.
### Internal
- Release: Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR.
- Release: Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut.
## v0.11.2 (2026-04-07) ## v0.11.2 (2026-04-07)
### Changed ### Changed
+3 -1
View File
@@ -84,7 +84,7 @@ Browse sibling episode files and the active mpv queue in one overlay modal. Open
</tr> </tr>
<tr> <tr>
<td><b>Jellyfin</b></td> <td><b>Jellyfin</b></td>
<td>Browse and launch media from your Jellyfin server</td> <td>Browse, launch, and cast media from your Jellyfin server with setup and discovery controls in the app tray</td>
</tr> </tr>
<tr> <tr>
<td><b>Jimaku</b></td> <td><b>Jimaku</b></td>
@@ -252,6 +252,8 @@ subminer app --setup # launch the first-run setup wizard
SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing the mpv plugin and configuring Yomitan dictionaries. Follow the on-screen steps to complete setup. SubMiner creates a default config, starts in the system tray, and opens a setup popup that walks you through installing the mpv plugin and configuring Yomitan dictionaries. Follow the on-screen steps to complete setup.
Jellyfin setup is available from the tray or `subminer jellyfin`; once Jellyfin is enabled with a server URL, the tray can toggle Jellyfin Discovery for the current app session.
> [!NOTE] > [!NOTE]
> On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch. > On Windows, run `SubMiner.exe` directly — it opens the setup wizard automatically on first launch.
@@ -0,0 +1,34 @@
---
id: TASK-285
title: Rename anime visibility filter heading to title visibility
status: Done
assignee:
- codex
created_date: '2026-04-10 00:00'
updated_date: '2026-04-10 00:00'
labels:
- stats
- ui
- bug
milestone: m-1
dependencies: []
references:
- stats/src/components/trends/TrendsTab.tsx
- stats/src/components/trends/TrendsTab.test.tsx
priority: low
ordinal: 200000
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Align the library cumulative trends filter UI with the new terminology by renaming the hardcoded anime visibility heading to title visibility.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The trends filter heading uses `Title Visibility`
- [x] #2 The component behavior and props stay unchanged
- [x] #3 A regression test covers the rendered heading text
<!-- AC:END -->
@@ -0,0 +1,44 @@
---
id: TASK-314
title: Improve Jellyfin setup popup and tray discovery toggle
status: Done
assignee: []
created_date: '2026-05-02 22:45'
updated_date: '2026-05-02 23:11'
labels:
- jellyfin
dependencies: []
references:
- src/main/runtime/jellyfin-setup-window.ts
- src/main/runtime/jellyfin-cli-auth.ts
- src/main/runtime/tray-runtime.ts
- src/main/runtime/jellyfin-remote-session-lifecycle.ts
documentation:
- docs-site/jellyfin-integration.md
- docs-site/configuration.md
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Improve the Jellyfin integration setup experience and remove the need to use command-line discovery mode for normal tray-driven use. The existing `--jellyfin` setup popup should become a frontend for the same auth persistence path used by CLI login, with manual/recent server selection and inline feedback. The tray should expose a runtime-only Jellyfin Discovery checkbox when Jellyfin is configured so users can start or stop cast/discovery mode without changing config.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The Jellyfin setup popup supports config/recent/default server choices, manual URL entry, username/password login, logout when a session exists, done/close, and inline success/error status without persisting passwords.
- [x] #2 CLI login and setup popup login share the same auth persistence behavior, including encrypted token storage, enabled/server/username/client metadata config patching, and recent server updates.
- [x] #3 `jellyfin.recentServers` is parsed, normalized, deduplicated, capped, documented, and included in generated config examples if exposed.
- [x] #4 The tray keeps Configure Jellyfin visible and shows a Jellyfin Discovery checkbox only when Jellyfin is configured with enabled integration, server URL, access token, and user ID.
- [x] #5 The tray Jellyfin Discovery checkbox starts/stops the current remote session at runtime only, announces after start, reports OSD/log status, and does not patch config.
- [x] #6 Startup auto-connect behavior remains governed by existing config, including `remoteControlAutoConnect`; explicit tray start can start discovery without requiring `remoteControlAutoConnect`.
- [x] #7 Focused tests cover setup popup actions/rendering, shared auth persistence, config parsing, tray toggle visibility/state/click behavior, and remote lifecycle auto-connect versus explicit-start behavior.
- [x] #8 Jellyfin docs and changelog fragment are updated.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented Jellyfin setup popup improvements, shared auth persistence for CLI/setup config shape, recent server config support, runtime-only tray Jellyfin Discovery toggle, docs/config examples, and changelog fragment. Verified focused Jellyfin/tray tests, config tests, launcher tests, typecheck, and docs tests.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,63 @@
---
id: TASK-286
title: 'Assess and address PR #49 CodeRabbit review follow-ups'
status: Done
assignee:
- codex
created_date: '2026-04-11 18:55'
updated_date: '2026-04-11 22:40'
labels:
- bug
- code-review
- windows
- overlay
dependencies: []
references:
- src/main/runtime/config-hot-reload-handlers.ts
- src/renderer/handlers/keyboard.ts
- src/renderer/handlers/mouse.ts
- vendor/subminer-yomitan
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Track the current PR #49 review round and resolve the actionable CodeRabbit findings on the Windows update branch.
Focus areas include the renderer mouse interaction fix, config hot-reload keyboard state, and any other review items that still apply after verifying the current branch state.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 All actionable CodeRabbit comments on PR #49 are either fixed or shown to be obsolete with evidence.
- [x] #2 Regression tests are added or updated for any behavior change that could regress.
- [x] #3 The branch passes the repo's relevant verification checks for the touched areas.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Pull the current unresolved CodeRabbit review threads for PR #49 and cluster them into still-actionable fixes versus obsolete/nit-only items.
2. For each still-actionable behavior bug, add or extend the narrowest failing test first in the touched suite before changing production code.
3. Implement the minimal fixes across the affected runtime, renderer, plugin, IPC, and Windows tracker files, keeping each change traceable to the review thread.
4. Run targeted verification for the touched areas, update task notes with assessment results, and capture which review comments were fixed versus assessed as obsolete or deferred nitpicks.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Assessed PR #49 CodeRabbit threads. Fixed the real regressions in first-run CLI gating, IPC session-action validation, renderer controller-modal lifecycle notifications, async subtitle-sidebar toggle guarding, plugin config-dir resolution priority, prerelease artifact upload failure handling, immersion tracker lazy startup, win32 z-order error handling, and Windows socket-aware mpv matching.
Review assessment: the overlay-shortcut lifecycle comment is obsolete for the current architecture because overlay-local shortcuts are intentionally handled through the local fallback path and the runtime only tracks configured-state/cleanup. Refactor-only nit comments for splitting `scripts/build-changelog.ts` and `src/core/services/session-bindings.ts` were left as follow-up quality work, not behavior bugs in this PR round.
Verification: `bun test src/main/runtime/first-run-setup-service.test.ts src/core/services/session-bindings.test.ts src/core/services/app-ready.test.ts src/core/services/ipc.test.ts src/renderer/handlers/keyboard.test.ts src/main/overlay-runtime.test.ts src/window-trackers/mpv-socket-match.test.ts`, `bun test src/window-trackers/windows-tracker.test.ts`, `bun run typecheck`, `lua scripts/test-plugin-lua-compat.lua`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Assessed the current CodeRabbit round on PR #49 and addressed the still-valid behavior issues rather than blanket-applying every bot suggestion. The branch now treats the new session/stats CLI flags as explicit startup commands during first-run setup, validates the new session actions through IPC, points session-binding command diagnostics at the correct config field, keeps immersion tracker startup lazy until later runtime triggers, and notifies overlay modal lifecycle state when controller-select/debug are opened from local keyboard bindings. I also switched the subtitle-sidebar IPC callback to the async guarded path so promise rejections feed renderer recovery instead of being dropped.
On the Windows/plugin side, the mpv plugin now prefers config-file matches before falling back to an existing config directory, prerelease workflow uploads fail if expected Linux/macOS artifacts are missing, the Win32 z-order bind path now validates the `GetWindowLongW` call for the window above mpv, and the Windows tracker now passes the target socket path into native polling and filters mpv instances by command line so multiple sockets can be distinguished on Windows. Added/updated regression coverage for first-run gating, IPC validation, session-binding diagnostics, controller modal lifecycle notifications, modal ready-listener dispatch, and socket-path matching. Verification run: `bun run typecheck`, the targeted Bun test suites for the touched areas, `bun test src/window-trackers/windows-tracker.test.ts`, and `lua scripts/test-plugin-lua-compat.lua`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,61 @@
---
id: TASK-286.1
title: 'Assess and address PR #49 subsequent CodeRabbit review round'
status: Done
assignee: []
created_date: '2026-04-11 23:14'
updated_date: '2026-04-11 23:16'
labels:
- bug
- code-review
- windows
- release
dependencies: []
references:
- .github/workflows/prerelease.yml
- src/window-trackers/mpv-socket-match.ts
- src/window-trackers/win32.ts
- src/core/services/overlay-shortcut.ts
parent_task_id: TASK-286
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Track the next unresolved CodeRabbit review threads on PR #49 after commit 9ce5de2f and resolve the still-valid follow-up issues without reopening already-assessed stale comments.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 All still-actionable CodeRabbit comments in the latest PR #49 round are fixed or explicitly shown stale with evidence.
- [x] #2 Regression coverage is added or updated for any behavior-sensitive fix in workflow or Windows socket matching.
- [x] #3 Relevant verification passes for the touched workflow, tracker, and shared matcher changes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Verify the five unresolved CodeRabbit threads against current branch state and separate still-valid bugs from stale comments.
2. Add or extend the narrowest failing tests for exact socket-path matching and prerelease workflow invariants before changing production code.
3. Implement minimal fixes in the prerelease workflow and Windows socket matching/cache path, leaving stale comments documented with evidence instead of forcing no-op changes.
4. Run targeted verification, then record the fixed-vs-stale assessment and close the subtask.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Assessed five unresolved PR #49 threads after 9ce5de2f. Fixed prerelease workflow cache keys to include `runner.arch`, changed prerelease publishing to validate artifacts before release creation/edit and only undraft after uploads complete, tightened Windows socket matching to require exact argument boundaries, and stopped memoizing null command-line lookup misses in the Win32 cache path.
Stale assessment: the `src/core/services/overlay-shortcut.ts` thread is still obsolete. Current code at `registerOverlayShortcuts()` returns `hasConfiguredOverlayShortcuts(shortcuts)`, not `false`, and the overlay-local handling remains intentionally driven by local fallback dispatch rather than global registration in this runtime path.
Verification: `bun test src/prerelease-workflow.test.ts src/window-trackers/mpv-socket-match.test.ts`, `bun test src/window-trackers/windows-tracker.test.ts src/prerelease-workflow.test.ts src/window-trackers/mpv-socket-match.test.ts`, `bun run typecheck`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Handled the next CodeRabbit round on PR #49 by fixing the still-valid prerelease workflow and Windows socket-matching issues while documenting the stale overlay-shortcut comment instead of forcing a no-op code change. The prerelease workflow now scopes all dependency caches by `runner.arch`, validates the final artifact set before touching the GitHub release, creates/edits the prerelease as a draft during uploads, and only flips `--draft=false` after all assets succeed. On Windows, socket matching now requires an exact `--input-ipc-server` argument boundary so `subminer-1` no longer matches `subminer-10`, and transient PowerShell/CIM misses no longer get cached forever as null command lines.
Regression coverage was added for the workflow invariants and exact socket matching. Verification passed with targeted prerelease workflow tests, Windows tracker tests, socket-matcher tests, and `bun run typecheck`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,49 @@
---
id: TASK-286.2
title: 'Assess and address PR #49 next CodeRabbit review round'
status: Done
assignee: []
created_date: '2026-04-12 02:50'
updated_date: '2026-04-12 02:52'
labels:
- bug
- code-review
- release
- testing
dependencies: []
references:
- .github/workflows/prerelease.yml
- src/prerelease-workflow.test.ts
- src/core/services/overlay-shortcut.ts
parent_task_id: TASK-286
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Track the next unresolved CodeRabbit review threads on PR #49 after commit 62ad77dc and resolve the still-valid follow-up issues while documenting stale repeats.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 All still-actionable CodeRabbit comments in the latest PR #49 round are fixed or explicitly shown stale with evidence.
- [x] #2 Regression coverage is updated for any workflow or test changes made in this round.
- [x] #3 Relevant verification passes for the touched workflow and prerelease test changes.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Assessed latest unresolved CodeRabbit round on PR #49. `src/core/services/overlay-shortcut.ts` comment is stale: `registerOverlayShortcuts()` returns `hasConfiguredOverlayShortcuts(shortcuts)`, so runtime registration is not hard-coded false.
Added exact, line-ending-agnostic prerelease tag trigger assertions in `src/prerelease-workflow.test.ts` and a regression asserting `bun run test:env` sits in the prerelease quality gate before source coverage.
Updated `.github/workflows/prerelease.yml` quality-gate to run `bun run test:env` after `bun run test:fast`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Assessed the latest CodeRabbit round for PR #49. Left the `overlay-shortcut.ts` thread open as stale with code evidence, tightened prerelease workflow trigger coverage, and added the missing `test:env` step to the prerelease quality gate. Verification: `bun test src/prerelease-workflow.test.ts`; `bun run typecheck`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,48 @@
---
id: TASK-286.3
title: 'Assess and address PR #49 latest CodeRabbit review round'
status: Done
assignee: []
created_date: '2026-04-12 03:08'
updated_date: '2026-04-12 03:09'
labels:
- bug
- code-review
- testing
dependencies: []
references:
- 'PR #49'
- .github/workflows/prerelease.yml
- src
parent_task_id: TASK-286
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Track the newest unresolved CodeRabbit review threads on PR #49 after commit 942c1649, fix the still-valid issues, verify them, and push the branch update.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 All still-actionable CodeRabbit comments in the newest PR #49 round are fixed or explicitly identified stale with evidence.
- [x] #2 Regression coverage is added or updated for behavior touched in this round.
- [x] #3 Relevant verification passes before commit and push.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Fetched the newest unresolved CodeRabbit threads for PR #49 after commit `942c1649`; only one unresolved actionable thread remained, on prerelease checksum output using repo-relative paths instead of asset basenames.
Added regression coverage in `src/prerelease-workflow.test.ts` and `src/release-workflow.test.ts` asserting checksum generation truncates to asset basenames and no longer writes the raw `sha256sum "${files[@]}" > release/SHA256SUMS.txt` form.
Updated both `.github/workflows/prerelease.yml` and `.github/workflows/release.yml` checksum generation steps to iterate over the `files` array and write `SHA256 basename` lines into `release/SHA256SUMS.txt`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Resolved the latest CodeRabbit round for PR #49 by fixing checksum generation to emit basename-oriented `SHA256SUMS.txt` entries in both prerelease and release workflows, with matching regression coverage. Verification: `bun test src/prerelease-workflow.test.ts src/release-workflow.test.ts`; `bun run typecheck`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,44 @@
---
id: TASK-287
title: Restore Lua parser compatibility for mpv plugin modules
status: Done
assignee: []
created_date: '2026-04-11 21:25'
updated_date: '2026-04-11 21:29'
labels:
- bug
- mpv-plugin
- lua
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Users with Lua runtimes that do not accept the current `goto continue` pattern in the mpv plugin should be able to load the plugin without syntax errors. Remove the parser-incompatible control-flow usage from the affected plugin modules without changing plugin behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 The mpv plugin source no longer relies on parser-incompatible `goto continue` labels in the affected Lua modules.
- [x] #2 Automated coverage fails on the old parser-incompatible source and passes once the compatibility fix is in place.
- [x] #3 Existing plugin start/gate verification still passes after the compatibility fix.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Reused existing local cleanups in `plugin/subminer/hover.lua` and `plugin/subminer/environment.lua` to remove `goto continue` / `::continue::` control flow without behavior changes.
Added `scripts/test-plugin-lua-compat.lua` and wired it into `test:plugin:src`; the regression checks reject the legacy pattern structurally and verify parse success with `luajit` when available.
Verification run on 2026-04-11: `lua scripts/test-plugin-lua-compat.lua` ✅, `bun run test:plugin:src` ✅, `bun run changelog:lint` ✅, `bun run typecheck` ✅, `bun run test:env` ✅, `bun run build` ✅, `bun run test:smoke:dist` ✅.
`bun run test:fast` remains red for unrelated existing immersion-tracker assertions in `src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts` and `src/core/services/immersion-tracker/__tests__/query.test.ts` (`tsMs`/`lastWatchedMs` observed as `-2147483648`).
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Removed parser-incompatible `goto continue` usage from the affected mpv Lua plugin modules, added a dedicated Lua compatibility regression script to the plugin test lane, and added a changelog fragment for the user-visible fix. Requested plugin verification is green; unrelated existing `test:fast` immersion-tracker failures remain outside this task.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,42 @@
---
id: TASK-288
title: Stabilize immersion-tracker CI timestamp handling under libsql/Bun
status: Done
assignee: []
created_date: '2026-04-11 21:34'
updated_date: '2026-04-11 21:43'
labels:
- bug
- ci
- immersion-tracker
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
`bun run test:fast` is currently failing because large millisecond timestamps are not handled safely through the libsql/Bun path. Fix timestamp parsing/storage so lifetime/library and session-event queries return correct wall-clock values in CI and runtime.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Large wall-clock timestamps round-trip correctly through immersion-tracker lifetime/library queries under the repo's Bun/libsql runtime.
- [x] #2 Session-event timestamps round-trip correctly for real wall-clock values used by runtime event inserts.
- [x] #3 Targeted immersion-tracker regression coverage passes, and the previously failing `test:fast` lane no longer fails on these timestamp assertions.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause split in two places: Bun/libsql corrupts large millisecond timestamp strings when coerced through `Number(...)`, and `imm_session_events.ts_ms` being `INTEGER` let runtime event inserts/readbacks return `-2147483648` on CI/runtime.
Fix shipped by parsing timestamp strings without the broken `Number(largeString)` path, migrating `imm_session_events.ts_ms` to `TEXT`, ordering/retention queries via `CAST(ts_ms AS REAL)`, and avoiding `Number(currentMs)` when reusing already-normalized timestamp strings.
Added regression coverage for both real runtime event inserts and schema migration/repair of previously truncated session-event rows.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed immersion-tracker timestamp handling under Bun/libsql so large wall-clock millisecond values survive runtime writes, query reads, and schema migration. `bun run test:fast`, `bun run typecheck`, `bun run test:env`, `bun run build`, `bun run test:smoke:dist`, and `bun run changelog:lint` all pass after the patch.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,33 @@
---
id: TASK-289
title: Finish current windows-qol rebase
status: Done
assignee: []
created_date: '2026-04-11 22:07'
updated_date: '2026-04-11 22:08'
labels:
- maintenance
- rebase
dependencies: []
references:
- /home/sudacode/projects/japanese/SubMiner
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Resolve the in-progress rebase on `windows-qol` and ensure the branch lands cleanly.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Interactive rebase completes without conflicts.
- [x] #2 Working tree is clean after the rebase finishes.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Completed the interactive rebase on `windows-qol` and resolved the transient editor-blocked `git rebase --continue` step. Branch now rebased cleanly onto `49e46e6b`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,56 @@
---
id: TASK-290
title: Cut stable release v0.12.0 on main
status: Done
assignee:
- codex
created_date: '2026-04-12 04:47'
updated_date: '2026-04-12 04:51'
labels: []
dependencies: []
documentation:
- docs/RELEASING.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Prepare the main branch for the stable SubMiner v0.12.0 release by applying the release-version updates, formatting changes required by the branch state, and rerunning the full release verification gate.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Main branch version and stable release metadata are updated for v0.12.0.
- [x] #2 Required formatting changes for the release candidate tree are applied and verified.
- [x] #3 The documented release verification gate passes locally and any remaining push or tag prerequisites are documented.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Audit main-branch release state: package version, release artifacts, current CI status, and current formatting debt.
2. Apply required formatting fixes to the files reported by `bun run format:check:src` and verify the formatting lane passes.
3. Update the package version to 0.12.0 and generate stable release metadata (`CHANGELOG.md`, `release/release-notes.md`, `docs-site/changelog.md`) using the documented release workflow.
4. Run the full local release gate on main (`changelog:lint`, `changelog:check --version 0.12.0`, `verify:config-example`, `typecheck`, `test:fast`, `test:env`, `build`, `docs:test`, `docs:build`, plus dist smoke) and document any remaining tag/push prerequisites.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Applied Prettier to all 39 files reported by `bun run format:check:src` on main and verified the formatting lane now passes.
Reapplied the stable changelog build entrypoint fix on main: added `writeStableReleaseArtifacts`, covered it with a focused regression test, and updated `package.json` so `changelog:build` forwards `--version` and `--date` through a single `build-release` command.
Verified the formatted mainline release tree with `bun run changelog:lint`, `bun run changelog:check --version 0.12.0`, `bun run verify:config-example`, `bun run typecheck`, `bun run test:fast`, `bun run test:env`, `bun run build`, `bun run docs:test`, `bun run docs:build`, and `bun run test:smoke:dist`; all passed.
Remote main CI also completed successfully for `Windows update (#49)` after the local release-prep pass. Remaining operational steps are commit/tag/push only.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Prepared `main` for the stable `v0.12.0` cut. Formatted the previously failing source files so `bun run format:check:src` is now clean, bumped `package.json` from `0.12.0-beta.3` to `0.12.0`, and generated the stable release artifacts with the explicit local cut date `2026-04-11`, which consumed the pending changelog fragments into `CHANGELOG.md`, `docs-site/changelog.md`, and `release/release-notes.md`.
Also reintroduced the release-script fix on main: the old `changelog:build` package script still used `build && docs`, which can drop `--version/--date` on the first step. Added a focused regression test in `scripts/build-changelog.test.ts`, implemented `writeStableReleaseArtifacts` in `scripts/build-changelog.ts`, and switched `package.json` to `build-release` so release flags propagate correctly. Verification on the final tree passed for formatting, changelog lint/check, config example verification, typecheck, fast tests, env tests, build, docs tests/build, dist smoke, and remote main CI. The branch is release-ready pending commit, tag, and push.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,43 @@
---
id: TASK-291
title: Add manual AniList selection for character dictionary resolution
status: Done
assignee:
- '@codex'
created_date: '2026-04-25 21:29'
updated_date: '2026-04-25 22:51'
labels:
- dictionary
- anilist
- cli
- ui
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add CLI and in-app UI support for correcting character dictionary anime resolution when guessit or AniList search picks the wrong series. Manual selections must apply to the whole detected series, persist across episodes, and replace stale incorrect entries in the auto-sync merged character dictionary state. Do not add tray UI. Known regression case: `Re - ZERO, Starting Life in Another World (2016) - S01E01 - - The End of the Beginning and the Beginning of the End [v2 Bluray-1080p Proper][10bit][x265][FLAC 2.0][EN+JA]-SCY.mkv` previously resolved to `10607 - Rerere no Tensai Bakabon`; it should be correctable to the chosen Re:ZERO AniList media and then reused for later files in that series.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 CLI can show AniList candidate matches for the current/target media and set a manual character-dictionary AniList override.
- [x] #2 In-app UI can show the current character-dictionary match, candidate matches, and apply an override without adding tray controls.
- [x] #3 Persisted overrides are keyed at series scope so all later episodes in the same series reuse the selected AniList media.
- [x] #4 Applying an override clears stale guess state, replaces the old incorrect active media entry in auto-sync state, rebuilds/imports the merged character dictionary, and refreshes subtitle dictionary usage.
- [x] #5 Regression tests cover the Re:ZERO filename, override reuse, stale active-media replacement, CLI handling, and IPC/UI contract behavior.
- [x] #6 Docs are updated for manual character dictionary anime selection.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add a focused override/resolution layer for character dictionary media selection: derive a stable series key from filename/guessit data, persist manual AniList media overrides under user data, and expose AniList candidate search helpers.
2. Update character dictionary snapshot resolution to check manual overrides before guessit-derived AniList search, and update auto-sync so applying an override removes stale incorrect media IDs and rebuilds/imports the merged dictionary.
3. Extend CLI with commands to list candidates and set an override for current or target media.
4. Extend existing in-app settings UI via IPC/preload contracts: show current match/candidates and let user apply an override. No tray controls.
5. Use TDD: add failing regressions first for Re:ZERO parsing/override behavior, auto-sync replacement, CLI handling, IPC contract, and UI state; then implement.
6. Update docs-site/manual docs for manual character dictionary anime selection, launcher usage, and the default `Ctrl+Alt+A` modal shortcut, then run focused tests and broader gates as time permits.
<!-- SECTION:PLAN:END -->
@@ -0,0 +1,52 @@
---
id: TASK-292
title: Restore Linux multi-subtitle copy digit capture
status: Done
assignee:
- '@codex'
created_date: '2026-04-25 21:31'
updated_date: '2026-04-25 21:36'
labels:
- bug
- linux
- shortcuts
- clipboard
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
On Linux, the copy-subtitle-multiple shortcut opens the numeric prompt but the follow-up digit is not captured, so the flow times out. User confirmed `wl-copy` itself is installed and working, so investigate the shortcut/digit capture path and restore multi-line subtitle copy without regressing existing session action behavior.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Linux copy-subtitle-multiple shortcut accepts a follow-up digit and copies that number of recent subtitle lines instead of timing out.
- [x] #2 The fix avoids depending on Linux Electron global shortcut digit registration for the follow-up numeric selection when a renderer-visible session can handle it.
- [x] #3 Regression tests cover the Linux multi-copy shortcut/digit flow and existing non-Linux/global shortcut behavior remains intact.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing regression coverage for Linux copy-subtitle-multiple local shortcut fallback starting renderer/session numeric selection instead of main-process digit globalShortcut capture.
2. Patch the overlay shortcut fallback/runtime path so Linux visible-overlay multi-copy and mine-sentence-multiple can dispatch session-action numeric selection when renderer handling is available, while preserving main-process numeric sessions for CLI/non-renderer paths.
3. Run targeted tests for shortcut fallback, overlay runtime, and renderer keyboard numeric selection; then run typecheck or a wider focused gate if needed.
4. Update task acceptance criteria/final notes after verification.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented the approved path by keeping multi-step numeric overlay shortcuts out of the main-process local fallback. The visible overlay now receives the original keydown and uses the existing renderer/session-action numeric selection flow for follow-up digits, avoiding Linux Electron globalShortcut digit capture for multi-copy and mine-sentence-multiple. Verification: targeted shortcut/renderer tests and changelog lint pass. `bun run typecheck` is currently blocked by unrelated existing errors in CLI/AniList dictionary-candidate work and `src/main/dependencies.ts` manual-selection API shape.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Restored Linux multi-line subtitle copy by preventing main-process overlay shortcut fallback from consuming multi-step numeric shortcuts (`copySubtitleMultiple` and `mineSentenceMultiple`). Those shortcuts now fall through to the visible overlay renderer, where the existing session binding flow prompts for a digit and dispatches the counted session action locally instead of relying on Electron globalShortcut digit registration. Added regression coverage for the fallback behavior and renderer follow-up digit dispatch, plus a changelog fragment.
Verification: `bun test src/core/services/overlay-shortcut-handler.test.ts src/renderer/handlers/keyboard.test.ts`; `bun run changelog:lint`. Full `bun run typecheck` was attempted but is blocked by unrelated current worktree errors in CLI/AniList dictionary-candidate tests/types and `src/main/dependencies.ts`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,25 @@
---
id: TASK-293
title: Fix interjection tokens receiving subtitle annotations
status: In Progress
assignee: []
created_date: '2026-04-25 22:50'
labels:
- tokenizer
- bug
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Standalone interjections such as あ should remain hoverable dictionary tokens but must not receive N+1, frequency, JLPT, or known-word subtitle annotation metadata.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 A MeCab 感動詞 token like あ is excluded by the shared subtitle annotation gate.
- [ ] #2 annotateTokens strips N+1/frequency/JLPT/known metadata from the interjection while preserving token lookup fields.
- [ ] #3 Focused tokenizer regression passes.
<!-- AC:END -->
@@ -0,0 +1,32 @@
---
id: TASK-294
title: Fix annotated subtitle tokens to honor subtitleStyle
status: Done
assignee: []
created_date: '2026-04-25 23:04'
updated_date: '2026-04-25 23:07'
labels:
- subtitles
- renderer
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Annotated token spans should inherit the configured subtitleStyle typography and only use annotation metadata to change token color.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Tokenized/annotated subtitles preserve configured base subtitle typography such as font family, size, weight, line height, letter spacing, text rendering, and text shadow.
- [x] #2 Known/N+1/JLPT/frequency/name-match annotations affect token color only, plus existing token metadata/hover affordances.
- [x] #3 A renderer regression test covers annotated token rendering with custom subtitleStyle.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Updated renderer subtitle annotation CSS so known/N+1/name/JLPT/frequency classes no longer override typography with token-specific shadows, underlines, padding, or hover font-weight. Added regression coverage using the user's custom subtitleStyle shape to verify annotated token spans inherit base typography and annotation CSS changes token color only. Verified with `bun test src/renderer/subtitle-render.test.ts`, `bun run typecheck`, and `bun run test:fast`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,66 @@
---
id: TASK-295
title: Add primary subtitle visibility keybinding
status: Done
assignee:
- Codex
created_date: '2026-04-25 23:09'
updated_date: '2026-04-25 23:45'
labels:
- renderer
- keybindings
- subtitles
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a `v` keybinding that overrides mpv's default `v` subtitle visibility toggle and instead toggles SubMiner's primary subtitle bar visibility on and off. Secondary subtitle hover behavior is out of scope.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Pressing `v` toggles the primary subtitle bar from visible to hidden.
- [x] #2 Pressing `v` again restores the primary subtitle bar visibility.
- [x] #3 The keybinding does not add or change secondary subtitle hover behavior.
- [x] #4 Relevant automated coverage verifies the toggle behavior.
- [x] #5 Pressing `v` in the mpv/plugin keybinding path also toggles the primary subtitle bar visibility instead of mpv native subtitle visibility.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Inspect existing renderer keybinding and subtitle bar visibility code, including current local edits in touched files.
2. Add a focused failing test for `v` toggling primary subtitle bar visibility without changing secondary hover behavior.
3. Implement the minimal renderer/keybinding change.
4. Run targeted tests and update acceptance criteria/final notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented renderer-local `KeyV` handling before session/mpv binding dispatch so mpv `sub-visibility` is not touched. Visibility state is stored in renderer state and applied via `primary-sub-hidden` class on the primary subtitle container.
Scope updated after user clarified the toggle must work when focus is in mpv as well as in the overlay renderer. Added a forced mpv plugin binding for `v` that runs `--toggle-primary-subtitle-bar`, then broadcasts a renderer IPC toggle event and reuses the same primary subtitle bar toggle path.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Added a renderer-local `v` key handler that toggles primary subtitle bar visibility by adding/removing `primary-sub-hidden` on the primary subtitle container.
- Added renderer state for the toggle so repeated presses restore the bar without issuing mpv `sub-visibility` commands.
- Added a forced mpv plugin `v` binding that invokes `--toggle-primary-subtitle-bar` and broadcasts the same renderer toggle event.
- Added CSS for the hidden primary subtitle bar state and regression coverage for both overlay and mpv/plugin entry points.
Tests:
- `bun test src/renderer/handlers/keyboard.test.ts --test-name-pattern "primary subtitle visibility key"`
- `bun test src/cli/args.test.ts --test-name-pattern "session action"`
- `bun test src/core/services/cli-command.test.ts --test-name-pattern "visibility and utility"`
- `bun test src/cli/args.test.ts src/cli/help.test.ts src/core/services/cli-command.test.ts src/main/runtime/cli-command-context.test.ts src/main/runtime/cli-command-context-deps.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/cli-command-context-factory.test.ts src/main/runtime/composers/cli-startup-composer.test.ts src/main/runtime/first-run-setup-service.test.ts`
- `lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-lua-compat.lua`
- `bun run typecheck`
- `bun run test:fast`
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,54 @@
---
id: TASK-296
title: Suppress crash notification when closing launcher-managed video
status: Done
assignee: []
created_date: '2026-04-25 23:12'
updated_date: '2026-04-26 02:44'
labels:
- bug
- launcher
- mpv
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix regression where closing a running mpv video causes SubMiner/Electron service crash notification (`<html><tt>/SubMiner</tt> has encountered a fatal error and was closed.</html>`). Not present on origin/main/v0.12.0 path.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Closing a launcher-managed video stops the overlay/app without desktop crash notification.
- [x] #2 Regression test covers the shutdown path that caused the notification.
- [x] #3 Relevant launcher/app tests pass.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Confirm notification source from local crash metadata and mpv/app logs.
2. Add regression coverage for mpv quit/shutdown lifecycle helper spawning.
3. Update mpv Lua lifecycle to avoid Electron helper subprocesses during quit/shutdown while preserving normal end-file hide behavior.
4. Run plugin tests and changelog lint.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Crash records in ~/.cache/drkonqi show SIGBUS in Electron NetworkService utility process for SubMiner AppImage. mpv log shows shutdown starts two `/home/sudacode/.local/bin/SubMiner.AppImage --hide-visible-overlay` subprocesses and kills them during close. Root cause is mpv plugin spawning Electron control helpers during quit/shutdown.
Follow-up after retest: installed plugin matched source patch and no close-time hide command was spawned. New mpv log shows the initial `/home/sudacode/.local/bin/SubMiner.AppImage --start ...` subprocess remains owned by mpv for the whole playback and is killed when mpv quits. New DrKonqi crash at 2026-04-25 16:44 again shows SIGBUS in Electron NetworkService from that AppImage mount. Need detach the long-lived plugin-launched `--start` app process from mpv.
Second fix: plugin-launched `--start` now includes `--background`, using SubMiner's existing background relaunch path so mpv owns only a short-lived starter process rather than the long-running Electron app. Ran `make install-plugin` so ~/.config/mpv/scripts/subminer now contains both lifecycle and background-start fixes. Full `scripts/test-plugin-start-gate.lua` is currently blocked by an unrelated dirty-worktree primary subtitle bar binding test from TASK-297.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Changed the mpv Lua lifecycle so `shutdown` no longer spawns a `--hide-visible-overlay` helper, and `end-file` skips that helper when mpv reports `reason = "quit"`. Also changed plugin-launched `--start` to include `--background`, so mpv owns only SubMiner's short background launcher process instead of the long-running Electron/AppImage process. This addresses both observed crash sources: close-time helper commands and mpv killing the main SubMiner child process at quit. Installed the updated plugin into `~/.config/mpv/scripts/subminer` with `make install-plugin`, and the user confirmed the latest close no longer produced the notification.
Tests: `lua scripts/test-plugin-start-gate.lua` initially proved the shutdown regression failed before the lifecycle fix; full start-gate is currently affected by other dirty work in this file. Passing checks for this commit: `lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-binary-windows.lua`; `bun run changelog:lint`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,32 @@
---
id: TASK-297
title: Fix subtitle annotation color priority after typography cleanup
status: Done
assignee: []
created_date: '2026-04-25 23:44'
updated_date: '2026-04-25 23:46'
labels:
- subtitles
- renderer
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Known-word and frequency subtitle token colors should keep their configured priority after annotation CSS stopped using JLPT underlines.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Known-word token color takes priority over JLPT and frequency color classes.
- [x] #2 Frequency single-mode token color takes priority over JLPT color classes when frequency annotation is active.
- [x] #3 Regression coverage verifies CSS selectors do not allow JLPT color rules to override higher-priority annotation colors.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Scoped JLPT token color selectors so they only apply when no higher-priority known-word, N+1, name-match, or frequency class is present. This keeps known words green and frequency single-mode tokens using the single frequency color instead of being visually overridden by JLPT colors. Added CSS regression assertions for this priority behavior. Verified with `bun test src/renderer/subtitle-render.test.ts`, `bun run typecheck`, and `bun run test:fast`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,54 @@
---
id: TASK-298
title: Exclude kana grammar-helper merges like ことに from subtitle annotations
status: Done
assignee:
- codex
created_date: '2026-04-26 00:08'
updated_date: '2026-04-26 00:15'
labels:
- tokenizer
- annotations
- bug
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix subtitle tokenizer annotation behavior where all-hiragana grammar-helper merged tokens such as `ことに` can be marked as N+1. Current likely path: Yomitan emits `ことに` with headword `こと`; MeCab enrichment supplies content-led POS (`名詞|助詞`, likely `非自立|格助詞`); shared subtitle annotation filter does not exclude this family unless it matches narrower rules such as `これで` or explanatory endings.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `ことに`-style kana grammar-helper merges are not marked known, N+1, JLPT, or frequency-highlighted when their MeCab metadata indicates a non-independent noun plus helper particle.
- [x] #2 Regression coverage demonstrates the reported subtitle phrase does not mark `ことに` as N+1 while preserving annotation for real lexical content tokens.
- [x] #3 Existing tokenizer annotation tests pass.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
Approved approach (user: "let's do it"):
1. Add a regression test for the reported `ことに` case using Yomitan token `ことに` -> headword `こと` and MeCab metadata `名詞|助詞` / `非自立|格助詞`; assert all annotation fields are stripped while nearby lexical content can still be N+1.
2. Verify the new test fails before production changes.
3. Update the shared subtitle annotation filter to exclude conservative kana-only grammar-helper merges: merged surface differs from headword, surface is kana-only, first POS component is `名詞`, first POS2 component is `非自立`, and remaining POS components are grammar helpers (`助詞`/`助動詞`).
4. Run targeted tokenizer/annotation tests and update the task acceptance criteria/final notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Red test initially passed with headword `こと` because `こと` is already in `JLPT_EXCLUDED_TERMS` and the shared subtitle annotation filter checks that set. Updated regression to the live-risk shape `surface=ことに`, `headword=事`, with MeCab POS `名詞|助詞` / `非自立|格助詞`; this failed before the filter change and passed after.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented a conservative shared subtitle annotation filter for kana-only non-independent noun helper merges. Tokens such as `ことに` with a kanji dictionary headword like `事` are now stripped of known-word, N+1, JLPT, and frequency metadata when MeCab shows the first component as `名詞/非自立` and trailing components as grammar helpers.
Added unit coverage in `src/core/services/tokenizer/annotation-stage.test.ts` and an integration-style tokenizer regression for the reported phrase shape in `src/core/services/tokenizer.test.ts`, verifying `ことに` stays plain while a real lexical token can still become the N+1 target.
Validation: `bun test src/core/services/tokenizer/annotation-stage.test.ts`; `bun test src/core/services/tokenizer.test.ts`; `bun run test:fast`; `bun run changelog:lint`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,66 @@
---
id: TASK-299
title: Force audio replacement during manual subtitle mining
status: Done
assignee:
- Codex
created_date: '2026-04-26 00:10'
updated_date: '2026-04-26 02:42'
labels:
- anki
- mining
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Manual subtitle mining via the Ctrl+C/Ctrl+V flow should replace expression and sentence audio fields even when the user has configured media overwrite fields to false. These fields can already contain proxy-inserted SubMiner audio on a new card, and manual update intent is to replace that generated content.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Manual subtitle mining replaces existing expression audio content regardless of configured audio overwrite settings.
- [x] #2 Manual subtitle mining replaces existing sentence audio content regardless of configured audio overwrite settings.
- [x] #3 Non-manual mining/update flows continue to respect configured audio overwrite settings.
- [x] #4 A regression test covers manual audio replacement when overwrite settings are disabled.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Locate the manual subtitle mining Ctrl+C/Ctrl+V flow and the Anki media field overwrite gate.
2. Add a failing regression test showing manual mining overwrites expression and sentence audio when configured audio overwrite is disabled.
3. Implement the smallest path-specific override so only manual subtitle mining forces audio replacement.
4. Run the focused mining test and update task acceptance criteria/final notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented focused manual clipboard update behavior in CardCreationService.updateLastAddedFromClipboard: generated manual audio is written to both resolved sentence audio and expression audio fields with forced overwrite. Other update flows still use existing overwrite config paths.
Verification: focused Anki tests passed; typecheck passed; changelog lint and diff check passed. Full bun run test:fast was attempted but is blocked by unrelated existing tokenizer annotation-stage failures tied to dirty task 298 worktree changes.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Manual clipboard subtitle updates now resolve both sentence audio and expression audio fields and replace both with the newly generated audio regardless of ankiConnect.behavior.overwriteAudio.
- Added a regression test for the Ctrl+C/Ctrl+V manual update path with existing proxy-inserted audio and overwriteAudio disabled.
- Registered the regression test in test:fast, documented the overwrite exception in user docs, and added a changelog fragment.
Verification:
- bun test src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/card-creation.test.ts
- bun run tsc --noEmit
- bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts
- bun run changelog:lint
- bun run docs:test
- bun run docs:build
- git diff --check
Blocked gate:
- bun run test:fast currently fails in unrelated src/core/services/tokenizer/annotation-stage.test.ts tests for kana-only non-independent noun helper merges; those files have pre-existing dirty changes outside this task.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,51 @@
---
id: TASK-300
title: Fix transparent subtitle hover background config
status: Done
assignee: []
created_date: '2026-04-26 03:23'
updated_date: '2026-04-26 03:26'
labels:
- bug
- overlay
- config
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
User reports setting subtitleStyle.hoverTokenBackgroundColor to transparent still renders default hover background in overlay subtitles.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Transparent hoverTokenBackgroundColor is accepted by config resolution.
- [x] #2 Renderer applies transparent hover token background instead of falling back to default.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce config alias behavior with a failing config test.
2. Map subtitleStyle.hoverBackground to hoverTokenBackgroundColor in config resolution while keeping canonical key precedence.
3. Add renderer regression for transparent hover token background CSS variable.
4. Update docs and changelog fragment; run focused verification.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Local user config used subtitleStyle.hoverBackground, which was ignored because only subtitleStyle.hoverTokenBackgroundColor was recognized. Canonical key still takes precedence when both are present.
Verification passed: bun run test:config:src; bun test src/renderer/subtitle-render.test.ts; bun run changelog:lint; bun run docs:test; bun run docs:build.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented config compatibility for transparent hover token backgrounds. `subtitleStyle.hoverBackground` now maps to the canonical `subtitleStyle.hoverTokenBackgroundColor` during resolution, preserving canonical key precedence. Added regression coverage for the alias and renderer handling of `transparent`, documented the alias, and added a changelog fragment.
Verification: `bun run test:config:src`; `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`; `bun run docs:test`; `bun run docs:build`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,55 @@
---
id: TASK-301
title: Fix launcher-managed video close leaving background app alive
status: Done
assignee:
- Codex
created_date: '2026-04-26 03:29'
updated_date: '2026-04-26 03:44'
labels:
- bug
- launcher
- mpv
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Launcher/plugin-managed video playback should not leave the SubMiner background app or tray icon running after the video closes unless the user explicitly launched SubMiner in background mode via --background or by starting with no app arguments. This is a regression after crash-avoidance work that added background startup for launcher-managed playback.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Closing a launcher-managed video exits the launcher-started SubMiner app/tray instead of leaving it alive.
- [x] #2 Explicit background launches still keep SubMiner alive after windows close.
- [x] #3 No-argument app startup behavior remains unchanged.
- [x] #4 Regression coverage exercises the launcher-managed playback shutdown lifecycle.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add regression coverage first: plugin auto-start should tag launcher-managed playback, and app mpv shutdown handling should quit only when started in that managed playback mode.
2. Add a narrow CLI flag/state field for launcher-managed playback, separate from explicit persistent background mode.
3. Have plugin pass the new flag with its background start command.
4. On mpv shutdown/disconnect, request app quit only when managed playback mode is active; preserve explicit --background and no-arg startup persistence.
5. Run focused plugin/app tests, then relevant launcher/core gates if feasible.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented managed playback shutdown by adding a `--managed-playback` app flag that the mpv plugin passes only for launcher-managed starts. The main mpv shutdown path now quits the app when initial args indicate managed playback, while explicit background/no-arg startup remains persistent. Added plugin start-gate and mpv protocol regression coverage.
Implemented managed playback lifecycle: mpv plugin auto-start passes --background --managed-playback; app quits on mpv shutdown only when initial args include managedPlayback. Explicit --background and no-arg startup remain persistent. Installed updated mpv plugin to ~/.config/mpv/scripts/subminer via make install-plugin.
Retest showed tray still remained. Root cause: relying on mpv's JSON IPC shutdown event was insufficient; the app may only see the socket close. Added managed-playback quit on MpvIpcClient onClose before reconnect scheduling, with regression coverage.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Launcher-managed playback now starts SubMiner with an internal --managed-playback marker alongside --background. The app requests quit either when mpv sends shutdown or when the mpv IPC socket closes, but only for managed playback mode; explicit background/no-arg startup remains persistent. Added CLI, mpv protocol, mpv socket-close, and plugin regression coverage plus a launcher changelog fragment. Rebuilt the app/launcher and confirmed focused checks, typecheck, build, plugin tests, dist smoke, and formatting.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,53 @@
---
id: TASK-302
title: Add visible hover affordance for annotated subtitle tokens
status: Done
assignee: []
created_date: '2026-04-26 03:39'
updated_date: '2026-04-26 03:40'
labels:
- overlay
- subtitle
- ux
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Annotated subtitle tokens keep annotation colors on hover, but with transparent hover backgrounds there is no visible hover indication. Add a subtle affordance that preserves annotation color semantics.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Annotated token hover keeps annotation color instead of switching to hover text color.
- [x] #2 Annotated token hover has a visible indication when hover background is transparent.
- [x] #3 Regression tests cover the hover CSS contract.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add failing CSS contract test for annotated token hover affordance.
2. Add brightness/saturation filter to annotated token hover CSS without changing annotation color.
3. Add changelog fragment and run focused verification.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented option 1 from approved design: annotated subtitle word hover keeps annotation color and adds `filter: brightness(1.18) saturate(1.08)` for visible affordance when the hover background is transparent.
Verification passed: `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Added a visible hover affordance for annotated subtitle tokens without changing annotation color semantics. Annotated word hover now applies a small brightness/saturation lift while retaining the existing background behavior, so transparent hover backgrounds still show feedback.
Regression coverage updated in `src/renderer/subtitle-render.test.ts` to assert the hover filter is present and that hover color overrides are still absent for annotated tokens. Added changelog fragment `changes/302-annotated-hover-affordance.md`.
Verification: `bun test src/renderer/subtitle-render.test.ts`; `bun run changelog:lint`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,57 @@
---
id: TASK-303
title: Update tray menu help action
status: Done
assignee:
- Codex
created_date: '2026-04-26 03:54'
updated_date: '2026-04-26 04:12'
labels:
- tray
- overlay
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Replace the tray menu's direct visible-overlay open action with an action that opens the existing in-session help modal. The tray should no longer expose an "Open Overlay" menu item; users should be able to open help from the tray instead.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Tray menu no longer includes an "Open Overlay" option.
- [x] #2 Tray menu includes an option to open the session help modal.
- [x] #3 Selecting the new tray help option initializes overlay runtime if needed and invokes the existing session help modal path.
- [x] #4 Focused regression tests cover the menu label and action wiring.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add focused regression coverage for tray menu template labels and main-process action wiring: assert Open Overlay is absent, Open Help is present, and clicking help initializes overlay runtime if needed before opening the existing session help modal path.
2. Update tray runtime action types/template to replace openOverlay with openSessionHelp.
3. Update tray main action builder dependencies to call the existing openSessionHelpModal function after overlay runtime initialization.
4. Run targeted tray tests, then broader relevant fast tests if needed.
5. Check acceptance criteria and finalize backlog notes.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented tray menu replacement via existing session help overlay path. Verification passed: targeted tray tests (`bun test src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts`), SubMiner verifier lanes `runtime-compat` and `docs`, and `bun run changelog:lint`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Replaced the tray menu's `Open Overlay` item with `Open Help`, wired it to initialize overlay runtime when needed, and then open the existing session help modal path. Updated tray runtime/main-deps/action tests to assert the old label is absent, the new label is present, and the new action calls the help modal. Added changelog fragment `changes/303-tray-help-menu.md`.
Verification:
- `bun test src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts`
- `bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane runtime-compat --lane docs src/main/runtime/tray-runtime.ts src/main/runtime/tray-main-actions.ts src/main/runtime/tray-main-deps.ts src/main.ts changes/303-tray-help-menu.md`
- `bun run changelog:lint`
Verifier artifacts: `.tmp/skill-verification/subminer-verify-20260425-211156-9fkdDf/`.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,54 @@
---
id: TASK-306
title: Separate background stats daemon from regular SubMiner app
status: Done
assignee: []
created_date: '2026-04-27 00:56'
updated_date: '2026-04-27 01:00'
labels:
- stats
- runtime
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Background stats mode should run only the stats data/server pieces. It must not bring up tray UI or expose the regular mpv connection surface, and stopping should remain CLI-only.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Launching stats background mode starts a separate stats daemon process rather than booting the regular SubMiner runtime.
- [x] #2 Background stats mode does not create or keep a tray icon.
- [x] #3 Background stats mode does not start mpv IPC/client surfaces that let mpv connect to the app.
- [x] #4 Background stats mode remains stoppable through the stats stop command line path.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add entry-runtime tests for public stats background/stop daemon detection.
2. Implement early public stats daemon command detection and route it before regular app boot.
3. Run targeted tests and update task status/criteria.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented early public stats daemon routing in main-entry runtime. Direct `--stats-background` and `--stats-stop` now resolve to daemon control before single-instance lock and before loading `main.js`, matching the existing internal launcher daemon flags. Installed missing Bun dependencies to run targeted tests.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Added `resolveStatsDaemonCommandAction` and updated entry detection so public `--stats-background` / `--stats-stop` invocations route through the isolated stats daemon control path.
- Reused that action resolution in `stats-daemon-entry` so public stop commands map to stop instead of the default start path.
- Added regression coverage for public daemon detection/action resolution.
Verification:
- `bun test src/main-entry-runtime.test.ts launcher/commands/command-modules.test.ts src/main/runtime/stats-cli-command.test.ts src/stats-daemon-control.test.ts`
- `bun run typecheck`
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,58 @@
---
id: TASK-307
title: Defer in-app stats server to running background stats daemon
status: Done
assignee: []
created_date: '2026-04-27 01:57'
updated_date: '2026-04-27 02:02'
labels:
- stats
- runtime
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
When normal SubMiner app startup has stats auto-start enabled, it should detect an already-running background stats daemon and avoid starting a second in-app stats server. Stats overlay/dashboard URL resolution should point at the background daemon.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 If a live background stats daemon state exists for another process, in-app stats auto-start does not start a local stats server.
- [x] #2 Stats URL resolution returns the background daemon URL when the background daemon is live.
- [x] #3 Stale or dead background daemon state is cleared and normal in-app stats startup still works.
- [x] #4 Regression tests cover the deferral behavior.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Add unit tests for stats server routing decisions around live/stale background daemon state.
2. Implement a small routing helper used by main stats startup.
3. Wire `ensureStatsServerStarted()` through the helper.
4. Run targeted tests/typecheck/changelog lint and finalize the task.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Extracted stats server URL routing into `src/main/runtime/stats-server-routing.ts` and wired `main.ts` through it. The helper returns the background daemon URL without calling local server startup when a live external daemon exists; dead/self-owned stale state is removed before falling back to local startup. Added the new test to `test:core:src`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Summary:
- Added a pure stats server routing helper that chooses between a live background daemon and local in-app stats server startup.
- Updated main stats URL resolution to defer to another process's background daemon and only start the in-app server when no live daemon is available.
- Added regression tests for live daemon deferral, dead daemon cleanup, self-owned stale state cleanup, and local server reuse.
- Added the routing test to the core source test lane and added a changelog fragment.
Verification:
- `bun test src/main/runtime/stats-server-routing.test.ts src/main-entry-runtime.test.ts src/main/runtime/stats-cli-command.test.ts src/stats-daemon-control.test.ts`
- `bun run test:core:src`
- `bun run typecheck`
- `bun run changelog:lint`
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,28 @@
---
id: TASK-313
title: Fix mpv buffering reload overlay lifecycle
status: To Do
assignee: []
created_date: '2026-05-02 22:12'
labels:
- bug
- mpv
- overlay
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
macOS local playback can emit an mpv reload/end-file/file-loaded sequence during buffering. SubMiner should treat same-media reload churn as a continuation, not a fresh playback session, so the visible overlay remains available and startup-only tokenization/AniSkip work is not repeated unnecessarily.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 Same-media mpv reload buffering does not hide the visible overlay.
- [ ] #2 Same-media mpv reload buffering does not re-arm the pause-until-ready startup gate or wait for a second tokenization-ready signal.
- [ ] #3 Same-media mpv reload buffering does not repeat AniSkip lookup work for the already-loaded media.
- [ ] #4 Normal new-file playback still clears per-media state, applies managed subtitle defaults, auto-starts/updates the overlay, and runs needed startup checks.
- [ ] #5 Regression coverage exercises the buffering reload/end-file/file-loaded sequence in the mpv plugin lifecycle.
<!-- AC:END -->
@@ -0,0 +1,35 @@
---
id: TASK-317
title: Add browser open affordance for texthooker
status: Done
assignee: []
created_date: '2026-05-03 02:02'
updated_date: '2026-05-03 02:21'
labels:
- feature
- texthooker
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add a `-o` flag to the texthooker subcommand to open the texthooker page in the user's default browser, and add a tray app option that triggers the same behavior. Implement with tests and existing launcher/tray patterns.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 `texthooker -o` starts/targets the texthooker page and opens it in the default browser.
- [x] #2 Tray app exposes a menu option to open the texthooker page in the default browser.
- [x] #3 Existing texthooker behavior without `-o` remains unchanged.
- [x] #4 Relevant CLI/tray behavior covered by tests.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented `subminer texthooker -o` by parsing the launcher subcommand flag, forwarding `--open-browser` to the app texthooker command, and allowing that app arg to force browser opening even when `texthooker.openBrowser` is false. Added an `Open Texthooker` tray menu item wired through the same CLI command path. Updated docs-site usage/launcher/API docs and added a changelog fragment. Verification: targeted CLI/tray tests passed; `bun run typecheck` passed; `bun run docs:test` passed; `bun run changelog:lint` passed; `bun run test:env` passed; `bun run build` passed; `bun run test:smoke:dist` passed; `bun run docs:build` passed after installing docs-site deps. `bun run test:fast` is blocked by an existing broader-suite failure in `runSubsyncManual writes deterministic _retimed filename when replace is false` (`window.electronAPI` undefined), followed by Bun nested-test cascade errors.
Follow-up fix: `subminer texthooker -o` now opens `http://127.0.0.1:5174` from the launcher after a successful texthooker app handoff, so it works even when the installed SubMiner app binary does not yet understand the app-side `--open-browser` flag. Reproduced the reported behavior; confirmed the texthooker server was running at `127.0.0.1:5174`; added a launcher regression asserting the browser URL is opened. Verification: `bun test launcher/mpv.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts src/core/services/cli-command.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts` passed; `bun run typecheck` passed; `bun run build:launcher` passed.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,38 @@
---
id: TASK-325
title: Fix session chart known-word percentage denominator
status: Done
assignee: []
created_date: '2026-05-04 01:19'
updated_date: '2026-05-04 01:23'
labels:
- stats
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Session detail known-word percentages should use the same filtered vocabulary occurrence rows for both known and total word counts. Current chart can divide known persisted word occurrences by raw token totals, causing excluded tokens to depress the known percentage.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Session known-word timeline API exposes cumulative filtered total word counts alongside known counts.
- [x] #2 Session detail chart computes known/unknown areas from filtered totals, not raw timeline token counts, when known-word data is available.
- [x] #3 Session summary known-word rate uses filtered persisted word totals where available and preserves safe fallback behavior when known-word data is unavailable.
- [x] #4 Regression tests cover filtered denominator behavior for the API and chart data path.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented in-place fix using existing persisted word occurrence rows. `/api/stats/sessions/:id/known-words-timeline` now returns cumulative `totalWordsSeen` from filtered persisted occurrences, and session known-word rates divide by the same filtered total. Session detail chart builds known/unknown areas from `totalWordsSeen` instead of raw timeline `tokensSeen`.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Known-word percentages on session charts now use filtered persisted word totals for both numerator and denominator. No migration/backfill required; data comes from existing `imm_word_line_occurrences`. Added regression coverage for the API response/rate and chart data builder.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,42 @@
---
id: TASK-326
title: Make stats word metrics honor filtering rules
status: Done
assignee: []
created_date: '2026-05-04 01:35'
updated_date: '2026-05-04 02:08'
labels:
- stats
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Audit stats app metrics that show or derive from word totals and make them use filtered persisted vocabulary occurrences where the UI concept is learned/seen words. Preserve raw telemetry only where it is intentionally playback/token telemetry.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Stats UI word totals, word rates, lookup-per-word rates, and chart word series use filtered persisted word occurrences where available.
- [x] #2 Known-word metrics continue to use the same filtered denominator as known counts.
- [x] #3 Trend, overview, library, session, and episode surfaces are audited with regression coverage for changed data paths.
- [x] #4 Fallback behavior remains safe for sessions without persisted vocabulary occurrences.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Audit finding: raw `tokensSeen` / `totalTokensSeen` still feeds overview hints, dashboard aggregation, trends activity/progress/anime cumulative/library summary, lookup-per-100-word rates, session rows/recent sessions/episode sessions, and library/anime/media headers. Vocabulary and known unique word summaries already use persisted filtered vocabulary rows. Recommended design: query-time filtered word totals from existing `imm_word_line_occurrences`, with raw-token fallback only when a session has no persisted occurrence rows.
Implemented shared query-time filtered word counts. Session summaries, overview hints, daily/monthly rollups, anime/media library/detail rows, anime episode rows, episode/media sessions, trends activity/progress/anime cumulative, library summary, and lookup-per-100-word ratios now use filtered persisted word occurrences. Fallback remains raw token totals only for sessions with no persisted subtitle-line rows.
Follow-up implemented: Vocab frequency tables now apply the same tokenizer vocabulary predicate at read time, because old `imm_words` rows can predate current tokenizer exclusion rules. Vocabulary persistence and cleanup also mirror the broader subtitle-annotation grammar filters. Added common frequency stop terms observed in the stats vocabulary list to the shared tokenizer exclusion set so those rows are filtered consistently across subtitle annotations, persistence, cleanup, stats reads, and SQL word-count aggregates.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Stats word metrics now honor filtering rules through the read-model query layer. Existing persisted `imm_word_line_occurrences` provide the filtered denominator; no migration/backfill needed. Vocab tables filter stored rows on read using tokenizer vocabulary rules, so legacy noisy rows stop appearing without a migration. Added regressions for session/overview/rollup fallback behavior, trends/library lookup-rate behavior, vocabulary read filtering, cleanup filtering, and shared stop-term filtering.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,42 @@
---
id: TASK-327
title: Persist stats page exclusion list in database
status: Done
assignee: []
created_date: '2026-05-04 01:39'
updated_date: '2026-05-04 01:49'
labels:
- feature
- stats
- database
dependencies: []
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add database-backed persistence for the stats page exclusion list. On first load with the new schema, seed the new table from the existing exclusion list source so existing user choices are preserved. After migration, update database rows whenever the exclusion list is changed or saved so it persists across browser sessions indefinitely.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 A new small database table stores stats page exclusion entries.
- [x] #2 First load with the new schema seeds the table from the existing exclusion list source.
- [x] #3 Subsequent exclusion list save/change operations update the database-backed list.
- [x] #4 Regression coverage verifies migration/seed behavior and persistence updates.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Implemented DB-backed stats exclusion list using schema version 18 and new `imm_stats_excluded_words` table. Added read/replace query helpers, service methods, and `/api/stats/excluded-words` GET/PUT routes. Stats frontend now loads exclusions from DB, seeds the empty DB table from legacy `localStorage` on first load, and writes each toggle/restore/clear through the API while keeping localStorage in sync for compatibility. Added focused regression coverage for schema/read-replace, API routes, API client, and frontend bootstrap/update behavior. Verification: `bun run typecheck` passed; `bun test src/core/services/__tests__/stats-server.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts` passed; `bun test src/core/services/immersion-tracker/storage-session.test.ts` passed; `bun run docs:test` passed; `bun run format:check:stats` passed; `bun run changelog:lint` passed. Blocked/unrelated: `bun run typecheck:stats` fails in existing stats files (`AnilistSelector.tsx`, `reading-utils*`, `session-grouping.test.ts`, `yomitan-lookup.test.tsx`); `bun run test:immersion:sqlite:src` fails existing `recordSubtitleLine counts exact Yomitan tokens for session metrics` expected 4 got 3; `bun run docs:build` fails missing `@catppuccin/vitepress/theme/macchiato/mauve.css` import.
Added `src/core/services/__tests__/stats-server.test.ts` and `stats/src/hooks/useExcludedWords.test.ts` to the `test:core:src` allowlist so the new DB exclusion route/client/store regressions run in the maintained fast source lane.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Persisted the stats vocabulary exclusion list in SQLite with new schema version 18 table `imm_stats_excluded_words`. Added backend read/replace helpers and `/api/stats/excluded-words` GET/PUT routes, then wired the stats frontend exclusion store to load DB rows, seed an empty DB from legacy browser localStorage on first load, and update the DB on toggle/restore/clear. Updated docs and added changelog fragment. Focused tests and root typecheck pass; broader stats/docs/sqlite gates are blocked by unrelated existing failures recorded in notes.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,43 @@
---
id: TASK-329
title: Keep JLPT subtitle styling underline-only
status: Done
assignee: []
created_date: '2026-05-04 02:13'
labels:
- bug
- renderer
- jlpt
dependencies: []
references:
- src/renderer/style.css
- src/renderer/subtitle-render.test.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Fix subtitle token styling so JLPT metadata never changes token text color. JLPT should only render the level marker/underline affordance while known, n+1, name-match, and frequency colors retain priority.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 JLPT-only subtitle tokens do not set token text color.
- [x] #2 JLPT level marker/underline still uses configured JLPT color.
- [x] #3 Existing known, n+1, name-match, and frequency text colors remain unchanged.
<!-- AC:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Changed subtitle JLPT styling from text color to underline decoration and updated renderer CSS regression coverage.
Verification:
- `bun test src/renderer/subtitle-render.test.ts`
- `bunx prettier --check src/renderer/subtitle-render.test.ts src/renderer/style.css`
- `bun run typecheck`
Blocked:
- `bun run test:fast` fails in existing dirty stats/session work: `recordSubtitleLine counts exact Yomitan tokens for session metrics` expects `tokensSeen` 4 but gets 3.
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -0,0 +1,70 @@
---
id: TASK-330
title: Fix PR 60 CI failures and CodeRabbit feedback
status: Done
assignee:
- codex
created_date: '2026-05-04 02:50'
updated_date: '2026-05-04 02:59'
labels:
- ci
- pr-review
dependencies: []
references:
- 'https://github.com/ksyasuda/SubMiner/pull/60'
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Resolve failing GitHub Actions checks and actionable unresolved CodeRabbit review feedback on PR #60 (Persist stats exclusions in DB and fix word metrics filtering). Keep fixes scoped to the PR behavior and preserve existing project patterns.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Failing GitHub Actions checks for PR #60 have an identified root cause and local fix.
- [x] #2 All actionable unresolved CodeRabbit review comments on PR #60 are addressed locally or explicitly documented as non-actionable.
- [x] #3 Relevant local verification passes for the changed code paths.
- [x] #4 Task notes summarize CI failure context, review-comment handling, and any residual verification gaps.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Resolve PR #60 context and inspect GitHub Actions failures with the gh-fix-ci workflow.
2. Fetch unresolved review threads with the gh-address-comments workflow, focusing on CodeRabbit actionable comments.
3. Read the touched files/tests around the failing paths and comments; identify root cause before edits.
4. Apply minimal fixes with regression coverage where appropriate.
5. Run targeted verification first, then broader repo gates as time permits.
6. Update Backlog notes/acceptance criteria with CI/comment outcomes and residual risks.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Resolved PR #60 CI failure by restoring raw `tokensSeen` for session summaries while keeping filtered persisted word counts in aggregate/known-word paths. Addressed CodeRabbit feedback: fixed missing `headword` test fixture binding; paged vocabulary stats past filtered rows; preserved lifetime/rollup totals when retained-session recomputation is partial; emitted flat known-word timeline points for zero-visible-word line gaps; restored localStorage mocks; added rollback/retry behavior for excluded-word store persistence/initialization.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Fixed the PR #60 CI failure and addressed actionable CodeRabbit feedback.
Key changes:
- Restored exact Yomitan token counts for session summary metrics while leaving filtered word counts for aggregate and known-word calculations.
- Fixed malformed query test fixtures by binding `headword` into `imm_words` inserts.
- Updated vocabulary stats to page until enough visible rows are collected after post-query filtering.
- Made library/detail/rollup read models preserve lifetime or stored rollup totals when retained-session recomputation is partial, including dashboard rollup-derived word metrics.
- Kept known-word timeline line positions stable by emitting flat points for missing line indexes.
- Made excluded-word persistence rollback on failed writes, allow initialization retries after transient load failures, and restored mocked `localStorage` in tests.
Verification passed:
- `bun run typecheck`
- `bun run test:fast`
- `bun run test:env`
- `bun run build`
- `bun run test:smoke:dist`
- `bun run format:check:src`
- `git diff --check`
<!-- SECTION:FINAL_SUMMARY:END -->
@@ -1,5 +0,0 @@
type: internal
area: release
- Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR.
- Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut.
@@ -0,0 +1,5 @@
type: added
area: dictionary
- Added CLI and in-app AniList selection for character dictionary mismatches, with series-scoped overrides that replace stale wrong-title entries in the merged dictionary.
- Added launcher support through `subminer dictionary --candidates` and `subminer dictionary --select`, plus a default `Ctrl+Alt+A` shortcut for the in-app selector.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed Linux multi-line subtitle copy timing out after the prompt by letting the overlay handle the follow-up digit locally.
@@ -0,0 +1,4 @@
type: fixed
area: tokenizer
- Stopped standalone `あ` interjections from receiving subtitle annotation metadata such as N+1, JLPT, and frequency highlighting when POS tags are unavailable.
@@ -0,0 +1,4 @@
type: added
area: overlay
- Added a `V` shortcut and mpv plugin binding to toggle the SubMiner primary subtitle bar without changing mpv native subtitle visibility.
@@ -0,0 +1,4 @@
type: fixed
area: mpv
- Stopped mpv from owning long-running SubMiner AppImage subprocesses during playback shutdown, preventing desktop crash notifications when closing video.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Fixed annotated subtitle token colors so `subtitleStyle` typography is preserved and higher-priority known-word/frequency colors are not overridden by JLPT colors.
@@ -0,0 +1,4 @@
type: fixed
area: tokenizer
- Stopped kana-only grammar-helper merges such as `ことに` from receiving subtitle annotation metadata like N+1, JLPT, known-word, or frequency highlighting.
@@ -0,0 +1,4 @@
type: fixed
area: anki
- Anki: Manual clipboard subtitle updates now replace both expression and sentence audio fields even when configured audio overwrite is disabled.
@@ -0,0 +1,4 @@
type: fixed
area: config
- Accepted `subtitleStyle.hoverBackground` as an alias for `subtitleStyle.hoverTokenBackgroundColor`, so setting it to `transparent` removes hover token backgrounds.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: launcher
- Launcher-managed playback now exits the background SubMiner app when the video closes, while explicit background launches stay persistent.
@@ -0,0 +1,4 @@
type: fixed
area: overlay
- Added a subtle brightness lift for annotated subtitle token hover states so transparent hover backgrounds still show a visible hover affordance.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: tray
- Tray: Replaced the Open Overlay tray menu item with Open Help, which opens the session help modal.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- Stats background mode now routes through the isolated stats daemon instead of starting the regular SubMiner app runtime.
+4
View File
@@ -0,0 +1,4 @@
type: fixed
area: stats
- In-app stats startup now defers to an already-running background stats daemon instead of starting a second stats server.
+5
View File
@@ -0,0 +1,5 @@
type: internal
area: release
- Replaced the changelog renderer with a `claude -p` polish pass that merges related fragments, drops PR housekeeping, and writes user-friendly release notes. CHANGELOG.md keeps internal items in a collapsed `<details>` block; the GitHub release notes drop them entirely.
- Removed the release CI auto-build for pending `changes/*.md` fragments. Tag-based release runs now fail fast with a clear error if fragments are still pending; build the changelog locally with `bun run changelog:build` (which requires the `claude` CLI on PATH) and commit before tagging.
@@ -0,0 +1,4 @@
type: fixed
area: mpv
- Kept the visible overlay alive across same-media mpv reloads during buffering, avoiding duplicate startup gates and AniSkip lookups.
+5
View File
@@ -0,0 +1,5 @@
type: fixed
area: jellyfin
- Improved Jellyfin setup with recent server selection and inline authentication feedback.
- Added a tray Jellyfin Discovery toggle for runtime-only cast discovery.
+4
View File
@@ -0,0 +1,4 @@
type: added
area: texthooker
- Texthooker: Added `subminer texthooker -o` and a tray menu item to open the local texthooker page in the default browser.
+4
View File
@@ -0,0 +1,4 @@
type: changed
area: stats
- Stats vocabulary exclusions now persist in the immersion database and import existing browser-local exclusions on first load.
+6
View File
@@ -31,6 +31,12 @@ Rules:
- `README.md` is ignored by the generator - `README.md` is ignored by the generator
- if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment - if a PR should not produce release notes, apply the `skip-changelog` label instead of adding a fragment
How fragments turn into a release:
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
Prerelease notes: Prerelease notes:
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md` - prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible.
-11
View File
@@ -1,11 +0,0 @@
type: fixed
area: overlay
- Fixed Windows overlay z-order so the visible subtitle overlay stops staying above unrelated apps after mpv loses focus.
- Fixed Windows overlay tracking to use native window polling and owner/z-order binding, which keeps the subtitle overlay aligned to the active mpv window more reliably.
- Fixed Windows overlay hide/restore behavior so minimizing mpv immediately hides the overlay and restoring mpv brings it back on top of the mpv window without requiring a click.
- Fixed stats overlay layering so the in-player stats page now stays above mpv and the subtitle overlay while it is open.
- Fixed Windows subtitle overlay stability so transient tracker misses and restore events keep the current subtitle visible instead of waiting for the next subtitle line.
- Fixed Windows focus handoff from the interactive subtitle overlay back to mpv so the overlay no longer drops behind mpv and briefly disappears.
- Fixed Windows visible-overlay startup so it no longer briefly opens as an interactive or opaque surface before the tracked transparent overlay state settles.
- Fixed spurious auto-pause after overlay visibility recovery and window resize so the overlay no longer pauses mpv until the pointer genuinely re-enters the subtitle area.
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed Windows secondary subtitle hover mode so the expanded hover hit area no longer blocks the native minimize, maximize, and close buttons.
@@ -1,4 +0,0 @@
type: fixed
area: overlay
- Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv.
+7 -1
View File
@@ -172,8 +172,13 @@
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting. "openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
"openControllerSelect": "Alt+C", // Open controller select setting.
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
@@ -478,6 +483,7 @@
"jellyfin": { "jellyfin": {
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Device id setting. "deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", // Client name setting. "clientName": "SubMiner", // Client name setting.
+2
View File
@@ -213,6 +213,8 @@ Animated AVIF requires an AV1 encoder (`libaom-av1`, `libsvtav1`, or `librav1e`)
} }
``` ```
`overwriteAudio` applies to automatic card updates and duplicate-card enrichment. Manual clipboard subtitle updates (`Ctrl/Cmd+C`, then `Ctrl/Cmd+V`) always replace generated audio in both the expression audio field and sentence audio field.
## AI Translation ## AI Translation
SubMiner can auto-translate the mined sentence and fill the translation field. SubMiner can auto-translate the mined sentence and fill the translation field.
+48 -4
View File
@@ -1,6 +1,50 @@
# Changelog # Changelog
## v0.11.2 (2026-04-07) ## v0.12.0 (2026-04-11)
**Changed**
- Overlay: Added configurable overlay shortcuts for session help, controller select, and controller debug actions.
- Overlay: Added mpv/plugin and CLI routing for session help, controller utilities, and subtitle sidebar toggling through the shared session-action path.
- Overlay: Added a `V` shortcut and mpv plugin binding to toggle the SubMiner primary subtitle bar instead of mpv's native primary subtitle visibility.
- Overlay: Improved dedicated overlay modal retry and focus handling for runtime options, Jimaku, session help, controller tools, and the playlist browser.
- Overlay: Fixed controller configuration and controller debug shortcut opens so configured bindings bring up their modals again instead of tripping renderer recovery.
- Stats: Sessions are rolled up per episode within each day, with a bulk delete that wipes every session in the group.
- Stats: Trends add a 365-day range next to the existing 7d/30d/90d/all options.
- Stats: Library detail view gets a delete-episode action that removes the video and all its sessions.
- Stats: Vocabulary Top 50 tightens the word/reading column so katakana entries no longer push the scores off screen.
- Stats: Episode detail hides card events whose Anki notes have been deleted, instead of showing phantom mining activity.
- Stats: Trend and watch-time charts share a unified theme with horizontal gridlines and larger ticks for legibility.
- Stats: Overview, Library, Trends, Sessions, and Vocabulary now use generic "title" wording so YouTube videos and anime live comfortably side by side in the dashboard.
- Stats: Session timeline no longer plots seek-forward/seek-backward markers — they were too noisy on sessions with lots of rewinds.
- Stats: Replaced the "Library — Per Day" section on the Stats → Trends page with a "Library — Summary" section. The new section shows a top-10 watch-time leaderboard chart and a sortable per-title table (watch time, videos, sessions, cards, words, lookups, lookups/100w, date range), all scoped to the current date range selector.
**Fixed**
- Overlay: Fixed overlay drag-and-drop routing so dropping external subtitle files like `.ass` onto mpv still loads them when the overlay is visible.
- Overlay: Addressed the latest CodeRabbit follow-ups on PR #49, including generation-scoped Lua session binding names, stricter session command validation, session-help shortcut visibility, the numeric-selection key guard, stats-overlay startup classification, and safer session-binding persistence.
- Overlay: Addressed the latest CodeRabbit follow-ups on the Windows overlay flow, including exact mpv target resolution, lower-overlay helper arguments, Win32 failure detection, and overlay cleanup on tracker loss.
- Overlay: Fixed Windows overlay z-order so the visible subtitle overlay stops staying above unrelated apps after mpv loses focus.
- Overlay: Fixed Windows overlay tracking to use native window polling and owner/z-order binding, which keeps the subtitle overlay aligned to the active mpv window more reliably.
- Overlay: Fixed Windows overlay hide/restore behavior so minimizing mpv immediately hides the overlay and restoring mpv brings it back on top of the mpv window without requiring a click.
- Overlay: Fixed stats overlay layering so the in-player stats page now stays above mpv and the subtitle overlay while it is open.
- Overlay: Fixed Windows subtitle overlay stability so transient tracker misses and restore events keep the current subtitle visible instead of waiting for the next subtitle line.
- Overlay: Fixed Windows focus handoff from the interactive subtitle overlay back to mpv so the overlay no longer drops behind mpv and briefly disappears.
- Overlay: Fixed Windows visible-overlay startup so it no longer briefly opens as an interactive or opaque surface before the tracked transparent overlay state settles.
- Overlay: Fixed spurious auto-pause after overlay visibility recovery and window resize so the overlay no longer pauses mpv until the pointer genuinely re-enters the subtitle area.
- Overlay: Fixed Windows secondary subtitle hover mode so the expanded hover hit area no longer blocks the native minimize, maximize, and close buttons.
- Overlay: Fixed Windows Yomitan popup focus loss after closing nested lookups so the original popup stays interactive instead of falling through to mpv.
- Stats: Fixed immersion-tracker timestamp handling under Bun/libsql so library rows, session timelines, and lifetime summaries keep real wall-clock millisecond values instead of truncating to invalid negative timestamps.
- Mpv Plugin: Fixed the mpv Lua plugin so hover and environment modules no longer use the `goto continue` pattern that can fail to parse on some user Lua runtimes.
**Internal**
- Release: Added a dedicated beta/rc prerelease GitHub Actions workflow that publishes GitHub prereleases without consuming pending changelog fragments or updating AUR.
- Release: Added prerelease note generation so beta and release-candidate tags can reuse the current pending `changes/*.md` fragments while leaving stable changelog publication for the final release cut.
## Previous Versions
<details>
<summary>v0.11.x</summary>
<h2>v0.11.2 (2026-04-07)</h2>
**Changed** **Changed**
- Launcher: Replaced the launcher-only fullscreen toggle with `mpv.launchMode` so SubMiner-managed mpv playback can start in normal, maximized, or fullscreen mode. - Launcher: Replaced the launcher-only fullscreen toggle with `mpv.launchMode` so SubMiner-managed mpv playback can start in normal, maximized, or fullscreen mode.
@@ -10,13 +54,13 @@
- Launcher: Local playback now promotes a single unlabeled external subtitle sidecar to the primary slot instead of leaving mpv's embedded English auto-selection in place. - Launcher: Local playback now promotes a single unlabeled external subtitle sidecar to the primary slot instead of leaving mpv's embedded English auto-selection in place.
- Release: Fixed Linux AppImage startup packaging so Chromium child relaunches can resolve the bundled `libffmpeg.so` instead of crash-looping on startup. - Release: Fixed Linux AppImage startup packaging so Chromium child relaunches can resolve the bundled `libffmpeg.so` instead of crash-looping on startup.
## v0.11.1 (2026-04-04) <h2>v0.11.1 (2026-04-04)</h2>
**Fixed** **Fixed**
- Release: Linux packaged builds now expose the canonical `SubMiner` app identity to Electron's startup metadata so native Wayland compositors stop reporting the window class/app-id as lowercase `subminer`. - Release: Linux packaged builds now expose the canonical `SubMiner` app identity to Electron's startup metadata so native Wayland compositors stop reporting the window class/app-id as lowercase `subminer`.
- Linux: Linux now restores the runtime options, Jimaku, and Subsync shortcuts after the Electron 39 regression by routing those actions through the overlay's mpv/IPC shortcut path. - Linux: Linux now restores the runtime options, Jimaku, and Subsync shortcuts after the Electron 39 regression by routing those actions through the overlay's mpv/IPC shortcut path.
## v0.11.0 (2026-04-03) <h2>v0.11.0 (2026-04-03)</h2>
**Added** **Added**
- Overlay: Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback. - Overlay: Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
@@ -69,7 +113,7 @@
- Release: Kept GitHub Releases green when AUR publish flakes and needs manual follow-up. - Release: Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.
- Release: Updated Electron to 39.8.6 and pinned patched transitive build dependencies to clear the reported high-severity audit findings. - Release: Updated Electron to 39.8.6 and pinned patched transitive build dependencies to clear the reported high-severity audit findings.
## Previous Versions </details>
<details> <details>
<summary>v0.10.x</summary> <summary>v0.10.x</summary>
+39 -13
View File
@@ -28,9 +28,9 @@ Character dictionary sync is disabled by default. To turn it on:
"enabled": true, "enabled": true,
"accessToken": "your-token", "accessToken": "your-token",
"characterDictionary": { "characterDictionary": {
"enabled": true "enabled": true,
} },
} },
} }
``` ```
@@ -47,18 +47,20 @@ If `yomitan.externalProfilePath` is set, SubMiner switches to read-only external
A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for: A single character produces many searchable terms so that names are recognized regardless of how they appear in dialogue. SubMiner generates variants for:
**Spacing and combination:** **Spacing and combination:**
- Full name with space: 須々木 心一 - Full name with space: 須々木 心一
- Combined form: 須々木心一 - Combined form: 須々木心一
- Family name alone: 須々木 - Family name alone: 須々木
- Given name alone: 心一 - Given name alone: 心一
**Middle-dot removal** (common in katakana foreign names): **Middle-dot removal** (common in katakana foreign names):
- ア・リ・ス → アリス (combined), plus individual segments - ア・リ・ス → アリス (combined), plus individual segments
**Honorific suffixes** — each base name is expanded with 15 common suffixes: **Honorific suffixes** — each base name is expanded with 15 common suffixes:
| Honorific | Reading | | Honorific | Reading |
| --- | --- | | --------- | ---------- |
| さん | さん | | さん | さん |
| 様 | さま | | 様 | さま |
| 先生 | せんせい | | 先生 | せんせい |
@@ -93,7 +95,7 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting,
**Key settings:** **Key settings:**
| Option | Default | Description | | Option | Default | Description |
| --- | --- | --- | | -------------------------------- | --------- | ---------------------------------- |
| `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting | | `subtitleStyle.nameMatchEnabled` | `true` | Toggle character-name highlighting |
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
@@ -117,10 +119,10 @@ The three collapsible sections can be configured to start open or closed:
"collapsibleSections": { "collapsibleSections": {
"description": false, "description": false,
"characterInformation": false, "characterInformation": false,
"voicedBy": false "voicedBy": false,
} },
} },
} },
} }
``` ```
@@ -143,7 +145,7 @@ When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine
{ {
"activeMediaIds": [170942, 163134, 154587], "activeMediaIds": [170942, 163134, 154587],
"mergedRevision": "a1b2c3d4e5f6", "mergedRevision": "a1b2c3d4e5f6",
"mergedDictionaryTitle": "SubMiner Character Dictionary" "mergedDictionaryTitle": "SubMiner Character Dictionary",
} }
``` ```
@@ -163,6 +165,29 @@ SubMiner.AppImage --dictionary
This creates a standalone dictionary ZIP for the target media and saves it alongside the snapshots. This creates a standalone dictionary ZIP for the target media and saves it alongside the snapshots.
## Correcting AniList Matches
SubMiner uses `guessit` to infer the anime title from the active filename, then searches AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
Use the in-app selector or CLI to pin the correct AniList media for the whole series:
```bash
# List candidate AniList matches for a file
subminer dictionary --candidates "/path/to/episode.mkv"
# Save the correct AniList media ID for that series
subminer dictionary --select 21355 "/path/to/episode.mkv"
# Equivalent direct app flags
SubMiner.AppImage --dictionary-candidates --dictionary-target "/path/to/episode.mkv"
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv"
# Open the in-app selector from the running app
subminer app --open-character-dictionary
```
Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the filename guess. Later episodes with the same series key use the selected AniList ID automatically. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary.
## File Structure ## File Structure
All character dictionary data lives under `{userData}/character-dictionaries/`: All character dictionary data lives under `{userData}/character-dictionaries/`:
@@ -174,6 +199,7 @@ character-dictionaries/
anilist-163134.json anilist-163134.json
merged.zip # Active merged dictionary (imported into Yomitan) merged.zip # Active merged dictionary (imported into Yomitan)
auto-sync-state.json # Tracks active media IDs and revision auto-sync-state.json # Tracks active media IDs and revision
anilist-overrides.json # Manual series-to-AniList overrides
img/ img/
m170942-c12345.jpg # Character portrait m170942-c12345.jpg # Character portrait
m170942-va67890.jpg # Voice actor portrait m170942-va67890.jpg # Voice actor portrait
@@ -195,7 +221,7 @@ merged.zip
## Configuration Reference ## Configuration Reference
| Option | Default | Description | | Option | Default | Description |
| --- | --- | --- | | ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- |
| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList | | `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList |
| `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary | | `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary |
| `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only | | `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only |
@@ -212,7 +238,7 @@ SubMiner's character dictionary builder is inspired by the [Japanese Character N
The reference implementation covers similar ground — name variant generation, honorific expansion, structured Yomitan content, portrait embedding — and additionally supports VNDB as a data source for visual novel characters. Key differences: The reference implementation covers similar ground — name variant generation, honorific expansion, structured Yomitan content, portrait embedding — and additionally supports VNDB as a data source for visual novel characters. Key differences:
| | SubMiner | Reference Implementation | | | SubMiner | Reference Implementation |
| --- | --- | --- | | ---------------------- | -------------------------------------------- | ------------------------------------- |
| **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service | | **Runtime** | TypeScript, runs inside Electron | Rust, standalone web service |
| **Data sources** | AniList only | AniList + VNDB | | **Data sources** | AniList only | AniList + VNDB |
| **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI | | **Delivery** | Auto-synced into bundled Yomitan | ZIP download via web UI |
@@ -226,7 +252,7 @@ If you work with visual novels or want a standalone dictionary generator indepen
- **Names not highlighting:** Confirm `anilist.characterDictionary.enabled` is `true` and `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters. - **Names not highlighting:** Confirm `anilist.characterDictionary.enabled` is `true` and `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters.
- **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase. - **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase.
- **Wrong characters showing:** The merged dictionary includes your `maxLoaded` most recent titles. If you're seeing names from a previous show, they'll rotate out once you watch enough new titles to push it past the limit. - **Wrong characters showing:** Open the in-app character dictionary selector (`--open-character-dictionary`) or run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id <id>`. This replaces stale wrong-title entries for that series. If names are only from an older unrelated show, they'll rotate out once you watch enough new titles to push it past `maxLoaded`.
- **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this. - **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this.
- **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate. - **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate.
+22 -6
View File
@@ -310,7 +310,7 @@ See `config.example.jsonc` for detailed configuration options.
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). | | `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). | | `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) | | `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) | | `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) | | `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) | | `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) | | `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
@@ -535,8 +535,13 @@ See `config.example.jsonc` for detailed configuration options.
"mineSentence": "CommandOrControl+S", "mineSentence": "CommandOrControl+S",
"mineSentenceMultiple": "CommandOrControl+Shift+S", "mineSentenceMultiple": "CommandOrControl+Shift+S",
"markAudioCard": "CommandOrControl+Shift+A", "markAudioCard": "CommandOrControl+Shift+A",
"openCharacterDictionary": "CommandOrControl+Alt+A",
"openRuntimeOptions": "CommandOrControl+Shift+O", "openRuntimeOptions": "CommandOrControl+Shift+O",
"openSessionHelp": "CommandOrControl+Shift+H",
"openControllerSelect": "Alt+C",
"openControllerDebug": "Alt+Shift+C",
"openJimaku": "Ctrl+Shift+J", "openJimaku": "Ctrl+Shift+J",
"toggleSubtitleSidebar": "Backslash",
"multiCopyTimeoutMs": 3000 "multiCopyTimeoutMs": 3000
} }
} }
@@ -555,8 +560,13 @@ See `config.example.jsonc` for detailed configuration options.
| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | | `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) |
| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | | `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) |
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | | `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
| `openCharacterDictionary` | string \| `null` | Opens the character dictionary AniList selector (default: `"CommandOrControl+Alt+A"`) |
| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | | `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) |
| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Shift+H"`) |
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) |
| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | | `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) |
| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. |
**See `config.example.jsonc`** for the complete list of shortcut configuration options. **See `config.example.jsonc`** for the complete list of shortcut configuration options.
@@ -573,9 +583,10 @@ Important behavior:
- Controller input is only active while keyboard-only mode is enabled. - Controller input is only active while keyboard-only mode is enabled.
- Keyboard-only mode continues to work normally without a controller. - Keyboard-only mode continues to work normally without a controller.
- By default SubMiner uses the first connected controller. - By default SubMiner uses the first connected controller.
- `Alt+C` opens the controller config modal, where you can save the selected controller and remap actions inline. - `Alt+C` opens the controller config modal by default, and you can remap that shortcut through `shortcuts.openControllerSelect`.
- Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action. - Click `Learn`, then press the next fresh button, trigger, or stick direction you want to bind for that overlay action.
- `Alt+Shift+C` opens a live debug modal showing raw axes/button values plus a ready-to-copy `buttonIndices` config block. - `Alt+Shift+C` opens the debug modal by default, and you can remap that shortcut through `shortcuts.openControllerDebug`.
- The debug modal shows raw axes/button values plus a ready-to-copy `buttonIndices` config block.
- `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`. - `controller.buttonIndices` is a semantic reference/legacy mapping. Changing it does not rewrite the raw numeric descriptor values already stored under `controller.bindings`.
- Turning keyboard-only mode off clears the keyboard-only token highlight state. - Turning keyboard-only mode off clears the keyboard-only token highlight state.
- Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active. - Closing the Yomitan popup clears the temporary native text-selection fill, but keeps controller token selection active.
@@ -680,6 +691,7 @@ When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel | | `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) | | `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) | | `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
| `Ctrl+Alt+A` | Open character dictionary AniList selector |
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) | | `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) | | `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
@@ -694,7 +706,7 @@ These shortcuts are only active when the overlay window is visible and automatic
### Session Help Modal ### Session Help Modal
The session help modal is opened with `Y-H` by default (falls back to `Y-K` if needed) and shows the current session keybindings and color legend. The session help modal opens from the overlay with `Ctrl/Cmd+Shift+H` by default. The mpv plugin also exposes it through the `Y-H` chord (falling back to `Y-K` if needed). It shows the current session keybindings and color legend.
You can filter the modal quickly with `/`: You can filter the modal quickly with `/`:
@@ -846,7 +858,7 @@ This example is intentionally compact. The option table below documents availabl
**Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation. **Requirements:** [AnkiConnect](https://github.com/FooSoft/anki-connect) plugin must be installed and running in Anki. ffmpeg must be installed for media generation.
| Option | Values | Description | | Option | Values | Description |
| ------------------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------- | --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) | | `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) | | `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) | | `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
@@ -881,7 +893,7 @@ This example is intentionally compact. The option table below documents availabl
| `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) | | `media.audioPadding` | number (seconds) | Padding around audio clip timing (default: `0.5`) |
| `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) | | `media.fallbackDuration` | number (seconds) | Default duration if timing unavailable (default: `3.0`) |
| `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) | | `media.maxMediaDuration` | number (seconds) | Max duration for generated media from multi-line copy (default: `30`, `0` to disable) |
| `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode` (default: `true`) | | `behavior.overwriteAudio` | `true`, `false` | Replace existing audio on updates; when `false`, new audio is appended/prepended per `behavior.mediaInsertMode`; manual clipboard updates always replace generated audio (default: `true`) |
| `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) | | `behavior.overwriteImage` | `true`, `false` | Replace existing images on updates; when `false`, new images are appended/prepended per `behavior.mediaInsertMode` (default: `true`) |
| `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) | | `behavior.mediaInsertMode` | `"append"`, `"prepend"` | Where to insert new media when overwrite is off (default: `"append"`) |
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) | | `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
@@ -1145,6 +1157,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
"jellyfin": { "jellyfin": {
"enabled": true, "enabled": true,
"serverUrl": "http://127.0.0.1:8096", "serverUrl": "http://127.0.0.1:8096",
"recentServers": ["http://127.0.0.1:8096"],
"username": "", "username": "",
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
@@ -1162,6 +1175,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
| -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ | | -------------------------- | --------------- | ------------------------------------------------------------------------------------------------------------ |
| `enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) | | `enabled` | `true`, `false` | Enable Jellyfin integration and CLI commands (default: `false`) |
| `serverUrl` | string (URL) | Jellyfin server base URL | | `serverUrl` | string (URL) | Jellyfin server base URL |
| `recentServers` | string[] | Recent Jellyfin server URLs shown in setup; entries are trimmed, deduped, and capped at 5 |
| `username` | string | Default username used by `--jellyfin-login` | | `username` | string | Default username used by `--jellyfin-login` |
| `deviceId` | string | Client device id sent in auth headers (default: `subminer`) | | `deviceId` | string | Client device id sent in auth headers (default: `subminer`) |
| `clientName` | string | Client name sent in auth headers (default: `SubMiner`) | | `clientName` | string | Client name sent in auth headers (default: `SubMiner`) |
@@ -1194,6 +1208,8 @@ See [Jellyfin Integration](/jellyfin-integration) for the full setup and cast-to
Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`. Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enabled`, `jellyfin.remoteControlEnabled`, and `jellyfin.remoteControlAutoConnect`.
When Jellyfin is enabled with a server URL and SubMiner is running, the tray menu also shows a `Jellyfin Discovery` checkbox. It starts or stops discovery for the current runtime session only and does not write config. Starting discovery still requires a valid stored or environment-provided Jellyfin auth session.
### Discord Rich Presence ### Discord Rich Presence
Discord Rich Presence is enabled by default. SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer unless you turn it off. Discord Rich Presence is enabled by default. SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer unless you turn it off.
+1 -1
View File
@@ -102,7 +102,7 @@ Secondary subtitle text (typically English translations) is stored alongside pri
### Word Exclusion List ### Word Exclusion List
The Vocabulary tab toolbar includes an **Exclusions** button for hiding words from all vocabulary views. Excluded words are stored in browser localStorage and 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.
## Retention Defaults ## Retention Defaults
+11 -3
View File
@@ -6,7 +6,8 @@ SubMiner includes an optional Jellyfin CLI integration for:
- listing libraries and media items - listing libraries and media items
- launching item playback in the connected mpv instance - launching item playback in the connected mpv instance
- receiving Jellyfin remote cast-to-device playback events in-app - receiving Jellyfin remote cast-to-device playback events in-app
- opening an in-app setup window for server/user/password input - opening an in-app setup window for server selection and authentication
- toggling Jellyfin cast discovery from the tray once configured
## Requirements ## Requirements
@@ -23,6 +24,7 @@ SubMiner includes an optional Jellyfin CLI integration for:
"jellyfin": { "jellyfin": {
"enabled": true, "enabled": true,
"serverUrl": "http://127.0.0.1:8096", "serverUrl": "http://127.0.0.1:8096",
"recentServers": ["http://127.0.0.1:8096"],
"username": "your-user", "username": "your-user",
"remoteControlEnabled": true, "remoteControlEnabled": true,
"remoteControlAutoConnect": true, "remoteControlAutoConnect": true,
@@ -48,6 +50,8 @@ subminer jellyfin -l \
--password 'your-password' --password 'your-password'
``` ```
`subminer jellyfin` opens the setup window. It offers the configured server, recent servers, and a manual server URL field. Successful login keeps the window open, stores the Jellyfin session token in encrypted storage, updates the configured server/username/client metadata, and refreshes recent servers. Passwords are never stored.
3. List libraries: 3. List libraries:
```bash ```bash
@@ -66,6 +70,8 @@ Launcher wrapper for Jellyfin cast discovery mode (background app + tray):
subminer jellyfin -d subminer jellyfin -d
``` ```
After Jellyfin is enabled with a server URL and SubMiner is already running, the tray menu shows `Jellyfin Discovery`. Use that checkbox to start or stop discovery for the current runtime session without changing config. If the stored login session is missing or expired, starting discovery shows a warning and setup remains the path to refresh credentials. It does not survive app restart.
Stop discovery session/app: Stop discovery session/app:
```bash ```bash
@@ -129,12 +135,13 @@ remote playback target in Jellyfin's cast-to-device menu.
- `jellyfin.enabled=true` - `jellyfin.enabled=true`
- valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session) - valid `jellyfin.serverUrl` and Jellyfin auth session (env override or stored login session)
- `jellyfin.remoteControlEnabled=true` (default) - `jellyfin.remoteControlEnabled=true` (default)
- `jellyfin.remoteControlAutoConnect=true` (default) - `jellyfin.remoteControlAutoConnect=true` (default) for startup auto-connect
- `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect) - `jellyfin.autoAnnounce=false` by default (`true` enables auto announce/visibility check logs on connect)
### Behavior ### Behavior
- SubMiner connects to Jellyfin remote websocket and posts playback capabilities. - SubMiner connects to Jellyfin remote websocket and posts playback capabilities.
- Startup auto-connect still requires `remoteControlAutoConnect=true`; the tray `Jellyfin Discovery` checkbox can start discovery later even when startup auto-connect is disabled.
- `Play` events open media in mpv with the same defaults used by `--jellyfin-play`. - `Play` events open media in mpv with the same defaults used by `--jellyfin-play`.
- If mpv IPC is not connected at cast time, SubMiner auto-launches mpv in idle mode with SubMiner defaults and retries playback. - If mpv IPC is not connected at cast time, SubMiner auto-launches mpv in idle mode with SubMiner defaults and retries playback.
- `Playstate` events map to mpv pause/resume/seek/stop controls. - `Playstate` events map to mpv pause/resume/seek/stop controls.
@@ -147,7 +154,8 @@ remote playback target in Jellyfin's cast-to-device menu.
- Device not visible in Jellyfin cast menu: - Device not visible in Jellyfin cast menu:
- ensure SubMiner is running - ensure SubMiner is running
- ensure session token is valid (`--jellyfin-login` again if needed) - ensure session token is valid (`--jellyfin-login` again if needed)
- ensure `remoteControlEnabled` and `remoteControlAutoConnect` are true - ensure `remoteControlEnabled` is true
- use tray `Jellyfin Discovery` or `subminer jellyfin -d` to start discovery
- Cast command received but playback does not start: - Cast command received but playback does not start:
- verify mpv IPC can connect (`--start` flow) - verify mpv IPC can connect (`--start` flow)
- verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...` - verify item is playable from normal `--jellyfin-play --jellyfin-item-id ...`
+5 -2
View File
@@ -70,7 +70,7 @@ subminer stats -b # start background stats daemon
## Subcommands ## Subcommands
| Subcommand | Purpose | | Subcommand | Purpose |
| ---------------------------- | ---------------------------------------------------------- | | ------------------------------------------ | ------------------------------------------------------------------ |
| `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) | | `subminer jellyfin` / `jf` | Jellyfin workflows (`-d` discovery, `-p` play, `-l` login) |
| `subminer stats` | Start stats server and open immersion dashboard in browser | | `subminer stats` | Start stats server and open immersion dashboard in browser |
| `subminer stats -b` | Start or reuse background stats daemon (non-blocking) | | `subminer stats -b` | Start or reuse background stats daemon (non-blocking) |
@@ -82,7 +82,10 @@ subminer stats -b # start background stats daemon
| `subminer mpv socket` | Print active socket path | | `subminer mpv socket` | Print active socket path |
| `subminer mpv idle` | Launch detached idle mpv instance | | `subminer mpv idle` | Launch detached idle mpv instance |
| `subminer dictionary <path>` | Generate character dictionary ZIP from file/dir target | | `subminer dictionary <path>` | Generate character dictionary ZIP from file/dir target |
| `subminer dictionary --candidates <path>` | List AniList candidate matches for character dictionary correction |
| `subminer dictionary --select <id> <path>` | Pin an AniList media ID for that target series |
| `subminer texthooker` | Launch texthooker-only mode | | `subminer texthooker` | Launch texthooker-only mode |
| `subminer texthooker -o` | Launch texthooker and open it in the default browser |
| `subminer app` | Pass arguments directly to SubMiner binary | | `subminer app` | Pass arguments directly to SubMiner binary |
Use `subminer <subcommand> -h` for command-specific help. Use `subminer <subcommand> -h` for command-specific help.
@@ -90,7 +93,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 |
+2
View File
@@ -100,6 +100,8 @@ If you prefer a hands-on approach (animecards-style), you can copy the current s
- For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1``9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard. - For multiple lines: press `Ctrl/Cmd+Shift+C`, then a digit `1``9` to select how many recent subtitle lines to combine. The combined text is copied to the clipboard.
3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill. 3. Press `Ctrl/Cmd+V` to update the last-added card with the clipboard contents plus audio, image, and translation — the same fields auto-update would fill.
Manual clipboard updates always replace generated audio in both the expression audio field and sentence audio field, even when `ankiConnect.behavior.overwriteAudio` is disabled. The manual flow assumes you are intentionally replacing the proxy-generated clip on the newest card.
This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card. This is useful when auto-update is disabled or when you want explicit control over which subtitle line gets attached to the card.
| Shortcut | Action | Config key | | Shortcut | Action | Config key |
+3
View File
@@ -41,11 +41,14 @@ All keybindings use a `y` chord prefix — press `y`, then the second key:
| `y-s` | Start overlay | | `y-s` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay | | `y-t` | Toggle visible overlay |
| `v` | Toggle primary subtitle bar visibility |
| `y-o` | Open settings window | | `y-o` | Open settings window |
| `y-r` | Restart overlay | | `y-r` | Restart overlay |
| `y-c` | Check status | | `y-c` | Check status |
| `y-k` | Skip intro (AniSkip) | | `y-k` | Skip intro (AniSkip) |
The bare `v` binding is a forced mpv binding. It overrides mpv's default primary subtitle visibility toggle and routes the action to SubMiner's primary subtitle bar instead.
## Menu ## Menu
Press `y-y` to open an interactive menu in mpv's OSD: Press `y-y` to open an interactive menu in mpv's OSD:
+7 -1
View File
@@ -172,8 +172,13 @@
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes. "multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting. "toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting. "markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting. "openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
"openJimaku": "Ctrl+Shift+J" // Open jimaku setting. "openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
"openSessionHelp": "CommandOrControl+Shift+H", // Open session help setting.
"openControllerSelect": "Alt+C", // Open controller select setting.
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable. }, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
// ========================================== // ==========================================
@@ -478,6 +483,7 @@
"jellyfin": { "jellyfin": {
"enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false "enabled": false, // Enable optional Jellyfin integration and CLI control commands. Values: true | false
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096). "serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
"username": "", // Default Jellyfin username used during CLI login. "username": "", // Default Jellyfin username used during CLI login.
"deviceId": "subminer", // Device id setting. "deviceId": "subminer", // Device id setting.
"clientName": "SubMiner", // Client name setting. "clientName": "SubMiner", // Client name setting.
+14 -7
View File
@@ -36,8 +36,9 @@ The multi-line shortcuts open a digit selector with a 3-second timeout (`shortcu
These control playback and subtitle display. They require overlay window focus. These control playback and subtitle display. They require overlay window focus.
| Shortcut | Action | | Shortcut | Action |
| -------------------- | -------------------------------------------------- | | -------------------- | --------------------------------------------------- |
| `Space` | Toggle mpv pause | | `Space` | Toggle mpv pause |
| `V` | Toggle primary subtitle bar visibility |
| `J` | Cycle primary subtitle track | | `J` | Cycle primary subtitle track |
| `Shift+J` | Cycle secondary subtitle track | | `Shift+J` | Cycle secondary subtitle track |
| `Ctrl+Alt+P` | Open playlist browser for current directory + queue | | `Ctrl+Alt+P` | Open playlist browser for current directory + queue |
@@ -64,9 +65,11 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
## Subtitle & Feature Shortcuts ## Subtitle & Feature Shortcuts
| Shortcut | Action | Config key | | Shortcut | Action | Config key |
| ------------------ | -------------------------------------------------------- | ------------------------------ | | ------------------ | -------------------------------------------------------- | ----------------------------------- |
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` | | `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` |
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` | | `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
| `Ctrl/Cmd+Shift+H` | Open session help modal | `shortcuts.openSessionHelp` |
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` | | `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` | | `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` | | `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
@@ -79,12 +82,12 @@ The subtitle sidebar toggle is overlay-local and only opens when SubMiner has a
## Controller Shortcuts ## Controller Shortcuts
These overlay-local shortcuts are fixed and open controller utilities for the Chrome Gamepad API integration. These overlay-local shortcuts open controller utilities for the Chrome Gamepad API integration.
| Shortcut | Action | Configurable | | Shortcut | Action | Configurable |
| ------------- | ------------------------------ | ------------ | | ------------- | ------------------------------------ | -------------------------------- |
| `Alt+C` | Open controller config + remap modal | Fixed | | `Alt+C` | Open controller config + remap modal | `shortcuts.openControllerSelect` |
| `Alt+Shift+C` | Open controller debug modal | Fixed | | `Alt+Shift+C` | Open controller debug modal | `shortcuts.openControllerDebug` |
Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller. Controller input only drives the overlay while keyboard-only mode is enabled. The controller mapping and tuning live under the top-level `controller` config block; keyboard-only mode still works normally without a controller.
@@ -98,9 +101,13 @@ When the mpv plugin is installed, all commands use a `y` chord prefix — press
| `y-s` | Start overlay | | `y-s` | Start overlay |
| `y-S` | Stop overlay | | `y-S` | Stop overlay |
| `y-t` | Toggle visible overlay | | `y-t` | Toggle visible overlay |
| `v` | Toggle primary subtitle bar visibility |
| `y-o` | Open Yomitan settings | | `y-o` | Open Yomitan settings |
| `y-r` | Restart overlay | | `y-r` | Restart overlay |
| `y-c` | Check overlay status | | `y-c` | Check overlay status |
| `y-h` | Open session help |
The bare `v` plugin binding intentionally overrides mpv's native primary subtitle visibility toggle so the SubMiner primary subtitle bar is hidden or restored instead.
When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper). When the overlay has focus, press `y` then `d` to toggle DevTools (debugging helper).
@@ -113,7 +120,7 @@ When the overlay has focus, press `y` then `d` to toggle DevTools (debugging hel
## Customizing Shortcuts ## Customizing Shortcuts
All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+Shift+M"`. Use `null` to disable a shortcut. All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+Alt+A"`. Use `null` to disable a shortcut.
```jsonc ```jsonc
{ {
+17 -2
View File
@@ -100,18 +100,23 @@ subminer mpv socket # Print active mpv socket path
subminer mpv status # Exit 0 if socket is ready, else exit 1 subminer mpv status # Exit 0 if socket is ready, else exit 1
subminer mpv idle # Launch detached idle mpv with SubMiner defaults subminer mpv idle # Launch detached idle mpv with SubMiner defaults
subminer dictionary /path/to/file-or-directory # Generate character dictionary ZIP from target (manual Yomitan import) 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 # Launch texthooker-only mode
subminer texthooker -o # Launch texthooker and open it in your browser
subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow) subminer app --anilist # Pass args directly to SubMiner binary (example: AniList login flow)
# Direct packaged app control # Direct packaged app control
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 # Launch texthooker only (no overlay window) SubMiner.AppImage --texthooker # Launch texthooker only (no overlay window)
SubMiner.AppImage --texthooker --open-browser # Launch texthooker and open browser
SubMiner.AppImage --setup # Open first-run setup popup 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 visible overlay SubMiner.AppImage --show-visible-overlay # Force show visible overlay
SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay SubMiner.AppImage --hide-visible-overlay # Force hide visible overlay
SubMiner.AppImage --toggle-primary-subtitle-bar # Toggle primary subtitle bar visibility
SubMiner.AppImage --start --dev # Enable app/dev mode only SubMiner.AppImage --start --dev # Enable app/dev mode only
SubMiner.AppImage --start --debug # Alias for --dev SubMiner.AppImage --start --debug # Alias for --dev
SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode SubMiner.AppImage --start --log-level debug # Force verbose logging without app/dev mode
@@ -124,9 +129,14 @@ SubMiner.AppImage --jellyfin-items --jellyfin-library-id LIBRARY_ID --jellyfin-s
SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start or plugin workflow) SubMiner.AppImage --jellyfin-play --jellyfin-item-id ITEM_ID --jellyfin-audio-stream-index 1 --jellyfin-subtitle-stream-index 2 # Requires connected mpv IPC (--start or plugin workflow)
SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability announce + visibility check
SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime 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 --open-character-dictionary # Open in-app AniList selector
SubMiner.AppImage --help # Show all options SubMiner.AppImage --help # Show all options
``` ```
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
### Logging and App Mode ### Logging and App Mode
- `--log-level` controls logger verbosity. - `--log-level` controls logger verbosity.
@@ -166,6 +176,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan
- `subminer config`: config helpers (`path`, `show`). - `subminer config`: config helpers (`path`, `show`).
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`). - `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target. - `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`). - `subminer texthooker`: texthooker-only shortcut (same behavior as `--texthooker`).
- `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage. - `subminer app` / `subminer bin`: direct passthrough to the SubMiner binary/AppImage.
- Subcommand help pages are available (for example `subminer jellyfin -h`). - Subcommand help pages are available (for example `subminer jellyfin -h`).
@@ -272,12 +283,12 @@ SubMiner supports gamepad/controller input for couch-friendly usage via the Chro
1. Connect a controller before or after launching SubMiner. 1. Connect a controller before or after launching SubMiner.
2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding. 2. Enable keyboard-only mode — press `Y` on the controller (default binding) or use the overlay keybinding.
3. Press `Alt+C` in the overlay to pick the controller you want to save and remap any action inline. 3. Press `Alt+C` in the overlay by default to pick the controller you want to save and remap any action inline.
4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller. 4. Click `Learn` on the overlay action you want, then press the matching button, trigger, or stick direction on the controller.
5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps. 5. Use the left stick to navigate subtitle tokens and scroll the popup; use the right stick vertically for popup page jumps.
6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup. 6. Press `A` to look up the selected word, `X` to mine a card, `B` to close the popup.
By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline. `Alt+Shift+C` still opens the live debug modal with raw axes/button values for non-standard pads. By default SubMiner uses the first connected controller. `Alt+C` opens the controller config modal, where you can save the preferred controller and remap bindings inline, and `Alt+Shift+C` opens the live debug modal with raw axes/button values for non-standard pads. Both shortcuts can be changed through `shortcuts.openControllerSelect` and `shortcuts.openControllerDebug`.
### Default Button Mapping ### Default Button Mapping
@@ -321,6 +332,10 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback. Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
Press `V` to hide or restore the primary SubMiner subtitle bar. The mpv plugin also binds bare `v` to the same action, overriding mpv's native primary subtitle visibility toggle.
`Ctrl/Cmd+Shift+H` opens the session help modal with the current overlay and mpv keybindings. If you use the mpv plugin, the same help view is also available through the `y-h` chord.
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
+3 -1
View File
@@ -164,6 +164,8 @@ Start it with either:
```bash ```bash
subminer texthooker subminer texthooker
# or open the page immediately
subminer texthooker -o
``` ```
or by leaving `texthooker.launchAtStartup` enabled. or by leaving `texthooker.launchAtStartup` enabled.
@@ -273,7 +275,7 @@ Examples:
Examples: Examples:
- open a media picker, then call `subminer /path/to/file.mkv` - open a media picker, then call `subminer /path/to/file.mkv`
- launch browser-only subtitle tooling with `subminer texthooker` - launch browser-only subtitle tooling with `subminer texthooker -o`
- disable the helper UI for a session with `subminer --no-texthooker video.mkv` - disable the helper UI for a session with `subminer --no-texthooker video.mkv`
#### Build an overlay-adjacent client #### Build an overlay-adjacent client
+18 -6
View File
@@ -2,16 +2,28 @@
# Releasing # Releasing
## Prerequisites
- `claude` (Claude Code CLI) installed, on `PATH`, and authenticated.
`changelog:build` and `changelog:prerelease-notes` invoke
`claude -p --model sonnet` to merge and rewrite `changes/*.md` fragments into
a polished, user-facing release body. Either OAuth login (`claude /login`) or
`ANTHROPIC_API_KEY` works. Install from <https://claude.com/claude-code> if
you don't already have it.
## Stable Release ## Stable Release
1. Confirm `main` is green: `gh run list --workflow CI --limit 5`. 1. Confirm `main` is green: `gh run list --workflow CI --limit 5`.
2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples. 2. Confirm release-facing docs are current: `README.md`, `changes/*.md`, and any touched `docs-site/` pages/config examples.
3. Run `bun run changelog:lint`. 3. Run `bun run changelog:lint`.
4. Bump `package.json` to the release version. 4. Bump `package.json` to the release version.
5. Build release metadata before tagging: 5. Build release metadata before tagging (this calls `claude -p` locally):
`bun run changelog:build --version <version> --date <yyyy-mm-dd>` `bun run changelog:build --version <version> --date <yyyy-mm-dd>`
- Release CI now also auto-runs this step when releasing directly from a tag and `changes/*.md` fragments remain. - The polished `CHANGELOG.md` and `release/release-notes.md` are committed
6. Review `CHANGELOG.md` and `release/release-notes.md`. before tagging. Release CI no longer auto-builds the changelog; it fails
fast if `changes/*.md` fragments are still present on a tag-based run.
6. Review `CHANGELOG.md` and `release/release-notes.md`. Edit by hand if Claude
missed something — the committed Markdown is what ships.
7. Run release gate locally: 7. Run release gate locally:
`bun run changelog:check --version <version>` `bun run changelog:check --version <version>`
`bun run verify:config-example` `bun run verify:config-example`
@@ -31,7 +43,7 @@
1. Confirm release-facing docs and pending `changes/*.md` fragments are current. 1. Confirm release-facing docs and pending `changes/*.md` fragments are current.
2. Run `bun run changelog:lint`. 2. Run `bun run changelog:lint`.
3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`. 3. Bump `package.json` to the prerelease version, for example `0.11.3-beta.1` or `0.11.3-rc.1`.
4. Run the prerelease gate locally: 4. Run the prerelease gate locally (this calls `claude -p` locally):
`bun run changelog:prerelease-notes --version <version>` `bun run changelog:prerelease-notes --version <version>`
`bun run verify:config-example` `bun run verify:config-example`
`bun run typecheck` `bun run typecheck`
@@ -51,8 +63,8 @@ Notes:
- Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night. - Pass `--date` explicitly when you want the release stamped with the local cut date; otherwise the generator uses the current ISO date, which can roll over to the next UTC day late at night.
- `changelog:check` now rejects tag/package version mismatches. - `changelog:check` now rejects tag/package version mismatches.
- `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files. - `changelog:prerelease-notes` also rejects tag/package version mismatches and writes `release/prerelease-notes.md` without mutating tracked changelog files.
- `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` and removes the released `changes/*.md` fragments. - `changelog:build` generates `CHANGELOG.md` + `release/release-notes.md` (both polished by `claude -p`) and removes the released `changes/*.md` fragments. The CHANGELOG keeps internal notes inside a `<details><summary>Internal changes</summary>` collapse; the release notes drop them entirely.
- In the same way, the release workflow now auto-runs `changelog:build` when it detects unreleased `changes/*.md` on a tag-based run, then verifies and publishes. - The release workflow no longer auto-runs `changelog:build`. If pending `changes/*.md` fragments are present on a tag-based run, CI exits with a clear `::error::` pointing at the local fix. Run `bun run changelog:build --version <version>` locally, commit the polished output, then tag.
- Do not tag while `changes/*.md` fragments still exist. - Do not tag while `changes/*.md` fragments still exist.
- Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut. - Prerelease tags intentionally keep `changes/*.md` fragments in place so multiple prereleases can reuse the same cumulative pending notes until the final stable cut.
- If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`. - If you need to repair a published release body (for example, a prior versions section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,184 @@
# Library Summary Replaces Per-Day Trends — Design
**Status:** Draft
**Date:** 2026-04-09
**Scope:** `stats/` frontend, `src/core/services/immersion-tracker/query-trends.ts` backend
## Problem
The "Library — Per Day" section on the stats Trends tab (`stats/src/components/trends/TrendsTab.tsx:224-254`) renders six stacked-area charts — Videos, Watch Time, Cards, Words, Lookups, and Lookups/100w, each broken down per title per day.
In practice these charts are not useful:
- Most titles only have activity on one or two days in a window, so they render as isolated bumps on a noisy baseline.
- Stacking 7+ titles with mostly-zero days makes individual lines hard to follow.
- The top "Activity" and "Period Trends" sections already answer "what am I doing per day" globally.
- The "Library — Cumulative" section directly below already answers "which titles am I progressing through" with less noise.
The per-day section occupies significant vertical space without carrying its weight, and the user has confirmed it should be replaced.
## Goal
Replace the six per-day stacked charts with a single "Library — Summary" section that surfaces per-title aggregate statistics over the selected date range. The new view should make it trivially easy to answer: "For the selected window, which titles am I spending time on, how much mining output have they produced, and how efficient is my lookup rate on each?"
## Non-goals
- Changing the "Library — Cumulative" section (stays as-is).
- Changing the "Activity", "Period Trends", or "Patterns" sections.
- Adding a new API endpoint — the existing dashboard endpoint is extended in place.
- Renaming internal `anime*` data-model identifiers (`animeId`, `imm_anime`, etc.). Those stay per the convention established in `c5e778d7`; only new fields/types/user-visible strings use generic "title"/"library" wording.
- Supporting a true all-time library view on the Trends tab. If that's ever wanted, it belongs on a different tab.
## Solution Overview
Delete the "Library — Per Day" section. In its place, add "Library — Summary", composed of:
1. A horizontal-bar leaderboard chart of watch time per title (top 10, descending).
2. A sortable table of every title with activity in the selected window, with columns: Title, Watch Time, Videos, Sessions, Cards, Words, Lookups, Lookups/100w, Date Range.
Both controls are scoped to the top-of-page date range selector. The existing shared Anime Visibility filter continues to work — it now gates Summary + Cumulative instead of Per-Day + Cumulative.
## Backend
### New type
Add to `stats/src/types/stats.ts` and the backend query module:
```ts
type LibrarySummaryRow = {
title: string; // display title — anime series, YouTube video title, etc.
watchTimeMin: number; // sum(total_active_min) across the window
videos: number; // distinct video_id count
sessions: number; // session count from imm_sessions
cards: number; // sum(total_cards)
words: number; // sum(total_tokens_seen)
lookups: number; // sum(lookup_count) from imm_sessions
lookupsPerHundred: number | null; // lookups / words * 100, null when words == 0
firstWatched: number; // min(rollup_day) as epoch day, within the window
lastWatched: number; // max(rollup_day) as epoch day, within the window
};
```
### Query changes in `src/core/services/immersion-tracker/query-trends.ts`
- Add `librarySummary: LibrarySummaryRow[]` to `TrendsDashboardQueryResult`.
- Populate it from a single aggregating query over `imm_daily_rollups` joined to `imm_videos``imm_anime`, filtered by `rollup_day` within the selected window. Session count and lookup count come from `imm_sessions` aggregated by `video_id` and then grouped by the parent library entry. Use a single query (or at most two joined/unioned) — no N+1.
- `imm_anime` is the generic library-grouping table; anime series, YouTube videos, and yt-dlp imports all land there. The internal table name stays `imm_anime`; only the new field uses generic naming.
- Return rows pre-sorted by `watchTimeMin` descending so the leaderboard is zero-cost and the table default sort matches.
- Emit `lookupsPerHundred: null` when `words == 0`.
### Removed from API response
Drop the entire `animePerDay` field from `TrendsDashboardQueryResult` (both backend in `src/core/services/immersion-tracker/query-trends.ts` and frontend in `stats/src/types/stats.ts`).
Internally, the existing helpers (`buildPerAnimeFromDailyRollups`, `buildEpisodesPerAnimeFromDailyRollups`) are still used as intermediates to build `animeCumulative.*` via `buildCumulativePerAnime`. Keep those helpers — just scope their output to local variables inside `getTrendsDashboard` instead of exposing them on the response. The `buildPerAnimeFromSessions` call for lookups and the `buildLookupsPerHundredPerAnime` helper become unused and can be deleted.
Before removing `animePerDay` from the frontend type, verify no other file under `stats/src/` references it. Based on current inspection, only `TrendsTab.tsx` and `stats/src/types/stats.ts` touch it.
## Frontend
### New component: `stats/src/components/trends/LibrarySummarySection.tsx`
Owns the header, leaderboard chart, visibility-filtered data, and the table. Keeps `TrendsTab.tsx` from growing. Component props: `{ rows: LibrarySummaryRow[]; hiddenTitles: ReadonlySet<string>; windowStart: Date; windowEnd: Date }`.
Internal state: `useState<{ column: ColumnId; direction: 'asc' | 'desc' }>` for sort, defaulting to `{ column: 'watchTimeMin', direction: 'desc' }`.
### Layout
Replaces `TrendsTab.tsx:224-254`:
```
[SectionHeader: "Library — Summary"]
[AnimeVisibilityFilter — unchanged, shared with Cumulative below]
[Card, col-span-full: Leaderboard — horizontal bar chart, ~260px tall]
[Card, col-span-full: Sortable table, auto height up to ~480px with internal scroll]
```
Both cards use the existing chart/card wrapper styling.
### Leaderboard chart
- Recharts horizontal bar chart (matches the rest of the page — existing charts use `recharts`, not ECharts).
- Top 10 titles by watch time. If fewer titles have activity, render what's there.
- Y-axis: title (category), truncated with ellipsis at container width; full title visible in the Recharts tooltip.
- X-axis: minutes (number).
- Use `layout="vertical"` with `YAxis dataKey="title" type="category"` and `XAxis type="number"`.
- Single series color: `#8aadf4` (matching the existing Watch Time color).
- Reuse `CHART_DEFAULTS`, `CHART_THEME`, `TOOLTIP_CONTENT_STYLE` from `stats/src/lib/chart-theme.ts` so theming matches the rest of the dashboard.
- Chart order is fixed at watch-time desc regardless of table sort — the leaderboard's meaning is fixed.
### Table
- Plain HTML `<table>` with Tailwind classes. No new deps.
- Columns, in order:
1. **Title** — left-aligned, sticky, truncated with ellipsis, full title on hover.
2. **Watch Time** — formatted `Xh Ym` when ≥60 min, else `Xm`.
3. **Videos** — integer.
4. **Sessions** — integer.
5. **Cards** — integer.
6. **Words** — integer.
7. **Lookups** — integer.
8. **Lookups/100w** — one decimal place, `—` when null.
9. **Date Range**`Mon D → Mon D` using the title's `firstWatched` / `lastWatched` within the window.
- Click a column header to sort; click again to reverse. Visual arrow on the active column.
- Numeric columns right-aligned.
- Null `lookupsPerHundred` sorts as the lowest value in both directions (consistent with "no data").
- Row hover highlight; no row click action (read-only view).
- Empty state: "No library activity in the selected window."
### Visibility filter integration
Hiding a title via `AnimeVisibilityFilter` removes it from both the leaderboard and the table. The filter's set of available titles is built from the union of titles that appear in `librarySummary` and the existing `animeCumulative.*` arrays (matches current behavior in `buildAnimeVisibilityOptions`).
### `TrendsTab.tsx` changes
- Remove the `filteredEpisodesPerAnime`, `filteredWatchTimePerAnime`, `filteredCardsPerAnime`, `filteredWordsPerAnime`, `filteredLookupsPerAnime`, `filteredLookupsPerHundredPerAnime` locals.
- Remove the six `<StackedTrendChart>` calls in the "Library — Per Day" section.
- Remove the `<SectionHeader>Library — Per Day</SectionHeader>` and the `<AnimeVisibilityFilter>` from that position.
- Insert `<SectionHeader>Library — Summary</SectionHeader>` + `<AnimeVisibilityFilter>` + `<LibrarySummarySection>` in the same place.
- Update `buildAnimeVisibilityOptions` input to use `librarySummary` titles instead of the six dropped `animePerDay.*` arrays.
## Data flow
1. `useTrends(range, groupBy)` calls `/api/stats/trends/dashboard`.
2. Response now includes `librarySummary` (sorted by watch time desc).
3. `TrendsTab` holds the shared `hiddenAnime` set (unchanged).
4. `LibrarySummarySection` receives `librarySummary` + `hiddenAnime`, filters out hidden rows, renders the leaderboard from the top-10 slice of the filtered list, renders the table from the filtered list with local sort state applied.
5. Date-range selector changes trigger a new fetch; `groupBy` toggle does not affect the summary section (it's always window-total).
## Edge cases
- **No activity in window:** Section renders header + empty-state card. Leaderboard card hidden. Visibility filter hidden.
- **One title only:** Leaderboard renders a single bar; table renders one row. No special-casing.
- **Title with zero words but non-zero lookups:** `lookupsPerHundred` is `null`, rendered as `—`. Sort treats null as lowest.
- **Title with zero cards/lookups/words but non-zero watch time:** Normal zero rendering, still shown.
- **Very long titles:** Ellipsis in chart y-axis labels and table title column; full title in `title` attribute / ECharts tooltip.
- **Mixed sources (anime + YouTube):** No special case — both land in `imm_anime` and are grouped uniformly.
## Testing
### Backend (`query-trends.ts`)
New unit tests, following the existing pattern:
1. Empty window returns `librarySummary: []`.
2. Single title with a few rollups: all aggregates are correct; `firstWatched`/`lastWatched` match the bounding days within the window.
3. Multiple titles: rows returned sorted by watch time desc.
4. Mixed sources (anime-style + YouTube-style entries in `imm_anime`): both appear in the summary with their own aggregates.
5. Title with `words == 0`: `lookupsPerHundred` is `null`.
6. Date range excludes some rollups: excluded rollups are not counted; `firstWatched`/`lastWatched` reflect only within-window activity.
7. `sessions` and `lookups` come from `imm_sessions`, not `imm_daily_rollups`, and are correctly attributed to the parent library entry.
### Frontend
- Existing Trends tab smoke test should continue to pass after wiring.
- Optional: a targeted render test for `LibrarySummarySection` (empty state, single title, sort toggle, visibility filter interaction). Not required for merge if the smoke test exercises the happy path.
## Release / docs
- One fragment in `changes/*.md` summarizing the replacement.
- No user-facing docs (`docs-site/`) changes unless the per-day section was documented there — verify during implementation.
## Open items
None.
@@ -0,0 +1,347 @@
# Stats Dashboard Feedback Pass — Design
Date: 2026-04-09
Scope: Stats dashboard UX follow-ups from user feedback (items 17).
Delivery: **Single PR**, broken into logically scoped commits.
## Goals
Address seven concrete pieces of feedback against the Statistics menu:
1. Library — collapse episodes behind a per-series dropdown.
2. Sessions — roll up multiple sessions of the same episode within a day.
3. Trends — add a 365d range option.
4. Library — delete an episode (video) from its detail view.
5. Vocabulary — tighten spacing between word and reading in the Top 50 table.
6. Episode detail — hide cards whose Anki notes have been deleted.
7. Trend/watch charts — add gridlines, fix tick legibility, unify theming.
Out of scope for this pass: English-token ingestion cleanup and Overview stat-card drill-downs (feedback items 8 and 9). Those require a larger design decision and a migration respectively.
## Files touched (inventory)
Dashboard (`stats/src/`):
- `components/library/LibraryTab.tsx` — collapsible groups (item 1).
- `components/library/MediaDetailView.tsx`, `components/library/MediaHeader.tsx` — delete-episode action (item 4).
- `components/sessions/SessionsTab.tsx`, `components/library/MediaSessionList.tsx` — episode rollup (item 2).
- `components/trends/DateRangeSelector.tsx`, `hooks/useTrends.ts`, `lib/api-client.ts`, `lib/api-client.test.ts` — 365d (item 3).
- `components/vocabulary/FrequencyRankTable.tsx` — word/reading column collapse (item 5).
- `components/anime/EpisodeDetail.tsx` — filter deleted Anki cards (item 6).
- `components/trends/TrendChart.tsx`, `components/trends/StackedTrendChart.tsx`, `components/overview/WatchTimeChart.tsx`, `lib/chart-theme.ts` — chart clarity (item 7).
- New file: `stats/src/lib/session-grouping.ts` + `session-grouping.test.ts`.
Backend (`src/core/services/`):
- `immersion-tracker/query-trends.ts` — extend `TrendRange` and `TREND_DAY_LIMITS` (item 3).
- `immersion-tracker/__tests__/query.test.ts` — 365d coverage (item 3).
- `stats-server.ts` — passthrough if range validation lives here (check before editing).
- `__tests__/stats-server.test.ts` — 365d coverage (item 3).
## Commit plan
One PR, one feature per commit. Order picks low-risk mechanical changes first so failures in later commits don't block merging of earlier ones.
1. `feat(stats): add 365d range to trends dashboard` (item 3)
2. `fix(stats): tighten word/reading column in Top 50 table` (item 5)
3. `fix(stats): hide cards deleted from Anki in episode detail` (item 6)
4. `feat(stats): delete episode from library detail view` (item 4)
5. `feat(stats): collapsible series groups in library` (item 1)
6. `feat(stats): roll up same-episode sessions within a day` (item 2)
7. `feat(stats): gridlines and unified theme for trend charts` (item 7)
Each commit must pass `bun run typecheck`, `bun run test:fast`, and any change-specific checks listed below.
---
## Item 1 — Library collapsible series groups
### Current behavior
`LibraryTab.tsx` groups media via `groupMediaLibraryItems` and always renders the full grid of `MediaCard`s beneath each group header.
### Target behavior
Each group header becomes clickable. Groups with `items.length > 1` default to **collapsed**; single-video groups stay expanded (collapsing them would be visual noise).
### Implementation
- State: `const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(...)`. Initialize from `grouped` where `items.length > 1`.
- Toggle helper: `toggleGroup(key: string)` adds/removes from the set.
- Group header: wrap in a `<button>` with `aria-expanded` and a chevron icon (`▶`/`▼`). Keep the existing cover + title + subtitle layout inside the button.
- Children grid is conditionally rendered on `!collapsedGroups.has(group.key)`.
- Header summary (`N videos · duration · cards`) stays visible in both states so collapsed groups remain informative.
### Tests
- New `LibraryTab.test.tsx` (if not already present — check first) covering:
- Multi-video group renders collapsed on first mount.
- Single-video group renders expanded on first mount.
- Clicking the header toggles visibility.
- Header summary is visible in both states.
---
## Item 2 — Sessions episode rollup within a day
### Current behavior
`SessionsTab.tsx:10-24` groups sessions by day label only (`formatSessionDayLabel(startedAtMs)`). Multiple sessions of the same episode on the same day show as independent rows. `MediaSessionList.tsx` has the same problem inside the library detail view.
### Target behavior
Within each day, sessions with the same `videoId` collapse into one parent row showing combined totals. A chevron reveals the individual sessions. Single-session buckets render flat (no pointless nesting).
### Implementation
- New helper in `stats/src/lib/session-grouping.ts`:
```ts
export interface SessionBucket {
key: string; // videoId as string, or `s-${sessionId}` for singletons
videoId: number | null;
sessions: SessionSummary[];
totalActiveMs: number;
totalCardsMined: number;
representativeSession: SessionSummary; // most recent, for header display
}
export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[];
```
Sessions missing a `videoId` become singleton buckets.
- `SessionsTab.tsx`: after day grouping, pipe each `daySessions` through `groupSessionsByVideo`. Render each bucket:
- `sessions.length === 1`: existing `SessionRow` behavior, unchanged.
- `sessions.length >= 2`: render a **bucket row** that looks like `SessionRow` but shows combined totals and session count (e.g. `3 sessions · 1h 24m · 12 cards`). Chevron state stored in a second `Set<string>` on bucket key. Expanded buckets render the child `SessionRow`s indented (`pl-8`) beneath the header.
- `MediaSessionList.tsx`: within the media detail view, a single video's sessions are all the same `videoId` by definition — grouping here is by day only, and within a day multiple sessions render nested under a day header. Re-use the same visual pattern; factor the bucket row into a shared `SessionBucketRow` component.
### Delete semantics
- Deleting a bucket header offers "Delete all N sessions in this group" (reuse `confirmDayGroupDelete` pattern with a bucket-specific message, or add `confirmBucketDelete`).
- Deleting an individual session from inside an expanded bucket keeps the existing single-delete flow.
### Tests
- `session-grouping.test.ts`:
- Empty input → empty output.
- All unique videos → N singleton buckets.
- Two sessions same videoId → one bucket with correct totals and representative (most recent start time).
- Missing videoId → singleton bucket keyed by sessionId.
- `SessionsTab.test.tsx` (extend or add) verifying the rendered bucket rows expand/collapse and delete hooks fire with the right ID set.
---
## Item 3 — 365d trends range
### Backend
`src/core/services/immersion-tracker/query-trends.ts`:
- `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';`
- Add `'365d': 365` to `TREND_DAY_LIMITS`.
- `getTrendDayLimit` picks up the new key automatically because of the `Exclude<TrendRange, 'all'>` generic.
`src/core/services/stats-server.ts`:
- Search for any hardcoded range validation (e.g. allow-list in the trends route handler) and extend it.
### Frontend
- `hooks/useTrends.ts`: widen the `TimeRange` union.
- `components/trends/DateRangeSelector.tsx`: add `'365d'` to the options list. Display label stays as `365d`.
- `lib/api-client.ts` / `api-client.test.ts`: if the client validates ranges, add `365d`.
### Tests
- `query.test.ts`: extend the existing range table to cover `365d` returning 365 days of data.
- `stats-server.test.ts`: ensure the route accepts `range=365d`.
- `api-client.test.ts`: ensure the client emits the new range.
### Change-specific checks
- `bun run test:config` is not required here (no schema/defaults change).
- Run `bun run typecheck` + `bun run test:fast`.
---
## Item 4 — Delete episode from library detail
### Current behavior
`MediaDetailView.tsx` provides session-level delete only. The backend `deleteVideo` exists (`query-maintenance.ts:509`), the API is exposed at `stats-server.ts:559`, and `api-client.deleteVideo` is already wired (`stats/src/lib/api-client.ts:146`). `EpisodeList.tsx:46` already uses it from the anime tab.
### Target behavior
A "Delete Episode" action in `MediaHeader` (top-right, small, `text-ctp-red`), gated by `confirmEpisodeDelete(title)`. On success, call `onBack()` and make sure the parent `LibraryTab` refetches.
### Implementation
- Add an `onDeleteEpisode?: () => void` prop to `MediaHeader` and render the button only if provided.
- In `MediaDetailView`:
- New handler `handleDeleteEpisode` that calls `apiClient.deleteVideo(videoId)`, then `onBack()`.
- Reuse `confirmEpisodeDelete` from `stats/src/lib/delete-confirm.ts`.
- In `LibraryTab`:
- `useMediaLibrary` returns fresh data on mount. The simplest fix: pass a `refresh` function from the hook (extend the hook if it doesn't already expose one) and call it when the detail view signals back.
- Alternative: force a remount by incrementing a `libraryVersion` key on the library list. Prefer `refresh` for clarity.
### Tests
- Extend the existing `MediaDetailView.test.tsx`: mock `apiClient.deleteVideo`, click the new button, confirm `onBack` fires after success.
- `useMediaLibrary.test.ts`: if we add a `refresh` method, cover it.
---
## Item 5 — Vocabulary word/reading column collapse
### Current behavior
`FrequencyRankTable.tsx:110-144` uses a 5-column table: `Rank | Word | Reading | POS | Seen`. Word and Reading are auto-sized, producing a large gap.
### Target behavior
Merge Word + Reading into a single column titled "Word". Reading sits immediately after the headword in a muted, smaller style.
### Implementation
- Drop the `<th>Reading</th>` header and cell.
- Word cell becomes:
```tsx
<td className="py-1.5 pr-3">
<span className="text-ctp-text font-medium">{w.headword}</span>
{reading && (
<span className="text-ctp-subtext0 text-xs ml-1.5">
【{reading}】
</span>
)}
</td>
```
where `reading = fullReading(w.headword, w.reading)` and differs from `headword`.
- Keep `fullReading` import from `reading-utils`.
### Tests
- Extend `FrequencyRankTable.test.tsx` (if present — otherwise add a focused test) to assert:
- Headword renders.
- Reading renders when different from headword.
- Reading does not render when equal to headword.
---
## Item 6 — Hide Anki-deleted cards in Cards Mined
### Current behavior
`EpisodeDetail.tsx:109-147` iterates `cardEvents`, fetches note info via `ankiNotesInfo(allNoteIds)`, and for each `noteId` renders a row even if no matching `info` came back — the user sees an empty word with an "Open in Anki" button that leads nowhere.
### Target behavior
After `ankiNotesInfo` resolves:
- Drop `noteId`s that are not in the resolved map.
- Drop `cardEvents` whose `noteIds` list was non-empty but is now empty after filtering.
- Card events with a positive `cardsDelta` but no `noteIds` (legacy rollup path) still render as `+N cards` — we have no way to cross-reference them, so leave them alone.
### Implementation
- Compute `filteredCardEvents` as a `useMemo` depending on `data.cardEvents` and `noteInfos`.
- Iterate `filteredCardEvents` instead of `cardEvents` in the render.
- Surface a subtle note (optional, muted) "N cards hidden (deleted from Anki)" at the end of the list if any were filtered — helps the user understand why counts here diverge from session totals. Final decision on the note can be made at PR review; default: **show it**.
### Tests
- Add a test in `EpisodeDetail.test.tsx` (add the file if not present) that stubs `ankiNotesInfo` to return only a subset of notes and verifies the missing ones are not rendered.
### Other call sites
- Grep so far shows `ankiNotesInfo` is only used in `EpisodeDetail.tsx`. Re-verify before landing the commit; if another call site appears, apply the same filter.
---
## Item 7 — Trend/watch chart clarity pass
### Current behavior
`TrendChart.tsx`, `StackedTrendChart.tsx`, and `WatchTimeChart.tsx` render Recharts components with:
- No `CartesianGrid` → no horizontal reference lines.
- 9px axis ticks → borderline unreadable.
- Height 120 → cramped.
- Tooltip uses raw labels (`04/04` etc.).
- No shared theme object; each chart redefines colors and tooltip styles inline.
`stats/src/lib/chart-theme.ts` already exists and currently exports a single `CHART_THEME` constant with tick/tooltip colors and `barFill`. It will be extended, not replaced, to preserve existing consumers.
### Target behavior
All three charts share a theme, have horizontal gridlines, readable ticks, and sensible tooltips.
### Implementation
Extend `stats/src/lib/chart-theme.ts` with the additional shared defaults (keeping the existing `CHART_THEME` export intact so current consumers don't break):
```ts
export const CHART_THEME = {
tick: '#a5adcb',
tooltipBg: '#363a4f',
tooltipBorder: '#494d64',
tooltipText: '#cad3f5',
tooltipLabel: '#b8c0e0',
barFill: '#8aadf4',
grid: '#494d64',
axisLine: '#494d64',
} as const;
export const CHART_DEFAULTS = {
height: 160,
tickFontSize: 11,
margin: { top: 8, right: 8, bottom: 0, left: 0 },
grid: { strokeDasharray: '3 3', vertical: false },
} as const;
export const TOOLTIP_CONTENT_STYLE = {
background: CHART_THEME.tooltipBg,
border: `1px solid ${CHART_THEME.tooltipBorder}`,
borderRadius: 6,
color: CHART_THEME.tooltipText,
fontSize: 12,
};
```
Apply to each chart:
- Import `CartesianGrid` from recharts.
- Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` inside each chart container.
- `<XAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} />` and equivalent `YAxis`.
- `YAxis` gains `axisLine={{ stroke: CHART_THEME.axisLine }}`.
- `ResponsiveContainer` height changes from 120 → `CHART_DEFAULTS.height`.
- `Tooltip` `contentStyle` uses `TOOLTIP_CONTENT_STYLE`, and charts pass a `labelFormatter` when the label is a date key (e.g. show `Fri Apr 4`).
### Unit formatters
- `TrendChart` already accepts a `formatter` prop — extend usage sites to pass unit-aware formatters where they aren't already (`formatDuration`, `formatNumber`, etc.).
### Tests
- `chart-theme.test.ts` (if present — otherwise add a trivial snapshot to keep the shape stable).
- `TrendChart` snapshot/render tests: no regression, gridline element present.
---
## Verification gate
Before requesting code review, run:
```
bun run typecheck
bun run test:fast
bun run test:env
bun run test:runtime:compat # dist-sensitive check for the charts
bun run build
bun run test:smoke:dist
```
No docs-site changes are planned in this spec; if `docs-site/` ends up touched (e.g. screenshots), also run `bun run docs:test` and `bun run docs:build`.
No config schema changes → `bun run test:config` and `bun run generate:config-example` are not required.
## Risks and open questions
- **MediaDetailView refresh**: `useMediaLibrary` may not expose a `refresh` function. If it doesn't, the simplest path is adding one; the alternative (keying a remount) works but is harder to test. Decide during implementation.
- **Session bucket delete UX**: "Delete all N sessions in this group" is powerful. The copy must make it clear the underlying sessions are being removed, not just the grouping. Reuse `confirmBucketDelete` wording from existing confirm helpers if possible.
- **Anki-deleted-cards hidden notice**: Showing a subtle "N cards hidden" footer is a call that can be made at PR review.
- **Bucket delete helper**: `confirmBucketDelete` does not currently exist in `delete-confirm.ts`. Implementation either adds it or reuses `confirmDayGroupDelete` with bucket-specific wording — decide during the session-rollup commit.
## Changelog entry
User-visible PR → needs a fragment under `changes/*.md`. Suggested title:
`Stats dashboard: collapsible series, session rollups, 365d trends, chart polish, episode delete.`
+37 -1
View File
@@ -190,7 +190,43 @@ test('dictionary command forwards --dictionary and target path to app binary', (
}); });
assert.equal(handled, true); assert.equal(handled, true);
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]); assert.deepEqual(forwarded, [['--start', '--dictionary', '--dictionary-target', '/tmp/anime']]);
});
test('dictionary command forwards candidate and selection modes to app binary', () => {
const candidatesContext = createContext();
candidatesContext.args.dictionary = true;
candidatesContext.args.dictionaryCandidates = true;
candidatesContext.args.dictionaryTarget = '/tmp/anime.mkv';
const selectContext = createContext();
selectContext.args.dictionary = true;
selectContext.args.dictionarySelect = true;
selectContext.args.dictionaryAnilistId = 21355;
selectContext.args.dictionaryTarget = '/tmp/anime.mkv';
const forwarded: string[][] = [];
runDictionaryCommand(candidatesContext, {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
});
runDictionaryCommand(selectContext, {
runAppCommandWithInherit: (_appPath, appArgs) => {
forwarded.push(appArgs);
},
});
assert.deepEqual(forwarded, [
['--start', '--dictionary-candidates', '--dictionary-target', '/tmp/anime.mkv'],
[
'--start',
'--dictionary-select',
'--dictionary-anilist-id',
'21355',
'--dictionary-target',
'/tmp/anime.mkv',
],
]);
}); });
test('dictionary command returns after app handoff starts', () => { test('dictionary command returns after app handoff starts', () => {
+14 -1
View File
@@ -18,7 +18,20 @@ export function runDictionaryCommand(
return false; return false;
} }
const forwarded = ['--dictionary']; const forwarded = [
'--start',
args.dictionaryCandidates
? '--dictionary-candidates'
: args.dictionarySelect
? '--dictionary-select'
: '--dictionary',
];
if (args.dictionarySelect) {
if (!args.dictionaryAnilistId) {
throw new Error('Dictionary selection requires an AniList media ID.');
}
forwarded.push('--dictionary-anilist-id', String(args.dictionaryAnilistId));
}
if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) { if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) {
forwarded.push('--dictionary-target', args.dictionaryTarget); forwarded.push('--dictionary-target', args.dictionaryTarget);
} }
@@ -28,6 +28,7 @@ function createContext(): LauncherCommandContext {
useTexthooker: false, useTexthooker: false,
autoStartOverlay: false, autoStartOverlay: false,
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'info', logLevel: 'info',
passwordStore: '', passwordStore: '',
@@ -44,6 +45,8 @@ function createContext(): LauncherCommandContext {
jellyfinPlay: false, jellyfinPlay: false,
jellyfinDiscovery: false, jellyfinDiscovery: false,
dictionary: false, dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false, stats: false,
doctor: false, doctor: false,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
+37
View File
@@ -129,6 +129,9 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
dictionaryTriggered: false, dictionaryTriggered: false,
dictionaryTarget: null, dictionaryTarget: null,
dictionaryLogLevel: null, dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false, statsTriggered: false,
statsBackground: false, statsBackground: false,
statsStop: false, statsStop: false,
@@ -141,6 +144,7 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
texthookerTriggered: false, texthookerTriggered: false,
texthookerLogLevel: null, texthookerLogLevel: null,
texthookerOpenBrowser: false,
}); });
assert.equal(parsed.jellyfin, false); assert.equal(parsed.jellyfin, false);
@@ -154,3 +158,36 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
assert.equal(parsed.configShow, true); assert.equal(parsed.configShow, true);
assert.equal(parsed.logLevel, 'warn'); assert.equal(parsed.logLevel, 'warn');
}); });
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
const parsed = createDefaultArgs({});
applyInvocationsToArgs(parsed, {
jellyfinInvocation: null,
configInvocation: null,
mpvInvocation: null,
appInvocation: null,
dictionaryTriggered: false,
dictionaryTarget: null,
dictionaryLogLevel: null,
dictionaryCandidates: false,
dictionarySelect: false,
dictionaryAnilistId: null,
statsTriggered: false,
statsBackground: false,
statsStop: false,
statsCleanup: false,
statsCleanupVocab: false,
statsCleanupLifetime: false,
statsLogLevel: null,
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
texthookerTriggered: true,
texthookerLogLevel: null,
texthookerOpenBrowser: true,
});
assert.equal(parsed.texthookerOnly, true);
assert.equal(parsed.texthookerOpenBrowser, true);
});
+23
View File
@@ -89,6 +89,14 @@ function parseDictionaryTarget(value: string): string {
return resolved; return resolved;
} }
function parseDictionaryAnilistId(value: string): number {
const id = Number.parseInt(value, 10);
if (!Number.isSafeInteger(id) || id <= 0 || String(id) !== value.trim()) {
fail(`Invalid AniList media ID: ${value}`);
}
return id;
}
export function createDefaultArgs( export function createDefaultArgs(
launcherConfig: LauncherYoutubeSubgenConfig, launcherConfig: LauncherYoutubeSubgenConfig,
mpvConfig: LauncherMpvConfig = {}, mpvConfig: LauncherMpvConfig = {},
@@ -138,6 +146,8 @@ export function createDefaultArgs(
jellyfinPlay: false, jellyfinPlay: false,
jellyfinDiscovery: false, jellyfinDiscovery: false,
dictionary: false, dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false, stats: false,
statsBackground: false, statsBackground: false,
statsStop: false, statsStop: false,
@@ -174,6 +184,7 @@ export function createDefaultArgs(
useTexthooker: true, useTexthooker: true,
autoStartOverlay: false, autoStartOverlay: false,
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'info', logLevel: 'info',
passwordStore: '', passwordStore: '',
@@ -214,6 +225,11 @@ export function applyRootOptionsToArgs(
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void { export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
if (invocations.dictionaryTriggered) parsed.dictionary = true; if (invocations.dictionaryTriggered) parsed.dictionary = true;
if (invocations.dictionaryCandidates) parsed.dictionaryCandidates = true;
if (invocations.dictionarySelect) parsed.dictionarySelect = true;
if (invocations.dictionaryAnilistId) {
parsed.dictionaryAnilistId = parseDictionaryAnilistId(invocations.dictionaryAnilistId);
}
if (invocations.statsTriggered) parsed.stats = true; if (invocations.statsTriggered) parsed.stats = true;
if (invocations.statsBackground) parsed.statsBackground = true; if (invocations.statsBackground) parsed.statsBackground = true;
if (invocations.statsStop) parsed.statsStop = true; if (invocations.statsStop) parsed.statsStop = true;
@@ -222,10 +238,17 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true; if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
if (invocations.dictionaryTarget) { if (invocations.dictionaryTarget) {
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget); parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
} else if (
invocations.dictionaryTriggered &&
!invocations.dictionaryCandidates &&
!invocations.dictionarySelect
) {
fail('Dictionary target path is required.');
} }
if (invocations.doctorTriggered) parsed.doctor = true; if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true; if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.texthookerTriggered) parsed.texthookerOnly = true; if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true;
if (invocations.jellyfinInvocation) { if (invocations.jellyfinInvocation) {
if (invocations.jellyfinInvocation.logLevel) { if (invocations.jellyfinInvocation.logLevel) {
@@ -35,3 +35,10 @@ test('parseCliPrograms routes app alias arguments through passthrough mode', ()
appArgs: ['--anilist', '--log-level', 'debug'], appArgs: ['--anilist', '--log-level', 'debug'],
}); });
}); });
test('parseCliPrograms captures texthooker browser-open flag', () => {
const result = parseCliPrograms(['texthooker', '-o'], 'subminer');
assert.equal(result.invocations.texthookerTriggered, true);
assert.equal(result.invocations.texthookerOpenBrowser, true);
});
+28 -4
View File
@@ -27,6 +27,9 @@ export interface CliInvocations {
dictionaryTriggered: boolean; dictionaryTriggered: boolean;
dictionaryTarget: string | null; dictionaryTarget: string | null;
dictionaryLogLevel: string | null; dictionaryLogLevel: string | null;
dictionaryCandidates: boolean;
dictionarySelect: boolean;
dictionaryAnilistId: string | null;
statsTriggered: boolean; statsTriggered: boolean;
statsBackground: boolean; statsBackground: boolean;
statsStop: boolean; statsStop: boolean;
@@ -39,6 +42,7 @@ export interface CliInvocations {
doctorRefreshKnownWords: boolean; doctorRefreshKnownWords: boolean;
texthookerTriggered: boolean; texthookerTriggered: boolean;
texthookerLogLevel: string | null; texthookerLogLevel: string | null;
texthookerOpenBrowser: boolean;
} }
function applyRootOptions(program: Command): void { function applyRootOptions(program: Command): void {
@@ -136,6 +140,9 @@ export function parseCliPrograms(
let dictionaryTriggered = false; let dictionaryTriggered = false;
let dictionaryTarget: string | null = null; let dictionaryTarget: string | null = null;
let dictionaryLogLevel: string | null = null; let dictionaryLogLevel: string | null = null;
let dictionaryCandidates = false;
let dictionarySelect = false;
let dictionaryAnilistId: string | null = null;
let statsTriggered = false; let statsTriggered = false;
let statsBackground = false; let statsBackground = false;
let statsStop = false; let statsStop = false;
@@ -146,6 +153,7 @@ export function parseCliPrograms(
let doctorLogLevel: string | null = null; let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false; let doctorRefreshKnownWords = false;
let texthookerLogLevel: string | null = null; let texthookerLogLevel: string | null = null;
let texthookerOpenBrowser = false;
let doctorTriggered = false; let doctorTriggered = false;
let texthookerTriggered = false; let texthookerTriggered = false;
@@ -207,13 +215,23 @@ export function parseCliPrograms(
commandProgram commandProgram
.command('dictionary') .command('dictionary')
.alias('dict') .alias('dict')
.description('Generate character dictionary ZIP from a file or directory target') .description('Generate or correct character dictionary AniList matches')
.argument('<target>', 'Video file path or anime directory path') .argument('[target]', 'Video file path or anime directory path')
.option('--candidates', 'List AniList candidates for a character dictionary target')
.option('--select <id>', 'Pin an AniList media ID for the target series')
.option('--log-level <level>', 'Log level') .option('--log-level <level>', 'Log level')
.action((target: string, options: Record<string, unknown>) => { .action((target: string | undefined, options: Record<string, unknown>) => {
const selectValue = typeof options.select === 'string' ? options.select.trim() : '';
const hasSelect = selectValue.length > 0;
if (options.candidates === true && hasSelect) {
throw new Error('Dictionary --candidates and --select cannot be combined.');
}
dictionaryTriggered = true; dictionaryTriggered = true;
dictionaryTarget = target; dictionaryTarget = target ?? null;
dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
dictionaryCandidates = options.candidates === true;
dictionarySelect = hasSelect;
dictionaryAnilistId = hasSelect ? selectValue : null;
}); });
commandProgram commandProgram
@@ -297,10 +315,12 @@ export function parseCliPrograms(
commandProgram commandProgram
.command('texthooker') .command('texthooker')
.description('Launch texthooker-only mode') .description('Launch texthooker-only mode')
.option('-o, --open-browser', 'Open texthooker in the default browser')
.option('--log-level <level>', 'Log level') .option('--log-level <level>', 'Log level')
.action((options: Record<string, unknown>) => { .action((options: Record<string, unknown>) => {
texthookerTriggered = true; texthookerTriggered = true;
texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
texthookerOpenBrowser = options.openBrowser === true;
}); });
commandProgram commandProgram
@@ -338,6 +358,9 @@ export function parseCliPrograms(
dictionaryTriggered, dictionaryTriggered,
dictionaryTarget, dictionaryTarget,
dictionaryLogLevel, dictionaryLogLevel,
dictionaryCandidates,
dictionarySelect,
dictionaryAnilistId,
statsTriggered, statsTriggered,
statsBackground, statsBackground,
statsStop, statsStop,
@@ -350,6 +373,7 @@ export function parseCliPrograms(
doctorRefreshKnownWords, doctorRefreshKnownWords,
texthookerTriggered, texthookerTriggered,
texthookerLogLevel, texthookerLogLevel,
texthookerOpenBrowser,
}, },
}; };
} }
+34 -1
View File
@@ -464,7 +464,40 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
assert.equal(result.status, 0); assert.equal(result.status, 0);
assert.equal( assert.equal(
fs.readFileSync(capturePath, 'utf8'), fs.readFileSync(capturePath, 'utf8'),
`--dictionary\n--dictionary-target\n${targetPath}\n`, `--start\n--dictionary\n--dictionary-target\n${targetPath}\n`,
);
});
});
test('dictionary command forwards manual AniList selection modes to app command path', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const targetPath = path.join(root, 'anime.mkv');
fs.writeFileSync(targetPath, '');
assert.equal(runLauncher(['dictionary', '--candidates', targetPath], env).status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
`--start\n--dictionary-candidates\n--dictionary-target\n${targetPath}\n`,
);
assert.equal(runLauncher(['dictionary', '--select', '21355', targetPath], env).status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
`--start\n--dictionary-select\n--dictionary-anilist-id\n21355\n--dictionary-target\n${targetPath}\n`,
); );
}); });
}); });
+26
View File
@@ -270,6 +270,29 @@ test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', ()
assert.equal(error.code, 1); assert.equal(error.code, 1);
}); });
test('launchTexthookerOnly forwards browser-open request to app command', () => {
const { dir } = createTempSocketPath();
const appPath = path.join(dir, 'fake-subminer.sh');
const argsPath = path.join(dir, 'args.txt');
const openedUrls: string[] = [];
fs.writeFileSync(appPath, `#!/bin/sh\nprintf '%s\\n' "$@" > "${argsPath}"\nexit 0\n`);
fs.chmodSync(appPath, 0o755);
const error = withProcessExitIntercept(() => {
launchTexthookerOnly(appPath, makeArgs({ logLevel: 'info', texthookerOpenBrowser: true }), {
openBrowser: (url) => openedUrls.push(url),
});
});
assert.equal(error.code, 0);
assert.deepEqual(fs.readFileSync(argsPath, 'utf8').trim().split('\n'), [
'--texthooker',
'--open-browser',
]);
assert.deepEqual(openedUrls, ['http://127.0.0.1:5174']);
fs.rmSync(dir, { recursive: true, force: true });
});
test('launchAppCommandDetached handles child process spawn errors', async () => { test('launchAppCommandDetached handles child process spawn errors', async () => {
let uncaughtError: Error | null = null; let uncaughtError: Error | null = null;
const onUncaughtException = (error: Error) => { const onUncaughtException = (error: Error) => {
@@ -399,6 +422,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
useTexthooker: false, useTexthooker: false,
autoStartOverlay: false, autoStartOverlay: false,
texthookerOnly: false, texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false, useRofi: false,
logLevel: 'error', logLevel: 'error',
passwordStore: '', passwordStore: '',
@@ -415,6 +439,8 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
jellyfinPlay: false, jellyfinPlay: false,
jellyfinDiscovery: false, jellyfinDiscovery: false,
dictionary: false, dictionary: false,
dictionaryCandidates: false,
dictionarySelect: false,
stats: false, stats: false,
doctor: false, doctor: false,
doctorRefreshKnownWords: false, doctorRefreshKnownWords: false,
+30 -1
View File
@@ -831,8 +831,30 @@ export async function startOverlay(
} }
} }
export function launchTexthookerOnly(appPath: string, args: Args): never { export function openUrlInDefaultBrowser(url: string, logLevel: LogLevel): void {
const target =
process.platform === 'darwin'
? { command: 'open', args: [url] }
: process.platform === 'win32'
? { command: 'cmd', args: ['/c', 'start', '', url] }
: { command: 'xdg-open', args: [url] };
const result = spawnSync(target.command, target.args, {
stdio: 'ignore',
env: process.env,
windowsHide: true,
});
if (result.error) {
log('warn', logLevel, `Failed to open browser for ${url}: ${result.error.message}`);
}
}
export function launchTexthookerOnly(
appPath: string,
args: Args,
deps: { openBrowser?: (url: string) => void } = {},
): never {
const overlayArgs = ['--texthooker']; const overlayArgs = ['--texthooker'];
if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser');
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
log('info', args.logLevel, 'Launching texthooker mode...'); log('info', args.logLevel, 'Launching texthooker mode...');
@@ -840,6 +862,13 @@ export function launchTexthookerOnly(appPath: string, args: Args): never {
if (result.error) { if (result.error) {
fail(`Failed to launch texthooker mode: ${result.error.message}`); fail(`Failed to launch texthooker mode: ${result.error.message}`);
} }
if (args.texthookerOpenBrowser && (result.status ?? 0) === 0) {
const url = 'http://127.0.0.1:5174';
const openBrowser =
deps.openBrowser ??
((browserUrl: string) => openUrlInDefaultBrowser(browserUrl, args.logLevel));
openBrowser(url);
}
process.exit(result.status ?? 0); process.exit(result.status ?? 0);
} }
+19
View File
@@ -99,6 +99,25 @@ test('parseArgs maps dictionary command and log-level override', () => {
assert.equal(parsed.logLevel, 'debug'); assert.equal(parsed.logLevel, 'debug');
}); });
test('parseArgs maps dictionary candidate lookup and manual selection', () => {
const candidateParsed = parseArgs(['dictionary', '--candidates', '.'], 'subminer', {});
assert.equal(candidateParsed.dictionaryCandidates, true);
assert.equal(candidateParsed.dictionaryTarget, process.cwd());
const selectParsed = parseArgs(['dictionary', '--select', '21355', '.'], 'subminer', {});
assert.equal(selectParsed.dictionarySelect, true);
assert.equal(selectParsed.dictionaryAnilistId, 21355);
assert.equal(selectParsed.dictionaryTarget, process.cwd());
});
test('parseArgs rejects conflicting dictionary candidate and selection modes', () => {
const exit = withProcessExitIntercept(() => {
parseArgs(['dictionary', '--candidates', '--select', '21355', '.'], 'subminer', {});
});
assert.equal(exit.code, 1);
});
test('parseArgs maps stats command and log-level override', () => { test('parseArgs maps stats command and log-level override', () => {
const parsed = parseArgs(['stats', '--log-level', 'debug'], 'subminer', {}); const parsed = parseArgs(['stats', '--log-level', 'debug'], 'subminer', {});
+4
View File
@@ -105,6 +105,7 @@ export interface Args {
useTexthooker: boolean; useTexthooker: boolean;
autoStartOverlay: boolean; autoStartOverlay: boolean;
texthookerOnly: boolean; texthookerOnly: boolean;
texthookerOpenBrowser: boolean;
useRofi: boolean; useRofi: boolean;
logLevel: LogLevel; logLevel: LogLevel;
passwordStore: string; passwordStore: string;
@@ -121,6 +122,9 @@ export interface Args {
jellyfinPlay: boolean; jellyfinPlay: boolean;
jellyfinDiscovery: boolean; jellyfinDiscovery: boolean;
dictionary: boolean; dictionary: boolean;
dictionaryCandidates: boolean;
dictionarySelect: boolean;
dictionaryAnilistId?: number;
stats: boolean; stats: boolean;
statsBackground?: boolean; statsBackground?: boolean;
statsStop?: boolean; statsStop?: boolean;
+5 -5
View File
@@ -2,7 +2,7 @@
"name": "subminer", "name": "subminer",
"productName": "SubMiner", "productName": "SubMiner",
"desktopName": "SubMiner.desktop", "desktopName": "SubMiner.desktop",
"version": "0.12.0-beta.2", "version": "0.12.0",
"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",
@@ -20,7 +20,7 @@
"dev:stats": "cd stats && bun run dev", "dev:stats": "cd stats && bun run dev",
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets", "build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap", "build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
"changelog:build": "bun run scripts/build-changelog.ts build && bun run changelog:docs", "changelog:build": "bun run scripts/build-changelog.ts build-release",
"changelog:check": "bun run scripts/build-changelog.ts check", "changelog:check": "bun run scripts/build-changelog.ts check",
"changelog:docs": "bun run scripts/build-changelog.ts docs", "changelog:docs": "bun run scripts/build-changelog.ts docs",
"changelog:lint": "bun run scripts/build-changelog.ts lint", "changelog:lint": "bun run scripts/build-changelog.ts lint",
@@ -45,10 +45,10 @@
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts", "test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts",
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/integrations.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js", "test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/integrations.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js",
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js", "test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
"test:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua", "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts", "test:core:src": "bun test src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts",
"test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:dist": "bun test dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
@@ -70,7 +70,7 @@
"test:launcher": "bun run test:launcher:src", "test:launcher": "bun run test:launcher:src",
"test:core": "bun run test:core:src", "test:core": "bun run test:core:src",
"test:subtitle": "bun run test:subtitle:src", "test:subtitle": "bun run test:subtitle:src",
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js", "test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
"generate:config-example": "bun run src/generate-config-example.ts", "generate:config-example": "bun run src/generate-config-example.ts",
"verify:config-example": "bun run src/verify-config-example.ts", "verify:config-example": "bun run src/verify-config-example.ts",
"start": "bun run build && electron . --start", "start": "bun run build && electron . --start",
+5 -1
View File
@@ -14,7 +14,7 @@ function M.init()
local utils = require("mp.utils") local utils = require("mp.utils")
local options_helper = require("options") local options_helper = require("options")
local environment = require("environment").create({ mp = mp }) local environment = require("environment").create({ mp = mp, utils = utils })
local opts = options_helper.load(options_lib, environment.default_socket_path()) local opts = options_helper.load(options_lib, environment.default_socket_path())
local state = require("state").new() local state = require("state").new()
@@ -61,6 +61,9 @@ function M.init()
ctx.process = make_lazy_proxy("process", function() ctx.process = make_lazy_proxy("process", function()
return require("process").create(ctx) return require("process").create(ctx)
end) end)
ctx.session_bindings = make_lazy_proxy("session_bindings", function()
return require("session_bindings").create(ctx)
end)
ctx.ui = make_lazy_proxy("ui", function() ctx.ui = make_lazy_proxy("ui", function()
return require("ui").create(ctx) return require("ui").create(ctx)
end) end)
@@ -72,6 +75,7 @@ function M.init()
end) end)
ctx.ui.register_keybindings() ctx.ui.register_keybindings()
ctx.session_bindings.register_bindings()
ctx.messages.register_script_messages() ctx.messages.register_script_messages()
ctx.lifecycle.register_lifecycle_hooks() ctx.lifecycle.register_lifecycle_hooks()
ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded") ctx.log.subminer_log("info", "lifecycle", "SubMiner plugin loaded")
+66 -11
View File
@@ -1,7 +1,9 @@
local M = {} local M = {}
local unpack_fn = table.unpack or unpack
function M.create(ctx) function M.create(ctx)
local mp = ctx.mp local mp = ctx.mp
local utils = ctx.utils
local detected_backend = nil local detected_backend = nil
local app_running_cache_value = nil local app_running_cache_value = nil
@@ -30,6 +32,63 @@ function M.create(ctx)
return "/tmp/subminer-socket" return "/tmp/subminer-socket"
end end
local function path_separator()
return is_windows() and "\\" or "/"
end
local function join_path(...)
local parts = { ... }
if utils and type(utils.join_path) == "function" then
return utils.join_path(unpack_fn(parts))
end
return table.concat(parts, path_separator())
end
local function file_exists(path)
if not utils or type(utils.file_info) ~= "function" then
return false
end
return utils.file_info(path) ~= nil
end
local function resolve_subminer_config_dir()
local home = os.getenv("HOME") or os.getenv("USERPROFILE") or ""
local candidates = {}
if is_windows() then
local app_data = os.getenv("APPDATA") or join_path(home, "AppData", "Roaming")
candidates = {
join_path(app_data, "SubMiner"),
}
else
local xdg_config_home = os.getenv("XDG_CONFIG_HOME")
local primary_base = (type(xdg_config_home) == "string" and xdg_config_home ~= "")
and xdg_config_home
or join_path(home, ".config")
candidates = {
join_path(primary_base, "SubMiner"),
join_path(home, ".config", "SubMiner"),
}
end
for _, dir in ipairs(candidates) do
if file_exists(join_path(dir, "config.jsonc")) or file_exists(join_path(dir, "config.json")) then
return dir
end
end
for _, dir in ipairs(candidates) do
if file_exists(dir) then
return dir
end
end
return candidates[1]
end
local function resolve_session_bindings_artifact_path()
return join_path(resolve_subminer_config_dir(), "session-bindings.json")
end
local function is_linux() local function is_linux()
return not is_windows() and not is_macos() return not is_windows() and not is_macos()
end end
@@ -55,23 +114,17 @@ function M.create(ctx)
if not image then if not image then
image = line:match('^"([^"]+)"') image = line:match('^"([^"]+)"')
end end
if not image then if image then
goto continue
end
if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then
return true return true
end end
if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then
return true return true
end end
end
else else
local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)") local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)")
if not argv0 then if argv0 and not argv0:find("subminer.lua", 1, true) and not argv0:find("subminer.conf", 1, true) then
goto continue
end
if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then
goto continue
end
local exe = argv0:match("([^/\\]+)$") or argv0 local exe = argv0:match("([^/\\]+)$") or argv0
if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then
return true return true
@@ -80,8 +133,7 @@ function M.create(ctx)
return true return true
end end
end end
end
::continue::
end end
return false return false
end end
@@ -198,7 +250,10 @@ function M.create(ctx)
is_windows = is_windows, is_windows = is_windows,
is_macos = is_macos, is_macos = is_macos,
is_linux = is_linux, is_linux = is_linux,
join_path = join_path,
default_socket_path = default_socket_path, default_socket_path = default_socket_path,
resolve_subminer_config_dir = resolve_subminer_config_dir,
resolve_session_bindings_artifact_path = resolve_session_bindings_artifact_path,
is_subminer_process_running = is_subminer_process_running, is_subminer_process_running = is_subminer_process_running,
is_subminer_app_running = is_subminer_app_running, is_subminer_app_running = is_subminer_app_running,
is_subminer_app_running_async = is_subminer_app_running_async, is_subminer_app_running_async = is_subminer_app_running_async,
+2 -6
View File
@@ -189,10 +189,7 @@ function M.create(ctx)
local source_len = #plain local source_len = #plain
local cursor = 1 local cursor = 1
for _, token in ipairs(payload.tokens or {}) do for _, token in ipairs(payload.tokens or {}) do
if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then if type(token) == "table" and type(token.text) == "string" and token.text ~= "" then
goto continue
end
local token_text = token.text local token_text = token.text
local start_pos = nil local start_pos = nil
local end_pos = nil local end_pos = nil
@@ -222,8 +219,7 @@ function M.create(ctx)
end end
cursor = end_pos + 1 cursor = end_pos + 1
end end
end
::continue::
end end
return nil return nil
+52 -6
View File
@@ -11,6 +11,29 @@ function M.create(ctx)
local subminer_log = ctx.log.subminer_log local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd local show_osd = ctx.log.show_osd
local function resolve_media_identity()
local path = mp.get_property("path")
if type(path) == "string" and path ~= "" then
return path
end
local filename = mp.get_property("filename")
if type(filename) == "string" and filename ~= "" then
return filename
end
local media_title = mp.get_property("media-title")
if type(media_title) == "string" and media_title ~= "" then
return media_title
end
return nil
end
local function is_reload_end_file(reason)
return reason == "reload" or reason == "redirect"
end
local function schedule_aniskip_fetch(trigger_source, delay_seconds) local function schedule_aniskip_fetch(trigger_source, delay_seconds)
local delay = tonumber(delay_seconds) or 0 local delay = tonumber(delay_seconds) or 0
mp.add_timeout(delay, function() mp.add_timeout(delay, function()
@@ -41,6 +64,25 @@ function M.create(ctx)
end end
local function on_file_loaded() local function on_file_loaded()
local media_identity = resolve_media_identity()
local same_media_reload = (
media_identity ~= nil
and state.pending_reload_media_identity ~= nil
and media_identity == state.pending_reload_media_identity
)
state.pending_reload_media_identity = nil
state.current_media_identity = media_identity
if same_media_reload then
subminer_log("debug", "lifecycle", "Skipping startup lifecycle for same-media mpv reload")
if state.overlay_running and resolve_auto_start_enabled() and process.has_matching_mpv_ipc_socket(opts.socket_path) then
process.run_control_command_async("show-visible-overlay", {
socket_path = opts.socket_path,
})
end
return
end
aniskip.clear_aniskip_state() aniskip.clear_aniskip_state()
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
local has_matching_socket = rearm_managed_subtitle_defaults() local has_matching_socket = rearm_managed_subtitle_defaults()
@@ -73,10 +115,8 @@ function M.create(ctx)
aniskip.clear_aniskip_state() aniskip.clear_aniskip_state()
hover.clear_hover_overlay() hover.clear_hover_overlay()
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
if state.overlay_running then state.current_media_identity = nil
subminer_log("info", "lifecycle", "mpv shutting down, hiding SubMiner overlay") state.pending_reload_media_identity = nil
process.hide_visible_overlay()
end
end end
local function register_lifecycle_hooks() local function register_lifecycle_hooks()
@@ -85,10 +125,16 @@ function M.create(ctx)
mp.register_event("file-loaded", function() mp.register_event("file-loaded", function()
hover.clear_hover_overlay() hover.clear_hover_overlay()
end) end)
mp.register_event("end-file", function() mp.register_event("end-file", function(event)
process.disarm_auto_play_ready_gate() process.disarm_auto_play_ready_gate()
hover.clear_hover_overlay() hover.clear_hover_overlay()
if state.overlay_running then local reason = type(event) == "table" and event.reason or nil
if is_reload_end_file(reason) then
state.pending_reload_media_identity = state.current_media_identity or resolve_media_identity()
return
end
state.pending_reload_media_identity = nil
if state.overlay_running and reason ~= "quit" then
process.hide_visible_overlay() process.hide_visible_overlay()
end end
end) end)
+3
View File
@@ -47,6 +47,9 @@ function M.create(ctx)
mp.register_script_message("subminer-stats-toggle", function() mp.register_script_message("subminer-stats-toggle", function()
mp.osd_message("Stats: press ` (backtick) in overlay", 3) mp.osd_message("Stats: press ` (backtick) in overlay", 3)
end) end)
mp.register_script_message("subminer-reload-session-bindings", function()
ctx.session_bindings.reload_bindings()
end)
end end
return { return {
+36
View File
@@ -186,6 +186,9 @@ function M.create(ctx)
end end
if action == "start" then if action == "start" then
table.insert(args, "--background")
table.insert(args, "--managed-playback")
local backend = resolve_backend(overrides.backend) local backend = resolve_backend(overrides.backend)
if backend and backend ~= "" then if backend and backend ~= "" then
table.insert(args, "--backend") table.insert(args, "--backend")
@@ -229,6 +232,22 @@ function M.create(ctx)
end) end)
end end
local function run_binary_command_async(args, callback)
subminer_log("debug", "process", "Binary command: " .. table.concat(args, " "))
mp.command_native_async({
name = "subprocess",
args = args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
local ok = success and (result == nil or result.status == 0)
if callback then
callback(ok, result, error)
end
end)
end
local function parse_start_script_message_overrides(...) local function parse_start_script_message_overrides(...)
local overrides = {} local overrides = {}
for i = 1, select("#", ...) do for i = 1, select("#", ...) do
@@ -446,6 +465,21 @@ function M.create(ctx)
end) end)
end end
local function toggle_primary_subtitle_bar()
if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found")
show_osd("Error: binary not found")
return
end
run_control_command_async("toggle-primary-subtitle-bar", nil, function(ok)
if not ok then
subminer_log("warn", "process", "Primary subtitle bar toggle command failed")
show_osd("Primary subtitle toggle failed")
end
end)
end
local function open_options() local function open_options()
if not binary.ensure_binary_available() then if not binary.ensure_binary_available() then
subminer_log("error", "binary", "SubMiner binary not found") subminer_log("error", "binary", "SubMiner binary not found")
@@ -528,6 +562,7 @@ function M.create(ctx)
build_command_args = build_command_args, build_command_args = build_command_args,
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
run_control_command_async = run_control_command_async, run_control_command_async = run_control_command_async,
run_binary_command_async = run_binary_command_async,
parse_start_script_message_overrides = parse_start_script_message_overrides, parse_start_script_message_overrides = parse_start_script_message_overrides,
ensure_texthooker_running = ensure_texthooker_running, ensure_texthooker_running = ensure_texthooker_running,
start_overlay = start_overlay, start_overlay = start_overlay,
@@ -535,6 +570,7 @@ function M.create(ctx)
stop_overlay = stop_overlay, stop_overlay = stop_overlay,
hide_visible_overlay = hide_visible_overlay, hide_visible_overlay = hide_visible_overlay,
toggle_overlay = toggle_overlay, toggle_overlay = toggle_overlay,
toggle_primary_subtitle_bar = toggle_primary_subtitle_bar,
open_options = open_options, open_options = open_options,
restart_overlay = restart_overlay, restart_overlay = restart_overlay,
check_status = check_status, check_status = check_status,
+359
View File
@@ -0,0 +1,359 @@
local M = {}
local unpack_fn = table.unpack or unpack
local KEY_NAME_MAP = {
Space = "SPACE",
Tab = "TAB",
Enter = "ENTER",
Escape = "ESC",
Backspace = "BS",
Delete = "DEL",
ArrowUp = "UP",
ArrowDown = "DOWN",
ArrowLeft = "LEFT",
ArrowRight = "RIGHT",
Slash = "/",
Backslash = "\\",
Minus = "-",
Equal = "=",
Comma = ",",
Period = ".",
Quote = "'",
Semicolon = ";",
BracketLeft = "[",
BracketRight = "]",
Backquote = "`",
}
local MODIFIER_MAP = {
ctrl = "Ctrl",
alt = "Alt",
shift = "Shift",
meta = "Meta",
}
function M.create(ctx)
local mp = ctx.mp
local utils = ctx.utils
local state = ctx.state
local process = ctx.process
local environment = ctx.environment
local subminer_log = ctx.log.subminer_log
local show_osd = ctx.log.show_osd
local function read_file(path)
local handle = io.open(path, "r")
if not handle then
return nil
end
local content = handle:read("*a")
handle:close()
return content
end
local function remove_binding_names(names)
for _, name in ipairs(names) do
mp.remove_key_binding(name)
end
for index = #names, 1, -1 do
names[index] = nil
end
end
local function key_code_to_mpv_name(code)
if KEY_NAME_MAP[code] then
return KEY_NAME_MAP[code]
end
local letter = code:match("^Key([A-Z])$")
if letter then
return string.lower(letter)
end
local digit = code:match("^Digit([0-9])$")
if digit then
return digit
end
local function_key = code:match("^(F%d+)$")
if function_key then
return function_key
end
return nil
end
local function key_spec_to_mpv_binding(key)
if type(key) ~= "table" then
return nil
end
if type(key.code) ~= "string" then
return nil
end
if type(key.modifiers) ~= "table" then
return nil
end
local key_name = key_code_to_mpv_name(key.code)
if not key_name then
return nil
end
local parts = {}
for _, modifier in ipairs(key.modifiers) do
local mapped = MODIFIER_MAP[modifier]
if mapped then
parts[#parts + 1] = mapped
end
end
parts[#parts + 1] = key_name
return table.concat(parts, "+")
end
local function build_cli_args(action_id, payload)
if action_id == "toggleVisibleOverlay" then
return { "--toggle-visible-overlay" }
elseif action_id == "toggleStatsOverlay" then
return { "--toggle-stats-overlay" }
elseif action_id == "copySubtitle" then
return { "--copy-subtitle" }
elseif action_id == "copySubtitleMultiple" then
return { "--copy-subtitle-count", tostring(payload and payload.count or 1) }
elseif action_id == "updateLastCardFromClipboard" then
return { "--update-last-card-from-clipboard" }
elseif action_id == "triggerFieldGrouping" then
return { "--trigger-field-grouping" }
elseif action_id == "triggerSubsync" then
return { "--trigger-subsync" }
elseif action_id == "mineSentence" then
return { "--mine-sentence" }
elseif action_id == "mineSentenceMultiple" then
return { "--mine-sentence-count", tostring(payload and payload.count or 1) }
elseif action_id == "toggleSecondarySub" then
return { "--toggle-secondary-sub" }
elseif action_id == "toggleSubtitleSidebar" then
return { "--toggle-subtitle-sidebar" }
elseif action_id == "markAudioCard" then
return { "--mark-audio-card" }
elseif action_id == "openRuntimeOptions" then
return { "--open-runtime-options" }
elseif action_id == "openJimaku" then
return { "--open-jimaku" }
elseif action_id == "openYoutubePicker" then
return { "--open-youtube-picker" }
elseif action_id == "openSessionHelp" then
return { "--open-session-help" }
elseif action_id == "openControllerSelect" then
return { "--open-controller-select" }
elseif action_id == "openControllerDebug" then
return { "--open-controller-debug" }
elseif action_id == "openPlaylistBrowser" then
return { "--open-playlist-browser" }
elseif action_id == "replayCurrentSubtitle" then
return { "--replay-current-subtitle" }
elseif action_id == "playNextSubtitle" then
return { "--play-next-subtitle" }
elseif action_id == "shiftSubDelayPrevLine" then
return { "--shift-sub-delay-prev-line" }
elseif action_id == "shiftSubDelayNextLine" then
return { "--shift-sub-delay-next-line" }
elseif action_id == "cycleRuntimeOption" then
local runtime_option_id = payload and payload.runtimeOptionId or nil
if type(runtime_option_id) ~= "string" or runtime_option_id == "" then
return nil
end
local direction = payload and payload.direction == -1 and "prev" or "next"
return { "--cycle-runtime-option", runtime_option_id .. ":" .. direction }
end
return nil
end
local function invoke_cli_action(action_id, payload)
if not process.check_binary_available() then
show_osd("Error: binary not found")
return
end
local cli_args = build_cli_args(action_id, payload)
if not cli_args then
subminer_log("warn", "session-bindings", "No CLI mapping for action: " .. tostring(action_id))
return
end
local args = { state.binary_path }
for _, arg in ipairs(cli_args) do
args[#args + 1] = arg
end
local runner = process.run_binary_command_async
if type(runner) ~= "function" then
runner = function(binary_args, callback)
mp.command_native_async({
name = "subprocess",
args = binary_args,
playback_only = false,
capture_stdout = true,
capture_stderr = true,
}, function(success, result, error)
local ok = success and (result == nil or result.status == 0)
if callback then
callback(ok, result, error)
end
end)
end
end
runner(args, function(ok, result, error)
if ok then
return
end
local reason = error or (result and result.stderr) or "unknown error"
subminer_log("warn", "session-bindings", "Session action failed: " .. tostring(reason))
show_osd("Session action failed")
end)
end
local function clear_numeric_selection(show_cancelled)
if state.session_numeric_selection and state.session_numeric_selection.timeout then
state.session_numeric_selection.timeout:kill()
end
state.session_numeric_selection = nil
remove_binding_names(state.session_numeric_binding_names)
if show_cancelled then
show_osd("Cancelled")
end
end
local function start_numeric_selection(action_id, timeout_ms)
clear_numeric_selection(false)
for digit = 1, 9 do
local digit_string = tostring(digit)
local name = "subminer-session-digit-" .. digit_string
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] = name
mp.add_forced_key_binding(digit_string, name, function()
clear_numeric_selection(false)
invoke_cli_action(action_id, { count = digit })
end)
end
state.session_numeric_binding_names[#state.session_numeric_binding_names + 1] =
"subminer-session-digit-cancel"
mp.add_forced_key_binding("ESC", "subminer-session-digit-cancel", function()
clear_numeric_selection(true)
end)
state.session_numeric_selection = {
action_id = action_id,
timeout = mp.add_timeout((timeout_ms or 3000) / 1000, function()
clear_numeric_selection(false)
show_osd(action_id == "copySubtitleMultiple" and "Copy timeout" or "Mine timeout")
end),
}
show_osd(
action_id == "copySubtitleMultiple"
and "Copy how many lines? Press 1-9 (Esc to cancel)"
or "Mine how many lines? Press 1-9 (Esc to cancel)"
)
end
local function execute_mpv_command(command)
if type(command) ~= "table" or command[1] == nil then
return
end
mp.commandv(unpack_fn(command))
end
local function handle_binding(binding, numeric_selection_timeout_ms)
if binding.actionType == "mpv-command" then
execute_mpv_command(binding.command)
return
end
if binding.actionId == "copySubtitleMultiple" or binding.actionId == "mineSentenceMultiple" then
start_numeric_selection(binding.actionId, numeric_selection_timeout_ms)
return
end
invoke_cli_action(binding.actionId, binding.payload)
end
local function load_artifact()
local artifact_path = environment.resolve_session_bindings_artifact_path()
local raw = read_file(artifact_path)
if not raw or raw == "" then
return nil, "Missing session binding artifact: " .. tostring(artifact_path)
end
local parsed, parse_error = utils.parse_json(raw)
if not parsed then
return nil, "Failed to parse session binding artifact: " .. tostring(parse_error)
end
if type(parsed) ~= "table" or type(parsed.bindings) ~= "table" then
return nil, "Invalid session binding artifact"
end
return parsed, nil
end
local function clear_bindings()
clear_numeric_selection(false)
remove_binding_names(state.session_binding_names)
end
local function register_bindings()
local artifact, load_error = load_artifact()
if not artifact then
subminer_log("warn", "session-bindings", load_error)
return false
end
clear_numeric_selection(false)
local previous_binding_names = state.session_binding_names
local next_binding_names = {}
state.session_binding_generation = (state.session_binding_generation or 0) + 1
local generation = state.session_binding_generation
local timeout_ms = tonumber(artifact.numericSelectionTimeoutMs) or 3000
for index, binding in ipairs(artifact.bindings) do
local key_name = key_spec_to_mpv_binding(binding.key)
if key_name then
local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index)
next_binding_names[#next_binding_names + 1] = name
mp.add_forced_key_binding(key_name, name, function()
handle_binding(binding, timeout_ms)
end)
else
subminer_log(
"warn",
"session-bindings",
"Skipped unsupported key code from artifact: " .. tostring(binding.key and binding.key.code or "unknown")
)
end
end
remove_binding_names(previous_binding_names)
state.session_binding_names = next_binding_names
subminer_log(
"info",
"session-bindings",
"Registered " .. tostring(#next_binding_names) .. " shared session bindings"
)
return true
end
local function reload_bindings()
return register_bindings()
end
return {
register_bindings = register_bindings,
reload_bindings = reload_bindings,
clear_bindings = clear_bindings,
}
end
return M

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