mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-16 13:55:51 -07:00
Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
e11a5fea0d
|
|||
|
f73fe179d0
|
|||
| 2938e7a32a | |||
| 82f6b4705a | |||
| a02c33dac4 | |||
| 2174e689a2 | |||
| 7d729cb60c | |||
| d963def1a9 | |||
| 6fb3d2eb13 | |||
| c5a77ac067 | |||
|
8ddc151435
|
|||
| b98d4d65c7 | |||
|
046e74ea91
|
|||
| 8bf847503d | |||
|
47b5903392
|
|||
| bf85554d1e | |||
|
d74c7e1235
|
|||
| 57ddd19953 | |||
| ee25536d90 | |||
| 7b0fbdf254 | |||
| 2fefc83e3f | |||
| dbdf578c68 | |||
| 441ecf3c04 | |||
|
a0dde4ee3e
|
|||
| fe4dacc1e7 | |||
| b08cd0db35 | |||
| bffb1c5982 | |||
| 5b8848518a | |||
|
176edd67f1
|
|||
| 4d65dec340 | |||
|
6607c333bc
|
|||
|
b2bbf1ae12
|
|||
| b204d4dd6e | |||
| 89ed675935 |
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"name": "subminer-local",
|
||||
"interface": {
|
||||
"displayName": "SubMiner Local"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "subminer-workflow",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/subminer-workflow"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL"
|
||||
},
|
||||
"category": "Productivity"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,22 +1,45 @@
|
||||
---
|
||||
name: 'subminer-change-verification'
|
||||
description: 'Compatibility shim. Canonical SubMiner change verification workflow now lives in the repo-local subminer-workflow plugin.'
|
||||
name: subminer-change-verification
|
||||
description: Verify SubMiner changes with repo-native cheap-first test lanes. Use after code, config, launcher, plugin, runtime, stats, documentation, or workflow changes; do not use for read-only questions.
|
||||
---
|
||||
|
||||
# Compatibility Shim
|
||||
# SubMiner Change Verification
|
||||
|
||||
Canonical source:
|
||||
Verify the behavior claimed by a change without running unrelated expensive checks by default.
|
||||
|
||||
- `plugins/subminer-workflow/skills/subminer-change-verification/SKILL.md`
|
||||
## Workflow
|
||||
|
||||
Canonical helper scripts:
|
||||
1. Inspect the requested scope and changed paths with `git status --short` and `git diff`.
|
||||
2. Read `docs/workflow/verification.md` as the source of truth for maintained lanes.
|
||||
3. Run the cheapest lane or lanes that cover the changed behavior.
|
||||
4. Escalate to the full handoff gate only for substantial or cross-boundary changes.
|
||||
5. Report exact commands, results, skipped checks, blockers, and remaining risk.
|
||||
|
||||
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh`
|
||||
- `plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh`
|
||||
Do not use hidden wrapper commands. Verification commands are owned by `package.json` and the workflow documentation.
|
||||
|
||||
When this shim is invoked:
|
||||
## Lane Selection
|
||||
|
||||
1. Read the canonical plugin-owned skill.
|
||||
2. Follow the plugin-owned skill as the source of truth.
|
||||
3. Use the wrapper scripts in this shim directory only for compatibility with existing commands and docs.
|
||||
4. Do not duplicate workflow changes here; update the plugin-owned skill and scripts instead.
|
||||
- Internal docs, `AGENTS.md`, or `.agents/skills/**`: `bun run test:docs:kb`
|
||||
- User-facing `docs-site/**`: `bun run docs:test`, then `bun run docs:build`
|
||||
- Config/schema/defaults: `bun run test:config`
|
||||
- If defaults or templates changed, also run `bun run generate:config-example` and `bun run verify:config-example`.
|
||||
- General TypeScript source: `bun run typecheck`, then `bun run test:fast`
|
||||
- Launcher or mpv plugin: `bun run test:launcher` or `bun run test:env`, based on the behavior changed
|
||||
- Runtime compatibility or dist-sensitive wiring: `bun run test:runtime:compat`
|
||||
- Stats dashboard: `bun run test:stats`
|
||||
- Build/release scripts: `bun run test:scripts`
|
||||
|
||||
For substantial changes, use the full gate documented in `AGENTS.md` and `docs/workflow/verification.md`.
|
||||
|
||||
## Runtime Escalation
|
||||
|
||||
Real runtime checks are required when the claim depends on actual Electron, mpv, overlay, focus, window tracking, launch, or socket behavior. Run the relevant application flow when the environment supports it. Otherwise, report the missing runtime dependency and do not present cheaper checks as authoritative runtime validation.
|
||||
|
||||
## Pre-Handoff Checks
|
||||
|
||||
Before handoff, reconcile both questions:
|
||||
|
||||
1. Do behavior, defaults, flags, shortcuts, ports, APIs, architecture, or workflow changes require documentation updates?
|
||||
2. Does the change require a current-outcome fragment under `changes/` according to `changes/README.md`?
|
||||
|
||||
Complete required updates before handoff or report the blocker.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
REPO_ROOT=$(cd "$SCRIPT_DIR/../../../.." && pwd)
|
||||
TARGET="$REPO_ROOT/plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh"
|
||||
|
||||
if [[ ! -x "$TARGET" ]]; then
|
||||
echo "Missing canonical script: $TARGET" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$TARGET" "$@"
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
REPO_ROOT=$(cd "$SCRIPT_DIR/../../../.." && pwd)
|
||||
TARGET="$REPO_ROOT/plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh"
|
||||
|
||||
if [[ ! -x "$TARGET" ]]; then
|
||||
echo "Missing canonical script: $TARGET" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exec "$TARGET" "$@"
|
||||
@@ -1,18 +0,0 @@
|
||||
---
|
||||
name: 'subminer-scrum-master'
|
||||
description: 'Compatibility shim. Canonical SubMiner scrum-master workflow now lives in the repo-local subminer-workflow plugin.'
|
||||
---
|
||||
|
||||
# Compatibility Shim
|
||||
|
||||
Canonical source:
|
||||
|
||||
- `plugins/subminer-workflow/skills/subminer-scrum-master/SKILL.md`
|
||||
|
||||
When this shim is invoked:
|
||||
|
||||
1. Read the canonical plugin-owned skill.
|
||||
2. Follow the plugin-owned skill as the source of truth.
|
||||
3. Do not duplicate workflow changes here; update the plugin-owned skill instead.
|
||||
|
||||
This shim exists so existing repo references and prompts keep resolving during the migration to the repo-local plugin workflow.
|
||||
@@ -1,4 +1,4 @@
|
||||
# AGENTS.MD
|
||||
# AGENTS.md
|
||||
|
||||
## Internal Docs
|
||||
|
||||
@@ -13,7 +13,7 @@ Start here, then leave this file.
|
||||
|
||||
`docs-site/` is user-facing. Do not treat it as the canonical internal source of truth.
|
||||
|
||||
`CLAUDE.md` is a symlink to this file — there is one project instruction file, not two.
|
||||
`CLAUDE.md` is a symlink to this file; there is one project instruction file, not two.
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -25,8 +25,9 @@ Start here, then leave this file.
|
||||
|
||||
## Build / Test
|
||||
|
||||
- Runtime/package manager: Bun (`packageManager: bun@1.3.5`)
|
||||
- Default handoff gate:
|
||||
- Runtime/package manager: Bun; use the version pinned by `package.json`.
|
||||
- Follow [`docs/workflow/verification.md`](./docs/workflow/verification.md) and start with the cheapest sufficient lane.
|
||||
- Full handoff gate for substantial changes:
|
||||
`bun run typecheck`
|
||||
`bun run test:fast`
|
||||
`bun run test:env`
|
||||
@@ -44,13 +45,15 @@ Start here, then leave this file.
|
||||
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||
- Stats dashboard UI (`stats/`): `bun run test:stats`
|
||||
- Build/release scripts (`scripts/**`): `bun run test:scripts`
|
||||
- Docs-only: `bun run docs:test`, then `bun run docs:build`
|
||||
- Internal docs, `AGENTS.md`, or repo skills: `bun run test:docs:kb`
|
||||
- User-facing `docs-site/`: `bun run docs:test`, then `bun run docs:build`
|
||||
- macOS mpv window helper: `bun test scripts/get-mpv-window-macos.test.ts`
|
||||
- Test lanes are directory-discovered via `scripts/test-lanes.ts`; never hand-list test files in `package.json`
|
||||
|
||||
## Docs Upkeep
|
||||
|
||||
- Docs ship with the change, not after. If a change alters behavior, defaults, flags, shortcuts, ports, or APIs, update the matching docs in the same PR. Touching code without reconciling its docs is an incomplete change.
|
||||
- Source of truth for config defaults is the generated `config.example.jsonc`. Never write a default value into prose you didn't read from it — and don't restate the same default across multiple docs; cite/link to one place so there's a single thing to update.
|
||||
- Source of truth for config defaults is the generated `config.example.jsonc`. Never write a default value into prose you didn't read from it, and don't restate the same default across multiple docs; cite/link to one place so there's a single thing to update.
|
||||
- Trigger map (touch left → update right):
|
||||
- `src/config/definitions/**` (schema/defaults/template) → `bun run generate:config-example`, then reconcile `docs-site/configuration.md` + any feature doc that cites that default
|
||||
- shortcuts/keybindings (`shortcuts.*`, `keybindings`, `stats.*Key`, `subtitleSidebar.toggleKey`, controller bindings) → `docs-site/shortcuts.md`
|
||||
@@ -71,16 +74,10 @@ Start here, then leave this file.
|
||||
|
||||
## Release / PR Notes
|
||||
|
||||
- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md` — format and rules in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out)
|
||||
- User-visible PRs need reconciled current-outcome fragment(s) in `changes/*.md`. Format and rules live in [`changes/README.md`](./changes/README.md) (`type` + `area` keys required; inspect existing same-PR fragments, then update/remove stale bullets or add only genuinely separate outcomes; apply the `skip-changelog` label to opt out).
|
||||
- User-visible docs changes get a `type: docs` fragment
|
||||
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
|
||||
- PR review helpers:
|
||||
- `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'`
|
||||
- `gh pr view --json number,title --jq '"PR #\\(.number): \\(.title)"'`
|
||||
- `gh api repos/:owner/:repo/pulls/<num>/comments --paginate`
|
||||
|
||||
## Runtime Notes
|
||||
|
||||
- Use Codex background for long jobs; tmux only when persistence/interaction is required
|
||||
- CI red: `gh run list/view`, rerun, fix, repeat until green
|
||||
- TypeScript: keep files small; follow existing patterns
|
||||
- Only Swift is the `scripts/get-mpv-window-macos.swift` helper (macOS mpv window detection); validate via `bun test scripts/get-mpv-window-macos.test.ts`
|
||||
- For CI debugging, inspect runs with `gh run list/view`; rerun or fix only within the requested scope.
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.3 (2026-08-13)
|
||||
|
||||
### Added
|
||||
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
|
||||
|
||||
### Changed
|
||||
- Subtitle Tokenization Performance: Reworks subtitle dictionary lookups to cut per-line work roughly in half, cache repeated lookups across lines, and stop tokenization from competing with on-screen subtitle prefetching. Also fixes several accuracy issues along the way: dropped readings on trailing kana, character names being skipped after a dictionary sync, annotations not refreshing after mining a card, and halfwidth katakana character names losing their reading or being swallowed by other words.
|
||||
|
||||
### Fixed
|
||||
- Character Dictionary Large Imports: Large character dictionaries (e.g. One Piece) no longer fail to install from a fixed timeout budget; the import now scales its time budget to dictionary size and reports detailed progress (page/character counts, image download progress, elapsed time) instead of one static message.
|
||||
- Stats Delete Responsiveness: Deleting sessions, episodes, or library entries no longer freezes the stats page or an active video player; deletes are now batched into a single transaction.
|
||||
- Styled Subtitle Cue Parsing: Heavily typeset subtitles (karaoke, signs) no longer flood the subtitle sidebar with garbage; vector drawing commands are no longer shown as text, and duplicate/animation-burst cues now collapse into one.
|
||||
- X11 mpv Renderer: Fixes an mpv crash on the first fullscreen toggle for X11/XWayland users with `gpu-next` shaders (e.g. ArtCNN), which was caused by X11 mode forcing the legacy OpenGL renderer.
|
||||
- X11 Overlay Display Scaling: Fixes the overlay appearing oversized and offset from mpv on X11/XWayland under fractional or mixed-monitor display scaling.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Subtitle text is now decoded from ASS exactly once at ingest, so the renderer, timing tracker, and tokenizer all share one decoded value instead of each re-deriving it.
|
||||
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
|
||||
### Changed
|
||||
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||
|
||||
### Fixed
|
||||
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
### Internal
|
||||
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.1 (2026-08-01)
|
||||
|
||||
### Added
|
||||
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||
|
||||
### Fixed
|
||||
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||
|
||||
## v0.19.0 (2026-07-29)
|
||||
|
||||
### Added
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "^5.9.3",
|
||||
"undici": "7.28.0",
|
||||
"undici": "7.29.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -36,16 +36,18 @@
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"app-builder-lib": "26.15.3",
|
||||
"brace-expansion": "5.0.8",
|
||||
"brace-expansion": "5.0.9",
|
||||
"electron-builder-squirrel-windows": "26.15.3",
|
||||
"fast-uri": "3.1.5",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.0",
|
||||
"js-yaml": "4.3.1",
|
||||
"lodash": "4.18.0",
|
||||
"minimatch": "10.2.5",
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.21",
|
||||
"tmp": "0.2.7",
|
||||
"undici": "7.29.0",
|
||||
},
|
||||
"packages": {
|
||||
"@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
|
||||
@@ -266,7 +268,7 @@
|
||||
|
||||
"boolean": ["boolean@3.2.0", "", {}, "sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw=="],
|
||||
|
||||
"brace-expansion": ["brace-expansion@5.0.8", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg=="],
|
||||
"brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="],
|
||||
|
||||
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
|
||||
|
||||
@@ -404,7 +406,7 @@
|
||||
|
||||
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||
|
||||
"fast-uri": ["fast-uri@3.1.4", "", {}, "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw=="],
|
||||
"fast-uri": ["fast-uri@3.1.5", "", {}, "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
|
||||
@@ -496,7 +498,7 @@
|
||||
|
||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||
|
||||
"js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="],
|
||||
"js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="],
|
||||
|
||||
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||
|
||||
@@ -714,7 +716,7 @@
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici": ["undici@7.28.0", "", {}, "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA=="],
|
||||
"undici": ["undici@7.29.0", "", {}, "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
@@ -754,8 +756,6 @@
|
||||
|
||||
"@discordjs/rest/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||
|
||||
"@discordjs/rest/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
||||
|
||||
"@discordjs/util/discord-api-types": ["discord-api-types@0.38.43", "", {}, "sha512-sSoBf/nK6m7BGtw65mi+QBuvEWaHE8MMziFLqWL+gT6ME/BLg34dRSVKS3Husx40uU06bvxUc3/X+D9Y6/zAbw=="],
|
||||
|
||||
"@electron/asar/commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="],
|
||||
@@ -808,8 +808,6 @@
|
||||
|
||||
"node-gyp/env-paths": ["env-paths@2.2.1", "", {}, "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A=="],
|
||||
|
||||
"node-gyp/undici": ["undici@6.27.0", "", {}, "sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg=="],
|
||||
|
||||
"node-gyp/which": ["which@6.0.1", "", { "dependencies": { "isexe": "^4.0.0" }, "bin": { "node-which": "bin/which.js" } }, "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg=="],
|
||||
|
||||
"pkijs/@noble/hashes": ["@noble/hashes@1.4.0", "", {}, "sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg=="],
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: Anki media
|
||||
|
||||
- Fixed sentence-audio generation timing out on slow network-mounted MKV files with many subtitle and font-attachment streams. Selected audio tracks now use bounded FFmpeg probing and a two-minute extraction budget, and missing output reports a clear FFmpeg error instead of raw `ENOENT`.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: internal
|
||||
area: docs
|
||||
|
||||
- Excluded the `/main/` and `/v/<version>/` docs trees from search indexing with a self-referential canonical, `noindex,follow`, and a matching `X-Robots-Tag` header, so crawlers spend their budget on the current docs instead of ~30 archived copies of every page.
|
||||
- Restored `<lastmod>` dates in the docs sitemap, which were silently dropped because production builds render from an untracked release snapshot.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Typeset subtitles no longer flood the stats. Karaoke openings and animated signs are authored as one subtitle event per animation frame, and immersion tracking counted every frame, which was enough to put an OP lyric at the top of "Top Repeated Words" for good. Lines are now collapsed on the way in using the same rules the subtitle sidebar already applies: matching parsed timings record exactly the cues the sidebar shows, while shifted, changing, or unparsed sources use a strict fallback where identical, contiguous, sub-0.1s lines stop counting after a few frames. Ordinary repeated dialogue and rewatches are unaffected.
|
||||
- Added a cleanup for stats already affected. The Vocabulary tab has a **Duplicates** button that scans a chosen window (7 days through all time), shows the bursts it found and the word and kanji counts they added, and collapses each run to one line once confirmed. `subminer stats cleanup --duplicate-lines` does the same from the terminal, with `--dry-run` and `--lookback-days <n>`. Only subtitle lines and the vocabulary counts they feed are touched; watch time and lines-seen totals are left as recorded.
|
||||
@@ -0,0 +1,5 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Dedicated overlay modals are prewarmed on macOS and Windows so shortcuts open them promptly on the first press. Windows now refreshes the hidden modal renderer between sessions to keep later modals interactive. On macOS, reused modals and the in-app stats window also open above fullscreen mpv on its current Space instead of appearing on another desktop or forcing a Space change.
|
||||
- Updated subtitle ASS observation to mpv's current `sub-text/ass` property, removing its deprecation warning.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed native Wayland drag-and-drop from file managers such as Thunar so subtitle and video files dropped on the visible overlay are resolved and forwarded to mpv.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed system-wide mouse lag on Windows while SubMiner is running: the overlay no longer installs Electron's global mouse hook for click-through forwarding, and the mpv window tracker no longer blocks the app on repeated PowerShell command-line lookups.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: documentation
|
||||
|
||||
- Hid the unfinished feature demos page from the documentation sidebar while keeping its direct URL available.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: added
|
||||
area: stats
|
||||
|
||||
- Library: duplicate cards for the same show can now be combined. Press "Select" above the library grid, tick the cards, and use "Merge Selected"; the dialog picks which entry to keep and moves every episode onto it. Sessions, mined cards, and watch time are preserved, the emptied entries disappear, and remembered title aliases keep future episodes on the merged card.
|
||||
- Library: episodes can be reassigned to another library entry from the "→" button on an episode row, which is the fix when one file lands under a stray title (e.g. an episode name parsed as the series). Manual assignments now survive later filename parsing, Jellyfin refreshes, and season repair. Local episodes in the same directory reuse a uniquely corrected destination unless they parse to a title that already has its own library entry, while conflicting seasons or manual destinations are not forced together. Emptying an entry this way removes it and returns to the grid.
|
||||
- Library: exact AniList title matches with compatible seasons fold duplicate cards automatically. Fuzzy same-AniList matches appear as dismissible "Possible duplicate" reviews instead of changing the library without confirmation; conflicting explicit seasons are left alone.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: notifications
|
||||
|
||||
- Character dictionary progress notifications on Linux now update in place instead of flickering off and reappearing on every status change.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: anki
|
||||
|
||||
- Mined audio and animated AVIF clips now capture the subtitle line that was actually mined. The clip range is snapshotted once at Yomitan lookup time (and reused for both audio and image), instead of each generator reading the live mpv subtitle when it starts — which clipped whatever line was on screen after slow audio extraction finished, producing too-short or misaligned AVIF clips.
|
||||
@@ -0,0 +1,9 @@
|
||||
type: fixed
|
||||
area: stats
|
||||
|
||||
- Stats deletes no longer freeze the stats dashboard: the delete worker module now resolves when running from source, so deletes actually run off the serving thread instead of silently falling back to it.
|
||||
- Deletes now subtract their exact contribution from lifetime summaries instead of rebuilding them from retained sessions, making delete cost proportional to what is deleted and preserving lifetime totals older than the session retention window.
|
||||
- If the delete worker crashes, the delete now retries on the current thread instead of failing.
|
||||
- Library merges, video moves, AniList reassignments, and `subminer stats cleanup -l` also stopped rebuilding lifetime summaries from retained sessions; they now recompute from per-episode history, so those operations are faster and no longer erase lifetime totals older than the session retention window.
|
||||
- Deleting content that contains very common words no longer rescans every occurrence of those words across the whole library; first/last-seen dates are refreshed with index seeks instead.
|
||||
- Session deletes on large databases dropped from minutes to milliseconds: an index on the subtitle-line event reference now prevents each deleted session event from scanning the whole subtitle-line table for foreign-key enforcement.
|
||||
@@ -523,7 +523,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||
// Most other AnkiConnect settings still require restart.
|
||||
// ==========================================
|
||||
@@ -605,7 +605,10 @@
|
||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||
} // Is kiku setting.
|
||||
}, // Is kiku setting.
|
||||
"lapisKiku": {
|
||||
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
||||
} // Lapis kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
// ==========================================
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { extname, join, posix, resolve, sep } from 'node:path';
|
||||
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
|
||||
@@ -26,6 +27,9 @@ function optionalEnv(value: string | undefined): string | undefined {
|
||||
const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/');
|
||||
const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
|
||||
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
||||
// The tracked `docs-site/` checkout, which stays a git working tree even when
|
||||
// `docsSourceDir` points at an untracked release snapshot. Used for git lookups only.
|
||||
const repoDocsDir = optionalEnv(process.env.SUBMINER_DOCS_REPO_DIR) ?? process.cwd();
|
||||
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
||||
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
||||
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.18.0';
|
||||
@@ -82,15 +86,18 @@ function pageToRoute(page: string): string | null {
|
||||
return route ? `/${route}` : '/';
|
||||
}
|
||||
|
||||
// Only the root channel is indexable. `main` and every /v/<version>/ archive are
|
||||
// near-verbatim copies of it, so they own their URL via a self-referential canonical
|
||||
// and are excluded from the index instead of being consolidated onto root. Uniform
|
||||
// self-canonical plus noindex avoids mixing noindex with a cross-page canonical,
|
||||
// which Google treats as a conflicting signal.
|
||||
const isIndexableChannel = channel === 'stable-root';
|
||||
|
||||
function pageToCanonicalHref(page: string): string | null {
|
||||
const route = pageToRoute(page);
|
||||
if (!route) return null;
|
||||
|
||||
if (channel === 'main') {
|
||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||
}
|
||||
|
||||
if (channel === 'stable-archive' && docsVersion !== latestStable) {
|
||||
if (!isIndexableChannel) {
|
||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||
}
|
||||
|
||||
@@ -106,7 +113,9 @@ function transformPageHead({ page }: TransformContext): HeadConfig[] {
|
||||
const href = pageToCanonicalHref(page);
|
||||
const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : [];
|
||||
|
||||
if (channel === 'main') {
|
||||
// Crawlable so links still pass through, but out of the index: ~30 archived copies
|
||||
// of every page otherwise soak up the crawl budget the current docs need.
|
||||
if (!isIndexableChannel) {
|
||||
head.push(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||
}
|
||||
|
||||
@@ -287,6 +296,39 @@ const versionItems = [
|
||||
})),
|
||||
];
|
||||
|
||||
function sitemapUrlToPage(url: string): string {
|
||||
const route = url.replace(/\.html$/, '').replace(/^\/+|\/+$/g, '');
|
||||
return route ? `${route}.md` : 'index.md';
|
||||
}
|
||||
|
||||
// VitePress derives <lastmod> by running `git log` inside its source dir. Production
|
||||
// builds point that at an untracked snapshot of the release tag, so the lookup comes
|
||||
// back empty and the sitemap ships with no dates at all. Resolve it from the tracked
|
||||
// checkout at the ref being built instead.
|
||||
function lastModifiedFor(url: string): string | undefined {
|
||||
const ref = docsVersion && docsVersion !== 'main' ? docsVersion : 'HEAD';
|
||||
const result = spawnSync('git', ['log', '-1', '--format=%cI', ref, '--', sitemapUrlToPage(url)], {
|
||||
cwd: repoDocsDir,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
return (result.status === 0 && result.stdout.trim()) || undefined;
|
||||
}
|
||||
|
||||
// Only the root channel publishes a sitemap. Archived and `main` builds would emit
|
||||
// their own copies listing the same canonical URLs, which just advertises the
|
||||
// duplicate trees we are trying to keep out of the index.
|
||||
const sitemap: UserConfig['sitemap'] = isIndexableChannel
|
||||
? {
|
||||
hostname: DOCS_HOSTNAME,
|
||||
transformItems(items) {
|
||||
return items
|
||||
.filter((item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`)
|
||||
.map((item) => ({ ...item, lastmod: item.lastmod ?? lastModifiedFor(item.url) }));
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const nav: DefaultTheme.NavItem[] = [
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: 'Get Started', link: '/installation' },
|
||||
@@ -306,7 +348,6 @@ const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{ text: 'Usage', link: '/usage' },
|
||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||
{ text: 'Feature Demos', link: '/demos' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -420,14 +461,7 @@ const config: UserConfig = {
|
||||
appearance: 'dark',
|
||||
cleanUrls: true,
|
||||
metaChunk: true,
|
||||
sitemap: {
|
||||
hostname: DOCS_HOSTNAME,
|
||||
transformItems(items) {
|
||||
return items.filter(
|
||||
(item) => item.url !== 'README' && item.url !== `${DOCS_HOSTNAME}/README`,
|
||||
);
|
||||
},
|
||||
},
|
||||
sitemap,
|
||||
transformHead: transformPageHead,
|
||||
lastUpdated: true,
|
||||
srcExclude: ['subagents/**', 'README.md'],
|
||||
|
||||
+3
-1
@@ -38,8 +38,10 @@ bun run docs:dev
|
||||
The public docs root is stable-only:
|
||||
|
||||
- `/` serves the latest stable release docs.
|
||||
- `/main/` serves development docs from `main` and is marked `noindex,follow`.
|
||||
- `/main/` serves development docs from `main`.
|
||||
- `/v/<version>/` serves stable release archives.
|
||||
- Prerelease tags do not update the docs site.
|
||||
|
||||
Only `/` is indexable. `/main/` and every `/v/<version>/` page carries a self-referential canonical plus `noindex,follow`, and the generated `_headers` file repeats that as an `X-Robots-Tag`. They stay crawlable so their links still resolve, but ~30 archived copies of every page would otherwise consume the crawl budget the current docs need. Only the root build emits `sitemap.xml`, and its `<lastmod>` dates come from `git log` against the tracked checkout at the released tag, because the build renders from an untracked snapshot that VitePress cannot date itself.
|
||||
|
||||
Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments.
|
||||
|
||||
@@ -289,6 +289,21 @@ Trigger with the mine sentence shortcut (`Ctrl/Cmd+S` by default). The card is c
|
||||
|
||||
To mine multiple subtitle lines as one sentence card, use `Ctrl/Cmd+Shift+S` followed by a digit (1–9) to select how many recent lines to combine.
|
||||
|
||||
## Word Card Type (Kiku/Lapis)
|
||||
|
||||
Word cards get a card-type flag when SubMiner fills their sentence, whether that comes from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining. By default the flag is `IsWordAndSentenceCard`; pick a different one with `ankiConnect.lapisKiku.wordCardKind`.
|
||||
|
||||
```jsonc
|
||||
"ankiConnect": {
|
||||
"isKiku": { "enabled": true },
|
||||
"lapisKiku": {
|
||||
"wordCardKind": "click" // word-and-sentence (default), click, sentence, audio, none
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`click` marks `IsClickCard`, `sentence` marks `IsSentenceCard`, `audio` marks `IsAudioCard`, and `none` leaves the flags untouched for templates that manage them elsewhere. Whichever flag is chosen, the other card-type flags are cleared so the note never claims two card types. The setting is only read when `isKiku` or `isLapis` is enabled, and cards mined with Mine Sentence or Mine Audio keep their own flag.
|
||||
|
||||
## Field Grouping (Kiku)
|
||||
|
||||
When you mine the same word multiple times, SubMiner can merge the cards instead of creating duplicates. This is designed for note types like [Kiku](https://github.com/youyoumu/kiku) that support grouped sentence/audio/image fields.
|
||||
@@ -313,11 +328,11 @@ When you mine the same word multiple times, SubMiner can merge the cards instead
|
||||
|
||||
### What Gets Merged
|
||||
|
||||
| Field | Merge behavior |
|
||||
| -------- | ---------------------------------------- |
|
||||
| Field | Merge behavior |
|
||||
| -------- | --------------------------------------------- |
|
||||
| Sentence | Both cards' sentences kept as grouped entries |
|
||||
| Audio | Both cards' `[sound:...]` entries kept |
|
||||
| Image | Both cards' images kept |
|
||||
| Audio | Both cards' `[sound:...]` entries kept |
|
||||
| Image | Both cards' images kept |
|
||||
|
||||
Identical values from both cards are kept as separate grouped entries; the merge does not deduplicate.
|
||||
|
||||
|
||||
@@ -75,8 +75,8 @@ src/
|
||||
renderer/ # Overlay renderer (modularized UI/runtime)
|
||||
handlers/ # Keyboard/mouse/gamepad interaction modules
|
||||
modals/ # Modal flows (Jimaku, Kiku, subsync, runtime options, session help,
|
||||
# character dictionary, playlist browser, subtitle sidebar,
|
||||
# YouTube track picker, controller config/debug/select)
|
||||
# changelog, character dictionary, playlist browser, subtitle
|
||||
# sidebar, YouTube track picker, controller config/debug/select)
|
||||
positioning/ # Subtitle position controller (drag-to-reposition)
|
||||
settings/ # Settings window UI (model, controls, markup)
|
||||
types/ # Domain type modules (anki, config, integrations, ...)
|
||||
|
||||
@@ -1,5 +1,56 @@
|
||||
# Changelog
|
||||
|
||||
## v0.19.3 (2026-08-13)
|
||||
|
||||
**Added**
|
||||
- Changelog Modal: Adds an in-app changelog you can open from the tray ("View Changelog") or the "What's New" button on the update notification, so the notification stays reachable while you read. It shows the newest published release notes (falling back to the bundled changelog if that fetch fails), folds older versions while keeping the current one expanded, and supports keyboard navigation (`J`/`K`/arrows, `Enter`, `R`, `Esc`).
|
||||
|
||||
**Changed**
|
||||
- Subtitle Tokenization Performance: Reworks subtitle dictionary lookups to cut per-line work roughly in half, cache repeated lookups across lines, and stop tokenization from competing with on-screen subtitle prefetching. Also fixes several accuracy issues along the way: dropped readings on trailing kana, character names being skipped after a dictionary sync, annotations not refreshing after mining a card, and halfwidth katakana character names losing their reading or being swallowed by other words.
|
||||
|
||||
**Fixed**
|
||||
- Character Dictionary Large Imports: Large character dictionaries (e.g. One Piece) no longer fail to install from a fixed timeout budget; the import now scales its time budget to dictionary size and reports detailed progress (page/character counts, image download progress, elapsed time) instead of one static message.
|
||||
- Stats Delete Responsiveness: Deleting sessions, episodes, or library entries no longer freezes the stats page or an active video player; deletes are now batched into a single transaction.
|
||||
- Styled Subtitle Cue Parsing: Heavily typeset subtitles (karaoke, signs) no longer flood the subtitle sidebar with garbage; vector drawing commands are no longer shown as text, and duplicate/animation-burst cues now collapse into one.
|
||||
- X11 mpv Renderer: Fixes an mpv crash on the first fullscreen toggle for X11/XWayland users with `gpu-next` shaders (e.g. ArtCNN), which was caused by X11 mode forcing the legacy OpenGL renderer.
|
||||
- X11 Overlay Display Scaling: Fixes the overlay appearing oversized and offset from mpv on X11/XWayland under fractional or mixed-monitor display scaling.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- Subtitle text is now decoded from ASS exactly once at ingest, so the renderer, timing tracker, and tokenizer all share one decoded value instead of each re-deriving it.
|
||||
- Added per-stage debug timings (`scanMs`, `mecabMs`, `frequencyMs`, `annotateMs`) to the subtitle tokenization pipeline log.
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.2 (2026-08-04)
|
||||
|
||||
**Changed**
|
||||
- Subsync: The sync modal now lets you choose both the reference subtitle (correct timing) and the out-of-sync subtitle to retime, for both alass and ffsubsync. alass can also use the loaded video's audio as a reference for local files. Retiming the secondary track now reloads the result into the secondary slot instead of overwriting the primary subtitle.
|
||||
|
||||
**Fixed**
|
||||
- Streaming Subtitle Tokenization: Jellyfin streams now seed subtitle tokenization directly from the downloaded subtitle file instead of relying on an mpv event that could be missed, and prefetching now runs to the end of the file and clears between episodes. The tokenization cache was raised from 256 to 2500 lines, and parsed cues are no longer lost when the active subtitle track briefly can't be resolved (e.g. switching to an embedded track). Together these prevent episodes from falling back to slow, line-by-line tokenization during playback.
|
||||
- Overlay: Subtitle lines now appear immediately at their cue time even on a tokenization cache miss, upgrading in place once tokens and annotations are ready, instead of waiting on a line still being processed. A failed tokenization is no longer cached as plain text, so repeated lines get another chance at annotations.
|
||||
- Background Logging: Background startup now respects the configured logging level when no explicit log level is passed.
|
||||
|
||||
<details>
|
||||
<summary>Internal changes</summary>
|
||||
|
||||
**Internal**
|
||||
- Patched three high-severity dependency advisories (`undici`, `brace-expansion`, `fast-uri`).
|
||||
|
||||
</details>
|
||||
|
||||
## v0.19.1 (2026-08-01)
|
||||
|
||||
**Added**
|
||||
- Word Card Type: Adds a setting (Settings > Mining/Anki > Kiku/Lapis Features > "Word Card Type") to choose which card-type flag SubMiner marks on Kiku/Lapis word cards — `word-and-sentence` (default), `click`, `sentence`, `audio`, or `none`. Click cards (`IsClickCard`) can now be flagged, and setting any card-type flag clears the others so a note can't claim two types at once.
|
||||
|
||||
**Fixed**
|
||||
- Yomitan Popup: Fixes the macOS Yomitan popup going inert after mining a card — clicks outside the popup no longer pass through to mpv, and scrolling over the popup scrolls its definitions instead of seeking playback.
|
||||
- YouTube Playlist Links: Fixes opening a video from a playlist URL (e.g. a Watch Later link with `list=`/`index=`) timing out while probing subtitles, metadata, or the playback URL.
|
||||
|
||||
## v0.19.0 (2026-07-29)
|
||||
|
||||
**Added**
|
||||
|
||||
+74
-56
@@ -398,30 +398,30 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
||||
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
||||
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||
| `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). |
|
||||
| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
|
||||
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
|
||||
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||
| Option | Values | Description |
|
||||
| ---------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `primaryDefaultMode` | string | Default primary subtitle bar visibility mode: `"hidden"`, `"visible"`, or `"hover"` (default: `"visible"`) |
|
||||
| `subtitleStyle.css` | object | CSS declaration object applied to primary subtitles after normal style defaults. Use CSS property names such as `font-size`. |
|
||||
| `secondary.css` | object | CSS declaration object applied to secondary subtitles after normal secondary style defaults. |
|
||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||
| `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). |
|
||||
| `primaryVisibleOnYomitanPopup` | boolean | Keep hover-mode primary subtitles visible while the Yomitan popup is open (`true` by default). |
|
||||
| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) |
|
||||
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||
| `knownWordMaturityColors` | object | Per-tier known-word colors used when `ankiConnect.knownWords.maturityEnabled` is on: `new` (`#ee99a0`), `learning` (`#b7bdf8`), `young` (`#91d7e3`), `mature` (`#a6da95`) |
|
||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
|
||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`10000` by default) |
|
||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||
|
||||
Subtitle CSS custom properties:
|
||||
|
||||
@@ -555,11 +555,11 @@ Secondary subtitles do **not** auto-load by default. To turn them on for local a
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ----------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||
| Option | Values | Description |
|
||||
| ----------------------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `secondarySubLanguages` | string[] | Language codes to auto-load (e.g., `["eng", "en"]`); non-Signs/Songs tracks are preferred when several tracks match. Default is empty (`[]`). |
|
||||
| `autoLoadSecondarySub` | `true`, `false` | Auto-detect and load a matching secondary subtitle track for local/Jellyfin sidecar files (default: `false`) |
|
||||
| `defaultMode` | `"hidden"`, `"visible"`, `"hover"` | Initial display mode (default: `"hover"`) |
|
||||
|
||||
These two settings apply to local and Jellyfin playback only. YouTube secondary selection is fixed to English and ignores them; see [YouTube Integration](/youtube-integration#secondary-subtitle-languages). `defaultMode` still controls how the loaded secondary bar is displayed in every case.
|
||||
|
||||
@@ -1043,7 +1043,7 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word"] }`). |
|
||||
| `ankiConnect.knownWords.maturityEnabled` | `true`, `false` | Color known words by Anki card maturity (new/learning/young/mature) instead of one color. Requires `knownWords.highlightEnabled` (default: `false`). Tier colors come from `subtitleStyle.knownWordMaturityColors`. |
|
||||
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
||||
| `ankiConnect.knownWords.matureThresholdDays` | number | Card interval in days at which a known word counts as mature (default: `21`, matching Anki's own convention) |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"overlay"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"overlay"`). `"both"` means overlay + system. `osd` and `osd-system` are legacy config-file-only values; use `"osd-system"` to keep the old OSD + system behavior. |
|
||||
@@ -1069,6 +1069,9 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
||||
"enabled": true,
|
||||
"fieldGrouping": "manual",
|
||||
"deleteDuplicateInAuto": true
|
||||
},
|
||||
"lapisKiku": {
|
||||
"wordCardKind": "word-and-sentence"
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1077,6 +1080,21 @@ SubMiner is intentionally built for [Kiku](https://kiku.youyoumu.my.id/) and [La
|
||||
- Enable `isKiku` to turn on duplicate merge behavior for mined Word/Expression hits.
|
||||
- When both are enabled, Kiku behavior is applied for grouping while sentence-card model settings are still read from `isLapis`.
|
||||
- `isKiku.fieldGrouping` supports `disabled`, `auto`, and `manual` merge modes; see [Field Grouping Modes](#field-grouping-modes).
|
||||
- `lapisKiku.wordCardKind` picks the card-type flag set on word cards; see [Word Card Type](#word-card-type). It is read only while `isLapis` or `isKiku` is enabled.
|
||||
|
||||
### Word Card Type
|
||||
|
||||
When SubMiner fills the sentence on a mined word card - from Yomitan auto-enrichment, a manual clipboard update, or stats-dashboard word mining - it marks which card that note should generate. `ankiConnect.lapisKiku.wordCardKind` chooses the flag:
|
||||
|
||||
| Value | Flag set |
|
||||
| ----------------------------- | ----------------------- |
|
||||
| `word-and-sentence` (default) | `IsWordAndSentenceCard` |
|
||||
| `click` | `IsClickCard` |
|
||||
| `sentence` | `IsSentenceCard` |
|
||||
| `audio` | `IsAudioCard` |
|
||||
| `none` | none; flags left as-is |
|
||||
|
||||
The other card-type flags are cleared so a note never claims two card types at once. Notes are skipped when the note type has no field for the chosen flag, and when the note was already mined as a sentence or audio card. Cards created by Mine Sentence and Mine Audio keep their own flag regardless of this setting.
|
||||
|
||||
### N+1 Word Highlighting
|
||||
|
||||
@@ -1167,10 +1185,10 @@ TsukiHime subtitle search works out of the box and needs no account or API key.
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ---------------------------- | ------------ | ---------------------------------------------------------------------------------------------------- |
|
||||
| Option | Values | Description |
|
||||
| ---------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| `tsukihime.apiBaseUrl` | string (URL) | Base URL of the TsukiHime API (default: `https://api.tsukihime.org/v1`). Only change it for a mirror. |
|
||||
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
||||
| `tsukihime.maxSearchResults` | number | Maximum releases returned per search (default: `10`; the API caps this at 100) |
|
||||
|
||||
The keyboard shortcut lives under `shortcuts.openTsukihime` (default `Ctrl+Shift+T`; set to `null` to disable). The older `animetosho` section and `shortcuts.openAnimetosho` are still accepted as deprecated aliases, with the current names taking precedence when both are set.
|
||||
|
||||
@@ -1178,9 +1196,9 @@ See [TsukiHime Integration](/tsukihime-integration) for the modal workflow, lang
|
||||
|
||||
### Subtitle Sync
|
||||
|
||||
Sync the active subtitle track from the overlay picker using `alass` or `ffsubsync`. Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||
Sync a subtitle track from the overlay picker using `alass` or `ffsubsync`. The picker lets you choose which track gets retimed (the active primary track by default) and, for alass, which reference it is aligned against (the secondary subtitle track by default). Both are **optional external tools** that must be installed separately and available on your `PATH` (or configured via the path options below).
|
||||
|
||||
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using a secondary subtitle as reference
|
||||
- [`alass`](https://github.com/kaegi/alass) - fast, audio-independent sync using another subtitle as reference; it can also take the local video file as reference (alass extracts the audio itself)
|
||||
- [`ffsubsync`](https://github.com/smacke/ffsubsync) - audio-based sync using the video file as reference
|
||||
|
||||
```json
|
||||
@@ -1228,17 +1246,17 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| -------------------------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
||||
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
||||
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
||||
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
||||
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
|
||||
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
|
||||
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
|
||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
|
||||
| Option | Values | Description |
|
||||
| -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `anilist.enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
||||
| `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) |
|
||||
| `characterDictionary.refreshTtlHours` | number | Hours before a cached media snapshot is refreshed (default: `168`, clamped to 1–8760) |
|
||||
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | What happens to snapshots evicted beyond `maxLoaded` (default: `"delete"`) |
|
||||
| `characterDictionary.collapsibleSections.description` | `true`, `false` | Open the Description section by default in generated dictionary entries |
|
||||
| `characterDictionary.collapsibleSections.characterInformation` | `true`, `false` | Open the Character Information section by default in generated dictionary entries |
|
||||
| `characterDictionary.collapsibleSections.voicedBy` | `true`, `false` | Open the Voiced by section by default in generated dictionary entries |
|
||||
| `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary settings updates to all Yomitan profiles or only active profile |
|
||||
|
||||
When `enabled` is `true` and `accessToken` is empty, SubMiner opens an AniList setup helper window. Keep `enabled` as `false` to disable all AniList setup/update behavior.
|
||||
|
||||
@@ -1539,18 +1557,18 @@ Configure the mpv executable, profile, and window state for SubMiner-managed mpv
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Values | Description |
|
||||
| ------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||
| Option | Values | Description |
|
||||
| ------------------------ | --------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `executablePath` | string | Absolute path to `mpv.exe` for Windows launch flows. Leave empty to auto-discover from `SUBMINER_MPV_PATH` or `PATH` (default `""`) |
|
||||
| `profile` | string | mpv profile name passed as `--profile=<name>`. Leave empty to pass no profile (default `""`) |
|
||||
| `launchMode` | `"normal"` \| `"maximized"` \| `"fullscreen"` | Window state when SubMiner spawns mpv (default `"normal"`) |
|
||||
| `socketPath` | string | mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin (platform-dependent default: `/tmp/subminer-socket`, or `\\\\.\\pipe\\subminer-socket` on Windows) |
|
||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
|
||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
||||
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
||||
| `backend` | `"auto"` \| `"hyprland"` \| `"sway"` \| `"x11"` \| `"macos"` \| `"windows"` | Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform (default: `"auto"`) |
|
||||
| `autoStartSubMiner` | `true`, `false` | Start SubMiner in the background when SubMiner-managed mpv loads a file (default: `true`) |
|
||||
| `pauseUntilOverlayReady` | `true`, `false` | Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness, with a 30-second fallback (default: `true`) |
|
||||
| `subminerBinaryPath` | string | SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path (default: `""`) |
|
||||
| `aniskipEnabled` | `true`, `false` | Enable AniSkip intro detection, chapter markers, and the skip-intro key (default: `true`) |
|
||||
| `aniskipButtonKey` | string | mpv key used to skip the detected intro while the skip prompt is visible (default: `"TAB"`) |
|
||||
|
||||
If `mpv.profile` is configured and the launcher also receives `--profile`, SubMiner passes both as a comma-separated mpv profile list.
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ The same immersion data powers the stats dashboard.
|
||||
- In-app overlay: focus the visible overlay, then press the key from `stats.toggleKey` (default: `` ` `` / `Backquote`).
|
||||
- Launcher command: run `subminer stats` to start the local stats server on demand (it also opens the dashboard in your browser when `stats.autoOpenBrowser` is enabled; the default is `false`).
|
||||
- Background server: run `subminer stats -b` to start or reuse a dedicated background stats daemon without keeping the launcher attached, and `subminer stats -s` to stop that daemon.
|
||||
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables. `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
|
||||
- Maintenance commands: run `subminer stats cleanup` or `subminer stats cleanup -v` to backfill/repair vocabulary metadata (`headword`, `reading`, POS) and purge stale or excluded rows from `imm_words` on demand; `subminer stats cleanup -l` repairs lifetime summary tables non-destructively (recomputed from per-episode history, so lifetime totals older than the session retention window are kept); `subminer stats cleanup --duplicate-lines` collapses repeated lines left behind by typeset subtitles (see [Repeated Line Cleanup](#repeated-line-cleanup)). `subminer stats rebuild` and `subminer stats backfill` rebuild or backfill rollup data.
|
||||
- Browser page: open `http://127.0.0.1:6969` directly if the local stats server is already running.
|
||||
|
||||
### Dashboard Tabs
|
||||
@@ -57,6 +57,13 @@ Jellyfin stream URLs are normalized to stable item links before stats titles are
|
||||
|
||||
When YouTube channel metadata is available, the Library tab groups videos by creator/channel and treats each tracked video as an episode-like entry inside that channel section.
|
||||
|
||||
A library entry is identified by its parsed title plus any detected season, so the same show can end up on several cards when releases disagree about the title or omit the season tag. Two fixes are available:
|
||||
|
||||
- **Merge duplicates.** Hit **Select** above the grid, tick the cards that are the same show, and choose **Merge Selected**. Pick which entry to keep in the dialog; every episode moves onto it and the other cards are removed. Nothing is deleted, so sessions, mined cards and watch time all carry over. SubMiner remembers the merged title variants, so future episodes parsed with one of those names join the kept entry instead of recreating a duplicate card.
|
||||
- **Move a single episode.** Hover an episode row in a title's episode list and use the **→** button to reassign it to another library entry. The correction is remembered, so later filename parsing or Jellyfin metadata cannot move that episode back. For local files, later episodes in the same directory inherit the correction when their detected seasons are compatible and every manual correction there points to the same entry; a file that parses to a title which already has its own library entry keeps that identity instead. Conflicting seasons or manual destinations are left for review. If the move empties the old entry, that card is removed and you are returned to the grid.
|
||||
|
||||
Once cover art resolves a series to an AniList entry, cards with compatible seasons are folded together automatically only when the searched title exactly matches an AniList title or synonym. A fuzzy result that points at an AniList entry already used by another card appears as a **Possible duplicate** review above the Library grid instead. Choose **Review merge** to compare the cards and pick which one to keep, or **Not duplicates** to dismiss that suggestion permanently. Entries with conflicting explicit season numbers are left alone rather than merged or suggested.
|
||||
|
||||
Open a title and use **Delete Entry** in its header to remove a mistakenly tracked show outright. This deletes every episode of that title along with their sessions, subtitle lines, rollups and cover art, drops the words and kanji that were only seen there, and removes the card from the Library grid. Individual episodes and sessions can still be deleted on their own from the episode list and session rows. Entry deletion is refused while that title is the one currently playing.
|
||||
|
||||

|
||||
@@ -125,6 +132,34 @@ Secondary subtitle text (typically English translations) is stored alongside pri
|
||||
|
||||
The Vocabulary tab toolbar includes an **Exclusions** button for hiding words from all vocabulary views. Excluded words are stored in the immersion database, with older browser localStorage exclusions imported on first load after upgrade. They can be managed (restored or cleared) from the exclusion modal. Exclusions affect stat cards, charts, the frequency rank table, and the word list.
|
||||
|
||||
### Repeated Line Cleanup
|
||||
|
||||
Karaoke openings and animated signs are authored as one subtitle event per animation frame, all carrying the same text. Playback reports every one of those frames, so a single OP lyric could be recorded hundreds of times and dominate "Top Repeated Words".
|
||||
|
||||
Recording now collapses those runs as they happen, matching what the subtitle sidebar shows:
|
||||
|
||||
- When the active subtitle source has been parsed, its cue list has already had duplicate events and animation bursts merged. A line landing inside a surviving cue but after that cue's start is a frame the sidebar merged away, and is not recorded.
|
||||
- When no parsed cue covers the live timing, including while a subtitle source is changing or shifted, the strict metadata-free rule applies: a run of identical, contiguous lines each shorter than 0.1s stops being recorded after a few frames. Runs are tracked per line of text, so dual-line karaoke (a kanji and a romaji line frame-flipped together) collapses both lines. Ordinary repeated dialogue, and lines held for a normal beat, always record.
|
||||
|
||||
For stats recorded before this, the Vocabulary tab toolbar has a **Duplicates** button:
|
||||
|
||||
- Pick how far back to look (7 days, 30 days, 90 days, 1 year, or all time). A narrower window does less work and keeps older history untouched.
|
||||
- **Scan** reports the bursts found, the lines they added, and the word and kanji counts they inflated, without writing anything.
|
||||
- **Clean Up** applies exactly what the scan reported: each run collapses to its first line (extended to cover the run), and the removed lines' word and kanji occurrences are subtracted from the vocabulary aggregates.
|
||||
|
||||
The same thing runs from the terminal:
|
||||
|
||||
```bash
|
||||
subminer stats cleanup --duplicate-lines --dry-run --lookback-days 30
|
||||
subminer stats cleanup --duplicate-lines --lookback-days 30
|
||||
```
|
||||
|
||||
`--duplicate-lines` (short: `-d`) picks the cleanup mode, so it cannot be combined with `--vocab` or `--lifetime`, and `--dry-run` and `--lookback-days <days>` only apply to it. Omitting `--lookback-days` scans all history; the value must be at least one day.
|
||||
|
||||
The cleanup chains runs per line of text, so interleaved dual-line karaoke collapses each of its lines. It also removes the short residue the live rule stores before a run is long enough to recognize: a run one frame short of the usual minimum qualifies when every event is under the strict 0.1s bound.
|
||||
|
||||
Runs never cross a session boundary, so rewatching an episode keeps both watches. Session telemetry (watch time, lines seen, tokens seen) and the rollups derived from it are left as recorded: they are cumulative samples taken during playback, and cannot be recomputed for sessions whose raw rows have since been pruned.
|
||||
|
||||
## Retention Defaults
|
||||
|
||||
By default, SubMiner keeps all retention tables and raw data (`0` means keep all) while continuing daily/monthly rollup maintenance:
|
||||
|
||||
@@ -151,6 +151,7 @@ subminer stats -b # start background stats daemon
|
||||
| `subminer stats` | Start the stats server (opens the dashboard when `stats.autoOpenBrowser` is on) |
|
||||
| `subminer stats -b` / `-s` | Start/reuse or stop the background stats daemon |
|
||||
| `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows (`-v` vocab, `-l` lifetime summaries) |
|
||||
| `subminer stats cleanup -d` | Collapse repeated lines from typeset subs (`--dry-run`, `--lookback-days <n>`) |
|
||||
| `subminer stats rebuild` / `backfill` | Rebuild or backfill rollup data |
|
||||
| `subminer doctor` | Dependency + config + socket diagnostics (`--refresh-known-words` refreshes the known-word cache) |
|
||||
| `subminer settings` | Open the SubMiner settings window |
|
||||
|
||||
@@ -21,6 +21,7 @@ function slugify(heading: string): string {
|
||||
}
|
||||
|
||||
const EXCLUDED_PAGES = new Set(['README.md']);
|
||||
const UNLISTED_ROUTES = new Set(['/demos']);
|
||||
const PUBLIC_PREFIXES = ['/assets/', '/screenshots/', '/config.example.jsonc', '/favicon'];
|
||||
|
||||
function loadPages(): Map<string, string> {
|
||||
@@ -114,7 +115,7 @@ test('slugify matches the VitePress cases these docs actually rely on', () => {
|
||||
expect(slugify('2. Install SubMiner')).toBe('_2-install-subminer');
|
||||
});
|
||||
|
||||
test('every docs page is reachable from the sidebar', async () => {
|
||||
test('every docs page is reachable from the sidebar unless explicitly unlisted', async () => {
|
||||
const { default: config } = await import('./.vitepress/config');
|
||||
const sidebar = config.themeConfig?.sidebar as Array<{
|
||||
items?: Array<{ text: string; link?: string }>;
|
||||
@@ -127,6 +128,8 @@ test('every docs page is reachable from the sidebar', async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const orphans = [...pages.keys()].filter((route) => !linked.has(route));
|
||||
const orphans = [...pages.keys()].filter(
|
||||
(route) => !linked.has(route) && !UNLISTED_ROUTES.has(route),
|
||||
);
|
||||
expect(orphans).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -161,10 +161,13 @@ If your subtitle file is out of sync with the audio, SubMiner can resynchronize
|
||||
|
||||
1. Open the subsync modal from the overlay.
|
||||
2. Select the sync engine (alass or ffsubsync).
|
||||
3. For alass, select a reference subtitle track from the video.
|
||||
4. SubMiner runs the sync and reloads the corrected subtitle.
|
||||
3. For alass, pick the **reference** - the subtitle with correct timing. This defaults to the secondary subtitle track. The loaded video file can also be used as the reference (alass extracts the audio itself), but it is never the default.
|
||||
4. Pick the **out-of-sync subtitle** - the track that gets retimed. This defaults to the active primary subtitle track and applies to both engines.
|
||||
5. SubMiner runs the sync and reloads the corrected subtitle into the slot the out-of-sync track came from: retiming the secondary track keeps it secondary and leaves the primary track selected.
|
||||
|
||||
For remote streams, including Jellyfin playback, the modal only offers alass. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync needs direct access to the local media file and is unavailable for stream URLs.
|
||||
The reference and the out-of-sync subtitle must be different tracks; the reference list hides whichever track is selected as the target.
|
||||
|
||||
For remote streams, including Jellyfin playback, the modal only offers alass with a subtitle reference. Jellyfin subtitle URLs are cached as temporary subtitle files so alass can read them, but the video stream is not downloaded. ffsubsync and the video-file reference need direct access to the local media file and are unavailable for stream URLs.
|
||||
|
||||
When you mine a sentence card from the stats dashboard, SubMiner can also use `alass` automatically to align a local English sidecar against the matching local Japanese sidecar before filling the card translation field. The source subtitle files are not modified; SubMiner writes a temporary retimed copy and reuses it while the stats server is running.
|
||||
|
||||
|
||||
@@ -523,7 +523,7 @@
|
||||
// ==========================================
|
||||
// AnkiConnect Integration
|
||||
// Automatic Anki updates and media generation options.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.
|
||||
// Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.
|
||||
// Shared AI provider transport settings are read from top-level ai and typically require restart.
|
||||
// Most other AnkiConnect settings still require restart.
|
||||
// ==========================================
|
||||
@@ -605,7 +605,10 @@
|
||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||
} // Is kiku setting.
|
||||
}, // Is kiku setting.
|
||||
"lapisKiku": {
|
||||
"wordCardKind": "word-and-sentence" // Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled. Values: word-and-sentence | click | sentence | audio | none
|
||||
} // Lapis kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
// ==========================================
|
||||
|
||||
+49
-21
@@ -56,34 +56,43 @@ test('main docs canonical uses /main/ and emits noindex', async () => {
|
||||
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
|
||||
]);
|
||||
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||
expect(mainDocsConfig.sitemap).toBeUndefined();
|
||||
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
});
|
||||
|
||||
test('latest stable archive canonical points to root equivalent', async () => {
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = '/v/0.14.0/';
|
||||
process.env.SUBMINER_DOCS_VERSION = 'v0.14.0';
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive');
|
||||
test.each([
|
||||
['latest stable', 'v0.14.0', '/v/0.14.0/', 'https://docs.subminer.moe/v/0.14.0/usage'],
|
||||
['superseded', 'v0.12.0', '/v/0.12.0/', 'https://docs.subminer.moe/v/0.12.0/usage'],
|
||||
])(
|
||||
'%s archive keeps a self-referential canonical and stays out of the index',
|
||||
async (_label, version, base, expectedCanonical) => {
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = base;
|
||||
process.env.SUBMINER_DOCS_VERSION = version;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
try {
|
||||
const { default: archiveConfig } = await import(`./.vitepress/config?archive-${version}`);
|
||||
|
||||
const head = await latestArchiveConfig.transformHead?.(makeTransformContext('usage.md'));
|
||||
const head = await archiveConfig.transformHead?.(makeTransformContext('usage.md'));
|
||||
|
||||
expect(head).toContainEqual([
|
||||
'link',
|
||||
{ rel: 'canonical', href: 'https://docs.subminer.moe/usage' },
|
||||
]);
|
||||
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
});
|
||||
expect(head).toContainEqual(['link', { rel: 'canonical', href: expectedCanonical }]);
|
||||
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||
// A sitemap here would advertise the archive tree we just excluded.
|
||||
expect(archiveConfig.sitemap).toBeUndefined();
|
||||
} finally {
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test('stable archive theme links stay on the selected version', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
@@ -433,3 +442,22 @@ test('docs sitemap excludes duplicate README page from indexable URLs', async ()
|
||||
|
||||
expect(transformedItems?.map((item) => item.url)).toEqual(['', 'usage']);
|
||||
});
|
||||
|
||||
test('docs sitemap dates every URL from the tracked checkout', async () => {
|
||||
const previousRepoDir = process.env.SUBMINER_DOCS_REPO_DIR;
|
||||
// Production builds render from an untracked snapshot, so the date has to come from
|
||||
// the real checkout rather than VitePress's own srcDir git lookup.
|
||||
process.env.SUBMINER_DOCS_REPO_DIR = docsSiteDir;
|
||||
try {
|
||||
const { default: sitemapConfig } = await import('./.vitepress/config?sitemap-lastmod');
|
||||
|
||||
const items = await sitemapConfig.sitemap?.transformItems?.([{ url: '' }, { url: 'usage' }]);
|
||||
|
||||
expect(items).toHaveLength(2);
|
||||
for (const item of items ?? []) {
|
||||
expect(item.lastmod).toMatch(/^\d{4}-\d{2}-\d{2}T/);
|
||||
}
|
||||
} finally {
|
||||
process.env.SUBMINER_DOCS_REPO_DIR = previousRepoDir;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -171,7 +171,9 @@ Without FFmpeg, card creation still works but audio and image fields will be emp
|
||||
|
||||
**Audio or screenshot generation hangs**
|
||||
|
||||
Media generation has a 30-second timeout (60 seconds for animated AVIF). If your video file is on a slow network mount or the codec requires software decoding, generation may time out. Try:
|
||||
Audio extraction has a 2-minute timeout. SubMiner also limits FFmpeg probing when mpv provides the selected audio stream, which avoids scanning unrelated subtitle and font-attachment streams in large MKV files. Screenshots retain a 30-second timeout, and animated AVIF uses 60 seconds.
|
||||
|
||||
If your video file is on a slow or unresponsive network mount, generation may still time out. Try:
|
||||
|
||||
- Using a local copy of the video file.
|
||||
- Reducing `ankiConnect.media.imageQuality` or switching from `avif` to `static` image type.
|
||||
@@ -227,7 +229,7 @@ Install ffsubsync or configure the path:
|
||||
|
||||
If subtitle sync fails (the error message is prefixed with the engine name):
|
||||
|
||||
- Ensure the reference subtitle track exists in the video (alass requires a source track).
|
||||
- Ensure a reference is selected (alass needs either a second subtitle track or the local video file, and it cannot be the same track that is being retimed).
|
||||
- Check that `ffmpeg` is available (used to extract the internal subtitle track).
|
||||
- Try running the sync tool manually to see detailed error output.
|
||||
- ffsubsync requires local files and cannot handle remote media streams (e.g., streaming URLs).
|
||||
@@ -405,8 +407,9 @@ On any Wayland session that is not Hyprland or Sway (KDE Plasma, GNOME, and othe
|
||||
|
||||
SubMiner handles this automatically:
|
||||
|
||||
- It launches its own window under XWayland (it sets `--ozone-platform-hint=x11`).
|
||||
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11egl,x11`) is applied.
|
||||
- It launches its own window under XWayland (it sets `--ozone-platform=x11`).
|
||||
- Every mpv it launches (via the `subminer` launcher, Jellyfin, or YouTube) is pinned to XWayland too - Wayland environment hints are stripped and an X11 GPU context (`--gpu-context=x11vk,x11egl,x11`) is applied. Only the window context is overridden; your `vo`/`gpu-api` and user shaders are left alone.
|
||||
- Fractional and mixed-monitor display scaling is handled per screen when SubMiner maps XWayland mpv coordinates to the overlay.
|
||||
- While mpv is windowed, the overlay is a managed X11 window owned by the tracked mpv window (`WM_TRANSIENT_FOR`), so it stays above mpv while other foreground X11/Xwayland apps can still cover both windows.
|
||||
- While tracked mpv is fullscreen, SubMiner swaps the visible overlay to a focusable-false X11 override-redirect window. That path can stay above the active fullscreen mpv window without requiring a KDE/KWin-specific rule, and SubMiner hides/releases it when mpv is no longer the active X11/Xwayland window.
|
||||
- The visible overlay is shown inactive on Linux, so normal hover should not steal keyboard focus from mpv.
|
||||
@@ -420,7 +423,7 @@ Requirements: `xdotool`, `xprop`, and `xwininfo` must be installed. SubMiner use
|
||||
This almost always means mpv came up as a **native Wayland** window that the XWayland overlay cannot cover. It happens when mpv is launched **manually** (your own command), because SubMiner can only force XWayland on the mpv processes it launches itself. Fix it one of these ways:
|
||||
|
||||
- Launch playback through SubMiner (the `subminer` launcher or the tray), which forces XWayland for you, or
|
||||
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11egl …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11egl` in your `mpv.conf`.
|
||||
- Force XWayland in your own mpv invocation, e.g. `mpv --gpu-context=x11vk,x11egl,x11 …`, or launch with `WAYLAND_DISPLAY= mpv …`, or set `gpu-context=x11vk` (Vulkan) / `gpu-context=x11egl` (OpenGL) in your `mpv.conf`.
|
||||
|
||||
To confirm mpv is on XWayland, `xdotool search --class mpv` should return a window id (a native Wayland mpv returns nothing).
|
||||
|
||||
|
||||
+10
-2
@@ -95,6 +95,8 @@ subminer texthooker # Texthooker-only mode (-o also opens the brow
|
||||
subminer stats -b # Start/reuse the background stats daemon
|
||||
subminer stats -s # Stop the background stats daemon
|
||||
subminer stats cleanup # Backfill vocabulary metadata, prune stale rows
|
||||
subminer stats cleanup -d --dry-run # Preview cleanup of repeated typeset subtitle lines
|
||||
subminer stats cleanup -d --lookback-days 30 # Clean only lines recorded in the last 30 days
|
||||
subminer stats rebuild # Rebuild rollup data
|
||||
subminer doctor --refresh-known-words # Refresh the known-word cache
|
||||
subminer logs -e # Export a sanitized log ZIP and print its path
|
||||
@@ -107,6 +109,8 @@ subminer app --stop # Stop the background app
|
||||
subminer --version # Print the launcher's version
|
||||
```
|
||||
|
||||
`stats cleanup` runs one mode per invocation: `-v`/`--vocab` (the default), `-l`/`--lifetime`, or `-d`/`--duplicate-lines`; explicitly selected modes cannot be combined. `--dry-run` and `--lookback-days <days>` apply to `--duplicate-lines` only and are rejected without it; `--lookback-days` must be at least one day, and leaving it off scans all history.
|
||||
|
||||
Jellyfin, cross-machine sync, and character-dictionary commands have their own sections: [Jellyfin](/jellyfin-integration), [Sync Between Machines](/launcher-script#sync-between-machines), and [Character Dictionary](/character-dictionary).
|
||||
|
||||
</details>
|
||||
@@ -137,7 +141,7 @@ SubMiner.AppImage --start --log-level debug # Verbose logging without dev mode
|
||||
SubMiner.AppImage --help # Show all options
|
||||
```
|
||||
|
||||
The remaining flags are internal or scripting-only surfaces: the `--jellyfin-*` family (login, library listing, item playback, cast announce), `--sync-cli` (the app's headless sync entrypoint that `subminer sync` proxies to), `--dictionary-candidates` / `--dictionary-select`, and `--playback-feedback <text>`. Run `SubMiner.AppImage --help` for the complete list. The previous `--open-animetosho` flag is still accepted as a deprecated alias for `--open-tsukihime`.
|
||||
The remaining flags are internal or scripting-only surfaces: the `--jellyfin-*` family (login, library listing, item playback, cast announce), `--sync-cli` (the app's headless sync entrypoint that `subminer sync` proxies to), the `--stats-cleanup-*` family that `subminer stats cleanup` forwards (`--stats-cleanup-vocab`, `--stats-cleanup-lifetime`, `--stats-cleanup-duplicate-lines`, and its `--stats-cleanup-dry-run` / `--stats-cleanup-lookback-days <days>` modifiers), `--dictionary-candidates` / `--dictionary-select`, and `--playback-feedback <text>`. Run `SubMiner.AppImage --help` for the complete list. The previous `--open-animetosho` flag is still accepted as a deprecated alias for `--open-tsukihime`.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -145,11 +149,13 @@ The tray menu includes `Export Logs`, which creates the same sanitized local-dat
|
||||
|
||||
Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config.
|
||||
|
||||
The tray menu also includes `View Changelog`, which opens the in-app changelog modal. It fetches the changelog from the newest published release, so you see release notes for versions newer than the one you run; if the download fails it falls back to the changelog bundled with your install and says so. Versions in the current `0.x` line are expanded by default and older lines are folded, matching this site's [Changelog](/changelog). A badge marks the version you have installed, and newer versions are tagged `New`. The same modal opens from the `What's New` button on the update-available overlay notification.
|
||||
|
||||
### Logging and App Mode
|
||||
|
||||
- `--log-level` controls logger verbosity.
|
||||
- `--dev` and `--debug` are app/dev-mode switches; they are not log-level aliases.
|
||||
- `--background` defaults to quieter logging (`warn`) unless `--log-level` is set.
|
||||
- `--background` starts at the default quieter logging level (`warn`), then follows `logging.level` after config loads. An explicit `--log-level` remains the override.
|
||||
- `--background` launched from a terminal detaches and returns the prompt; stop it with tray Quit or `SubMiner.AppImage --stop` (`SubMiner.exe --stop` on Windows).
|
||||
- Linux desktop launcher starts SubMiner with `--background` by default (via electron-builder `linux.executableArgs`).
|
||||
- On Hyprland and other Wayland compositors, the tray icon appears only when your panel provides a StatusNotifier/AppIndicator tray host.
|
||||
@@ -368,6 +374,8 @@ Press `V` to cycle the primary SubMiner subtitle bar through hidden → visible
|
||||
|
||||
`Ctrl/Cmd+/` opens the session help modal with the current overlay and mpv keybindings. The same help view is also available through the `y-h` chord in mpv.
|
||||
|
||||
The changelog modal (tray > `View Changelog`) works the same way: it renders over mpv when a video is playing and in its own window otherwise. Use `J`/`K` or the arrow keys to move between versions, `Enter` to fold or unfold one, `R` to refetch, and `Esc` to close.
|
||||
|
||||
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
|
||||
|
||||
### Drag-and-Drop
|
||||
|
||||
@@ -64,7 +64,7 @@ Use the basic subtitle websocket when you only need the current subtitle line as
|
||||
- **Client auth:** none
|
||||
- **Reconnects:** client-managed
|
||||
|
||||
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes.
|
||||
When a client connects, SubMiner immediately sends the latest subtitle payload if one is available. After that, it pushes a new message each time the current subtitle changes. Annotation-only upgrades do not repeat the same line on this basic stream.
|
||||
|
||||
#### Message shape
|
||||
|
||||
@@ -96,6 +96,8 @@ Use the annotation websocket for custom clients that want the same structured to
|
||||
|
||||
In practice, if you are building a new client, prefer `annotationWebsocket` unless you specifically need compatibility with an existing `websocket` consumer.
|
||||
|
||||
On a tokenization cache miss, this stream first sends the cue as plain text with an empty `tokens` array, then sends the annotated replacement when tokenization finishes. Treat each message as the complete current state, replacing the previous payload.
|
||||
|
||||
#### Message shape
|
||||
|
||||
```json
|
||||
|
||||
@@ -11,7 +11,7 @@ SubMiner auto-loads Japanese subtitles when you play a YouTube URL, giving you t
|
||||
|
||||
When SubMiner detects a YouTube URL (or `ytsearch:` target), it pauses mpv at startup and runs a subtitle pipeline before resuming playback:
|
||||
|
||||
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata.
|
||||
1. **Probe** --- `yt-dlp --dump-single-json` extracts all available subtitle tracks (manual uploads and auto-generated captions) along with video metadata. Every yt-dlp call passes `--no-playlist`, so playlist links (for example a Watch Later URL with `list=`/`index=`) resolve to the single video instead of the whole playlist.
|
||||
2. **Discover** --- Each track is normalized into a `YoutubeTrackOption` with language code, kind (`manual` or `auto`), display label, and direct download URL.
|
||||
3. **Select** --- SubMiner picks the best primary track (Japanese, preferring manual over auto) and secondary track (English, preferring manual over auto).
|
||||
4. **Download** --- Selected tracks are fetched via direct URL when available, falling back to `yt-dlp --write-subs` / `--write-auto-subs`. YouTube TimedText XML formats (`srv1`/`srv2`/`srv3`) are converted to VTT on the fly. Auto-generated VTT captions are normalized to remove rolling-caption duplication.
|
||||
|
||||
@@ -64,18 +64,23 @@ External subtitle files only (SRT, VTT, ASS). Embedded subtitle tracks are out o
|
||||
A cue parser extracts both timing and text content from subtitle files for prefetching.
|
||||
|
||||
**Parsed cue structure:**
|
||||
|
||||
```typescript
|
||||
interface SubtitleCue {
|
||||
startTime: number; // seconds
|
||||
endTime: number; // seconds
|
||||
text: string; // raw subtitle text
|
||||
startTime: number; // seconds
|
||||
endTime: number; // seconds
|
||||
text: string; // plain text, decoded from the source format
|
||||
}
|
||||
```
|
||||
|
||||
**Supported formats:**
|
||||
|
||||
- SRT/VTT: Regex-based parsing of timing lines + text content between timing blocks.
|
||||
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, split on the first 9 commas only (ASS v4+ has 10 fields; the last field is Text which can itself contain commas). Strip ASS override tags (`{\...}`) from the text before storing.
|
||||
ASS text fields contain inline override tags like `{\b1}`, `{\an8}`, `{\fad(200,300)}`. The cue parser strips these during extraction so the tokenizer receives clean text.
|
||||
- ASS: Parse `[Events]` section, extract `Dialogue:` lines, read the field order from the `Format:` row, and take everything after the Text field index as the text (Text can itself contain commas).
|
||||
|
||||
**ASS decoding.** The parser is where ASS text is decoded, once, via `assToPlainText()` in `src/core/services/ass-text.ts`. That decoder mirrors mpv's `ass_to_plaintext` so a cue read from a file reads identically to the same line arriving live on `sub-text`: `{...}` override blocks are markup, `\pN … \p0` vector drawing runs are dropped rather than shown as text, `\N`/`\n`/`\h` are the only escapes (`\{`, `\}` and `\\` are not), and an unclosed `{` is rendered verbatim. Every layer downstream — renderer, timing tracker, tokenizer, tokenization cache keys — receives plain text and uses `normalizePlainSubtitleText()` for whitespace only, so nothing decodes the same string twice and one authored line always maps to one cache key.
|
||||
|
||||
**Duplicate collapsing.** Typeset scripts emit one `Dialogue:` event per animation frame, plus layered copies of the same line. The parser collapses identical text over an identical span unconditionally, and collapses contiguous same-text runs of at least three events when the run looks like an animation. For ASS that means shared style and actor plus authoring evidence: a temporal tag (`\t`, `\move`, `\k`/`\kf`/`\ko`/`\K`, or anything wrapped in `\t(...)`), an animated `Effect` column (`Karaoke`, `Banner`, `Scroll`), or override values that change across the run. Static tags shared by every event (`\pos`, an identical `\clip`) are not evidence. SRT/VTT carry no such metadata, so there collapsing needs at least five contiguous events all under 0.1s — the frame timing left behind by ASS-to-SRT conversion. The parser keeps this authoring metadata (style, actor, layer, `Effect`, parsed override commands, source order) private; `parseSubtitleCues()` returns only `SubtitleCue`.
|
||||
|
||||
#### Prefetch Service Lifecycle
|
||||
|
||||
@@ -153,6 +158,7 @@ tokens (already have frequencyRank values from parser-level applyFrequencyRanks)
|
||||
### Dependency Analysis
|
||||
|
||||
All annotations either depend on MeCab POS data or benefit from running after it:
|
||||
|
||||
- **Known word marking:** Needs base tokens (surface/headword). No POS dependency, but no reason to run separately.
|
||||
- **Frequency filtering:** Uses `pos1Exclusions` and `pos2Exclusions` to clear frequency ranks on excluded tokens (particles, noise). Depends on MeCab POS data.
|
||||
- **JLPT marking:** Uses `shouldIgnoreJlptForMecabPos1` to filter. Depends on MeCab POS data.
|
||||
@@ -169,18 +175,14 @@ function annotateTokens(tokens, deps, options): MergedToken[] {
|
||||
|
||||
// Single pass: known word + frequency filtering + JLPT computed together
|
||||
const annotated = tokens.map((token) => {
|
||||
const isKnown = nPlusOneEnabled
|
||||
? token.isKnown || computeIsKnown(token, deps)
|
||||
: false;
|
||||
const isKnown = nPlusOneEnabled ? token.isKnown || computeIsKnown(token, deps) : false;
|
||||
|
||||
// Filter frequency rank using POS exclusions (rank values already set at parser level)
|
||||
const frequencyRank = frequencyEnabled
|
||||
? filterFrequencyRank(token, pos1Exclusions, pos2Exclusions)
|
||||
: undefined;
|
||||
|
||||
const jlptLevel = jlptEnabled
|
||||
? computeJlptLevel(token, deps.getJlptLevel)
|
||||
: undefined;
|
||||
const jlptLevel = jlptEnabled ? computeJlptLevel(token, deps.getJlptLevel) : undefined;
|
||||
|
||||
return { ...token, isKnown, frequencyRank, jlptLevel };
|
||||
});
|
||||
@@ -221,6 +223,7 @@ Replace `document.createElement('span')` calls in the renderer with `templateSpa
|
||||
### Current Behavior
|
||||
|
||||
In `renderWithTokens` (`subtitle-render.ts`), each render cycle:
|
||||
|
||||
1. Clears DOM with `innerHTML = ''`
|
||||
2. Creates a `DocumentFragment`
|
||||
3. Calls `document.createElement('span')` for each token (~10-15 per subtitle)
|
||||
@@ -256,27 +259,30 @@ Full recycling (collecting old nodes, clearing attributes, reusing them) require
|
||||
|
||||
## Combined Impact Summary
|
||||
|
||||
| Scenario | Before | After | Improvement |
|
||||
|----------|--------|-------|-------------|
|
||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||
| Scenario | Before | After | Improvement |
|
||||
| --------------------------------- | ---------- | ---------- | ----------- |
|
||||
| Normal playback (prefetch-warmed) | ~200-320ms | ~30-50ms | ~80-85% |
|
||||
| Cache hit (repeated subtitle) | ~72ms | ~55-65ms | ~10-20% |
|
||||
| Cache miss (immediate seek) | ~200-320ms | ~150-260ms | ~20-25% |
|
||||
|
||||
---
|
||||
|
||||
## Files Summary
|
||||
|
||||
### New Files
|
||||
|
||||
- `src/core/services/subtitle-prefetch.ts`
|
||||
- `src/core/services/subtitle-cue-parser.ts`
|
||||
|
||||
### Modified Files
|
||||
|
||||
- `src/core/services/subtitle-processing-controller.ts` (expose `preCacheTokenization`)
|
||||
- `src/core/services/tokenizer/annotation-stage.ts` (batched single-pass)
|
||||
- `src/renderer/subtitle-render.ts` (template cloneNode)
|
||||
- `src/main.ts` (wire up prefetch service)
|
||||
|
||||
### Test Files
|
||||
|
||||
- New tests for subtitle cue parser (SRT, VTT, ASS formats)
|
||||
- New tests for subtitle prefetch service (priority window, seek, pause/resume)
|
||||
- Updated tests for annotation stage (same behavior, new implementation)
|
||||
|
||||
@@ -25,6 +25,8 @@ Read when: you need to find the owner module for a behavior or test surface
|
||||
- Anki workflow: `src/anki-integration/`, `src/core/services/anki-jimaku*.ts`
|
||||
- Immersion tracking: `src/core/services/immersion-tracker/`
|
||||
Includes stats storage/query schema such as `imm_videos`, `imm_media_art`, and `imm_youtube_videos` for per-video and YouTube-specific library metadata.
|
||||
Library-entry identity aliases and merge recommendations are persisted alongside this schema; the stats HTTP and SPA layers only expose and present those domain decisions.
|
||||
`delete-maintenance-scheduler.ts` coalesces and serializes stats deletes; the expensive work runs in `delete-maintenance-worker-thread.ts` while the tracker queues playback writes. Each batch uses one transaction, lexical update, rollup refresh, and incremental lifetime subtraction (`planLifetimeRemovals`/`applyLifetimeRemovals` in `lifetime.ts`). Merges, moves, AniList reassignments, and `stats cleanup -l` use `repairLifetimeSummariesFromMedia` (recompute from the per-video media ledger). The full lifetime rebuild survives only as the empty-table bootstrap; anywhere else it would collapse lifetime totals to the session retention window.
|
||||
- AniList tracking + character dictionary: `src/core/services/anilist/`, `src/main/runtime/composers/anilist-*`, `src/main/character-dictionary-runtime.ts`, `src/main/character-dictionary-runtime/`
|
||||
- Jellyfin integration: `src/core/services/jellyfin*.ts`, `src/main/runtime/composers/jellyfin-*`
|
||||
- Window trackers: `src/window-trackers/`
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Subtitle Overlay Priming
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-06-14
|
||||
Last verified: 2026-08-04
|
||||
Owner: Kyle Yasuda
|
||||
Read when: debugging subtitle state or blank Linux/X11 overlay windows when the visible overlay is shown or recreated
|
||||
|
||||
@@ -47,18 +47,43 @@ subtitles do not draw.
|
||||
`emitSubtitle(payload)` and `refreshCurrentSubtitle(text)`, then prime secondary subtitles.
|
||||
6. Tokenization cache hit: call `consumeCachedSubtitle(text)`, `onSubtitleChange(text)`, and
|
||||
`emitSubtitle(cachedPayload)`, then prime secondary subtitles.
|
||||
7. Cache miss: call `refreshCurrentSubtitle(text)` and let normal tokenization emit the final
|
||||
payload.
|
||||
7. Cache miss: call `refreshCurrentSubtitle(text)`. Normal processing emits a plain payload
|
||||
synchronously, then replaces it with the tokenized payload when ready.
|
||||
|
||||
In `src/main.ts`, both `onSubtitleChange` and `refreshCurrentSubtitle` pause
|
||||
`subtitlePrefetchService`, notify it with `onSeek(lastObservedTimePos)`, and then call the matching
|
||||
`subtitleProcessingController` method. This gives the visible overlay priority over background
|
||||
prefetch work and re-centers prefetch around the live playback time.
|
||||
Both `onSubtitleChange` and `refreshCurrentSubtitle` pause `subtitlePrefetchService` and then call
|
||||
the matching `subtitleProcessingController` method, giving the visible overlay priority over
|
||||
background prefetch work. Prefetch is not re-centered here: restarting the run per line
|
||||
(`onSeek`) discarded the in-flight tokenization every time the subtitle changed, so only real
|
||||
seeks restart it (see `onTimePosUpdate` in `src/main.ts`).
|
||||
|
||||
On an uncached autoplay prime the raw payload is emitted here and reported to the controller with
|
||||
`notePlainSubtitleEmitted`, so the controller skips its own plain emit for that line and the
|
||||
overlay receives one plain payload followed by the annotated one.
|
||||
|
||||
The pause is released by the controller's `onProcessingSettled` callback, which fires once it has
|
||||
no work left. Emits do not release it: the first emit for an uncached line is the plain payload
|
||||
that precedes tokenization, and a run can finish without emitting at all (a suppressed duplicate,
|
||||
a failed tokenization). Both controller methods return whether processing is now pending, and the
|
||||
caller resumes immediately when it is not — a repeated subtitle schedules no work, so no settle is
|
||||
coming and prefetching would otherwise idle for the rest of the cue.
|
||||
|
||||
## Live Cue Delivery
|
||||
|
||||
- A tokenization cache miss emits the plain cue synchronously. Tokenization remains serialized so
|
||||
live work does not contend for Yomitan state.
|
||||
- If a newer cue arrives while an older line is still tokenizing, the newer plain cue or empty
|
||||
clear payload is emitted immediately. The older tokenization result is dropped before it can
|
||||
replace the current cue.
|
||||
- The current cue upgrades in place when its tokens and annotations are ready. This can reflow text
|
||||
or character images, but cue visibility does not wait for that work.
|
||||
|
||||
## Emitted State
|
||||
|
||||
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`, which sends the normal
|
||||
annotated subtitle payload to overlay windows and subtitle websocket listeners.
|
||||
- `emitSubtitle(payload)` maps to `emitSubtitlePayload(payload)`. Overlay windows and annotation
|
||||
websocket listeners receive both the immediate plain cue and its later annotation upgrade.
|
||||
- The basic subtitle websocket receives the immediate plain cue only. Because its serialized
|
||||
payload discards annotations, the later upgrade would be an identical duplicate and is skipped
|
||||
when text and cue timing match.
|
||||
- Secondary priming reads mpv `secondary-sub-text`, stores it in
|
||||
`mpvClient.currentSecondarySubText`, and broadcasts `secondary-subtitle:set` to overlay windows.
|
||||
- If secondary `requestProperty` fails, the primary flow stays complete and only a debug line is
|
||||
@@ -104,7 +129,11 @@ prefetch work and re-centers prefetch around the live playback time.
|
||||
path, empty or stale bounding shapes produced invisible or clipped subtitles even though the
|
||||
overlay window remained mapped above mpv.
|
||||
- Pointer pass-through should continue to use `setIgnoreMouseEvents(true, { forward: true })` and
|
||||
the Linux cursor-poll fallback, not bounding-shape clipping.
|
||||
the Linux cursor-poll fallback, not bounding-shape clipping. Note that on Windows click-through
|
||||
must go through `applyOverlayClickThrough()` (`src/core/services/overlay-click-through.ts`),
|
||||
which omits `forward: true` there: Electron implements forwarding with a global low-level mouse
|
||||
hook that lags mouse input system-wide whenever the main thread stalls; the Windows cursor poll
|
||||
handles overlay wake-up instead.
|
||||
- Visible-overlay show/reset marks Linux pointer passthrough state dirty even when the logical
|
||||
interaction state is already inactive. The next cursor-poll tick must still reapply
|
||||
`setIgnoreMouseEvents(true, { forward: true })`; otherwise a newly shown Electron overlay can keep
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# Documentation Catalog
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-08-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: finding internal docs or checking verification status
|
||||
|
||||
@@ -17,10 +17,10 @@ Read when: finding internal docs or checking verification status
|
||||
| KB rules | `docs/knowledge-base/README.md` | active | 2026-05-23 | maintenance policy |
|
||||
| Core beliefs | `docs/knowledge-base/core-beliefs.md` | active | 2026-03-13 | agent-first principles |
|
||||
| Quality scorecard | `docs/knowledge-base/quality.md` | active | 2026-03-13 | quality grades and gaps |
|
||||
| Workflow index | `docs/workflow/README.md` | active | 2026-05-23 | execution map |
|
||||
| Workflow index | `docs/workflow/README.md` | active | 2026-08-13 | execution map |
|
||||
| Planning guide | `docs/workflow/planning.md` | active | 2026-05-23 | lightweight vs execution plans |
|
||||
| Agent plugins | `docs/workflow/agent-plugins.md` | active | 2026-05-23 | repo-local agent workflow plugin ownership |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-05-23 | maintained verification lanes |
|
||||
| Agent skills | `docs/workflow/agent-skills.md` | active | 2026-08-13 | repo-local workflow skill ownership |
|
||||
| Verification guide | `docs/workflow/verification.md` | active | 2026-08-13 | maintained verification lanes |
|
||||
| Release guide | `docs/RELEASING.md` | active | 2026-05-23 | release checklist |
|
||||
|
||||
## Update Rules
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,184 +0,0 @@
|
||||
# Library Summary Replaces Per-Day Trends — Design
|
||||
|
||||
**Status:** Draft
|
||||
**Date:** 2026-04-09
|
||||
**Scope:** `stats/` frontend, `src/core/services/immersion-tracker/query-trends.ts` backend
|
||||
|
||||
## Problem
|
||||
|
||||
The "Library — Per Day" section on the stats Trends tab (`stats/src/components/trends/TrendsTab.tsx:224-254`) renders six stacked-area charts — Videos, Watch Time, Cards, Words, Lookups, and Lookups/100w, each broken down per title per day.
|
||||
|
||||
In practice these charts are not useful:
|
||||
|
||||
- Most titles only have activity on one or two days in a window, so they render as isolated bumps on a noisy baseline.
|
||||
- Stacking 7+ titles with mostly-zero days makes individual lines hard to follow.
|
||||
- The top "Activity" and "Period Trends" sections already answer "what am I doing per day" globally.
|
||||
- The "Library — Cumulative" section directly below already answers "which titles am I progressing through" with less noise.
|
||||
|
||||
The per-day section occupies significant vertical space without carrying its weight, and the user has confirmed it should be replaced.
|
||||
|
||||
## Goal
|
||||
|
||||
Replace the six per-day stacked charts with a single "Library — Summary" section that surfaces per-title aggregate statistics over the selected date range. The new view should make it trivially easy to answer: "For the selected window, which titles am I spending time on, how much mining output have they produced, and how efficient is my lookup rate on each?"
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Changing the "Library — Cumulative" section (stays as-is).
|
||||
- Changing the "Activity", "Period Trends", or "Patterns" sections.
|
||||
- Adding a new API endpoint — the existing dashboard endpoint is extended in place.
|
||||
- Renaming internal `anime*` data-model identifiers (`animeId`, `imm_anime`, etc.). Those stay per the convention established in `c5e778d7`; only new fields/types/user-visible strings use generic "title"/"library" wording.
|
||||
- Supporting a true all-time library view on the Trends tab. If that's ever wanted, it belongs on a different tab.
|
||||
|
||||
## Solution Overview
|
||||
|
||||
Delete the "Library — Per Day" section. In its place, add "Library — Summary", composed of:
|
||||
|
||||
1. A horizontal-bar leaderboard chart of watch time per title (top 10, descending).
|
||||
2. A sortable table of every title with activity in the selected window, with columns: Title, Watch Time, Videos, Sessions, Cards, Words, Lookups, Lookups/100w, Date Range.
|
||||
|
||||
Both controls are scoped to the top-of-page date range selector. The existing shared Anime Visibility filter continues to work — it now gates Summary + Cumulative instead of Per-Day + Cumulative.
|
||||
|
||||
## Backend
|
||||
|
||||
### New type
|
||||
|
||||
Add to `stats/src/types/stats.ts` and the backend query module:
|
||||
|
||||
```ts
|
||||
type LibrarySummaryRow = {
|
||||
title: string; // display title — anime series, YouTube video title, etc.
|
||||
watchTimeMin: number; // sum(total_active_min) across the window
|
||||
videos: number; // distinct video_id count
|
||||
sessions: number; // session count from imm_sessions
|
||||
cards: number; // sum(total_cards)
|
||||
words: number; // sum(total_tokens_seen)
|
||||
lookups: number; // sum(lookup_count) from imm_sessions
|
||||
lookupsPerHundred: number | null; // lookups / words * 100, null when words == 0
|
||||
firstWatched: number; // min(rollup_day) as epoch day, within the window
|
||||
lastWatched: number; // max(rollup_day) as epoch day, within the window
|
||||
};
|
||||
```
|
||||
|
||||
### Query changes in `src/core/services/immersion-tracker/query-trends.ts`
|
||||
|
||||
- Add `librarySummary: LibrarySummaryRow[]` to `TrendsDashboardQueryResult`.
|
||||
- Populate it from a single aggregating query over `imm_daily_rollups` joined to `imm_videos` → `imm_anime`, filtered by `rollup_day` within the selected window. Session count and lookup count come from `imm_sessions` aggregated by `video_id` and then grouped by the parent library entry. Use a single query (or at most two joined/unioned) — no N+1.
|
||||
- `imm_anime` is the generic library-grouping table; anime series, YouTube videos, and yt-dlp imports all land there. The internal table name stays `imm_anime`; only the new field uses generic naming.
|
||||
- Return rows pre-sorted by `watchTimeMin` descending so the leaderboard is zero-cost and the table default sort matches.
|
||||
- Emit `lookupsPerHundred: null` when `words == 0`.
|
||||
|
||||
### Removed from API response
|
||||
|
||||
Drop the entire `animePerDay` field from `TrendsDashboardQueryResult` (both backend in `src/core/services/immersion-tracker/query-trends.ts` and frontend in `stats/src/types/stats.ts`).
|
||||
|
||||
Internally, the existing helpers (`buildPerAnimeFromDailyRollups`, `buildEpisodesPerAnimeFromDailyRollups`) are still used as intermediates to build `animeCumulative.*` via `buildCumulativePerAnime`. Keep those helpers — just scope their output to local variables inside `getTrendsDashboard` instead of exposing them on the response. The `buildPerAnimeFromSessions` call for lookups and the `buildLookupsPerHundredPerAnime` helper become unused and can be deleted.
|
||||
|
||||
Before removing `animePerDay` from the frontend type, verify no other file under `stats/src/` references it. Based on current inspection, only `TrendsTab.tsx` and `stats/src/types/stats.ts` touch it.
|
||||
|
||||
## Frontend
|
||||
|
||||
### New component: `stats/src/components/trends/LibrarySummarySection.tsx`
|
||||
|
||||
Owns the header, leaderboard chart, visibility-filtered data, and the table. Keeps `TrendsTab.tsx` from growing. Component props: `{ rows: LibrarySummaryRow[]; hiddenTitles: ReadonlySet<string>; windowStart: Date; windowEnd: Date }`.
|
||||
|
||||
Internal state: `useState<{ column: ColumnId; direction: 'asc' | 'desc' }>` for sort, defaulting to `{ column: 'watchTimeMin', direction: 'desc' }`.
|
||||
|
||||
### Layout
|
||||
|
||||
Replaces `TrendsTab.tsx:224-254`:
|
||||
|
||||
```
|
||||
[SectionHeader: "Library — Summary"]
|
||||
[AnimeVisibilityFilter — unchanged, shared with Cumulative below]
|
||||
[Card, col-span-full: Leaderboard — horizontal bar chart, ~260px tall]
|
||||
[Card, col-span-full: Sortable table, auto height up to ~480px with internal scroll]
|
||||
```
|
||||
|
||||
Both cards use the existing chart/card wrapper styling.
|
||||
|
||||
### Leaderboard chart
|
||||
|
||||
- Recharts horizontal bar chart (matches the rest of the page — existing charts use `recharts`, not ECharts).
|
||||
- Top 10 titles by watch time. If fewer titles have activity, render what's there.
|
||||
- Y-axis: title (category), truncated with ellipsis at container width; full title visible in the Recharts tooltip.
|
||||
- X-axis: minutes (number).
|
||||
- Use `layout="vertical"` with `YAxis dataKey="title" type="category"` and `XAxis type="number"`.
|
||||
- Single series color: `#8aadf4` (matching the existing Watch Time color).
|
||||
- Reuse `CHART_DEFAULTS`, `CHART_THEME`, `TOOLTIP_CONTENT_STYLE` from `stats/src/lib/chart-theme.ts` so theming matches the rest of the dashboard.
|
||||
- Chart order is fixed at watch-time desc regardless of table sort — the leaderboard's meaning is fixed.
|
||||
|
||||
### Table
|
||||
|
||||
- Plain HTML `<table>` with Tailwind classes. No new deps.
|
||||
- Columns, in order:
|
||||
1. **Title** — left-aligned, sticky, truncated with ellipsis, full title on hover.
|
||||
2. **Watch Time** — formatted `Xh Ym` when ≥60 min, else `Xm`.
|
||||
3. **Videos** — integer.
|
||||
4. **Sessions** — integer.
|
||||
5. **Cards** — integer.
|
||||
6. **Words** — integer.
|
||||
7. **Lookups** — integer.
|
||||
8. **Lookups/100w** — one decimal place, `—` when null.
|
||||
9. **Date Range** — `Mon D → Mon D` using the title's `firstWatched` / `lastWatched` within the window.
|
||||
- Click a column header to sort; click again to reverse. Visual arrow on the active column.
|
||||
- Numeric columns right-aligned.
|
||||
- Null `lookupsPerHundred` sorts as the lowest value in both directions (consistent with "no data").
|
||||
- Row hover highlight; no row click action (read-only view).
|
||||
- Empty state: "No library activity in the selected window."
|
||||
|
||||
### Visibility filter integration
|
||||
|
||||
Hiding a title via `AnimeVisibilityFilter` removes it from both the leaderboard and the table. The filter's set of available titles is built from the union of titles that appear in `librarySummary` and the existing `animeCumulative.*` arrays (matches current behavior in `buildAnimeVisibilityOptions`).
|
||||
|
||||
### `TrendsTab.tsx` changes
|
||||
|
||||
- Remove the `filteredEpisodesPerAnime`, `filteredWatchTimePerAnime`, `filteredCardsPerAnime`, `filteredWordsPerAnime`, `filteredLookupsPerAnime`, `filteredLookupsPerHundredPerAnime` locals.
|
||||
- Remove the six `<StackedTrendChart>` calls in the "Library — Per Day" section.
|
||||
- Remove the `<SectionHeader>Library — Per Day</SectionHeader>` and the `<AnimeVisibilityFilter>` from that position.
|
||||
- Insert `<SectionHeader>Library — Summary</SectionHeader>` + `<AnimeVisibilityFilter>` + `<LibrarySummarySection>` in the same place.
|
||||
- Update `buildAnimeVisibilityOptions` input to use `librarySummary` titles instead of the six dropped `animePerDay.*` arrays.
|
||||
|
||||
## Data flow
|
||||
|
||||
1. `useTrends(range, groupBy)` calls `/api/stats/trends/dashboard`.
|
||||
2. Response now includes `librarySummary` (sorted by watch time desc).
|
||||
3. `TrendsTab` holds the shared `hiddenAnime` set (unchanged).
|
||||
4. `LibrarySummarySection` receives `librarySummary` + `hiddenAnime`, filters out hidden rows, renders the leaderboard from the top-10 slice of the filtered list, renders the table from the filtered list with local sort state applied.
|
||||
5. Date-range selector changes trigger a new fetch; `groupBy` toggle does not affect the summary section (it's always window-total).
|
||||
|
||||
## Edge cases
|
||||
|
||||
- **No activity in window:** Section renders header + empty-state card. Leaderboard card hidden. Visibility filter hidden.
|
||||
- **One title only:** Leaderboard renders a single bar; table renders one row. No special-casing.
|
||||
- **Title with zero words but non-zero lookups:** `lookupsPerHundred` is `null`, rendered as `—`. Sort treats null as lowest.
|
||||
- **Title with zero cards/lookups/words but non-zero watch time:** Normal zero rendering, still shown.
|
||||
- **Very long titles:** Ellipsis in chart y-axis labels and table title column; full title in `title` attribute / ECharts tooltip.
|
||||
- **Mixed sources (anime + YouTube):** No special case — both land in `imm_anime` and are grouped uniformly.
|
||||
|
||||
## Testing
|
||||
|
||||
### Backend (`query-trends.ts`)
|
||||
|
||||
New unit tests, following the existing pattern:
|
||||
|
||||
1. Empty window returns `librarySummary: []`.
|
||||
2. Single title with a few rollups: all aggregates are correct; `firstWatched`/`lastWatched` match the bounding days within the window.
|
||||
3. Multiple titles: rows returned sorted by watch time desc.
|
||||
4. Mixed sources (anime-style + YouTube-style entries in `imm_anime`): both appear in the summary with their own aggregates.
|
||||
5. Title with `words == 0`: `lookupsPerHundred` is `null`.
|
||||
6. Date range excludes some rollups: excluded rollups are not counted; `firstWatched`/`lastWatched` reflect only within-window activity.
|
||||
7. `sessions` and `lookups` come from `imm_sessions`, not `imm_daily_rollups`, and are correctly attributed to the parent library entry.
|
||||
|
||||
### Frontend
|
||||
|
||||
- Existing Trends tab smoke test should continue to pass after wiring.
|
||||
- Optional: a targeted render test for `LibrarySummarySection` (empty state, single title, sort toggle, visibility filter interaction). Not required for merge if the smoke test exercises the happy path.
|
||||
|
||||
## Release / docs
|
||||
|
||||
- One fragment in `changes/*.md` summarizing the replacement.
|
||||
- No user-facing docs (`docs-site/`) changes unless the per-day section was documented there — verify during implementation.
|
||||
|
||||
## Open items
|
||||
|
||||
None.
|
||||
@@ -1,347 +0,0 @@
|
||||
# Stats Dashboard Feedback Pass — Design
|
||||
|
||||
Date: 2026-04-09
|
||||
Scope: Stats dashboard UX follow-ups from user feedback (items 1–7).
|
||||
Delivery: **Single PR**, broken into logically scoped commits.
|
||||
|
||||
## Goals
|
||||
|
||||
Address seven concrete pieces of feedback against the Statistics menu:
|
||||
|
||||
1. Library — collapse episodes behind a per-series dropdown.
|
||||
2. Sessions — roll up multiple sessions of the same episode within a day.
|
||||
3. Trends — add a 365d range option.
|
||||
4. Library — delete an episode (video) from its detail view.
|
||||
5. Vocabulary — tighten spacing between word and reading in the Top 50 table.
|
||||
6. Episode detail — hide cards whose Anki notes have been deleted.
|
||||
7. Trend/watch charts — add gridlines, fix tick legibility, unify theming.
|
||||
|
||||
Out of scope for this pass: English-token ingestion cleanup and Overview stat-card drill-downs (feedback items 8 and 9). Those require a larger design decision and a migration respectively.
|
||||
|
||||
## Files touched (inventory)
|
||||
|
||||
Dashboard (`stats/src/`):
|
||||
- `components/library/LibraryTab.tsx` — collapsible groups (item 1).
|
||||
- `components/library/MediaDetailView.tsx`, `components/library/MediaHeader.tsx` — delete-episode action (item 4).
|
||||
- `components/sessions/SessionsTab.tsx`, `components/library/MediaSessionList.tsx` — episode rollup (item 2).
|
||||
- `components/trends/DateRangeSelector.tsx`, `hooks/useTrends.ts`, `lib/api-client.ts`, `lib/api-client.test.ts` — 365d (item 3).
|
||||
- `components/vocabulary/FrequencyRankTable.tsx` — word/reading column collapse (item 5).
|
||||
- `components/anime/EpisodeDetail.tsx` — filter deleted Anki cards (item 6).
|
||||
- `components/trends/TrendChart.tsx`, `components/trends/StackedTrendChart.tsx`, `components/overview/WatchTimeChart.tsx`, `lib/chart-theme.ts` — chart clarity (item 7).
|
||||
- New file: `stats/src/lib/session-grouping.ts` + `session-grouping.test.ts`.
|
||||
|
||||
Backend (`src/core/services/`):
|
||||
- `immersion-tracker/query-trends.ts` — extend `TrendRange` and `TREND_DAY_LIMITS` (item 3).
|
||||
- `immersion-tracker/__tests__/query.test.ts` — 365d coverage (item 3).
|
||||
- `stats-server.ts` — passthrough if range validation lives here (check before editing).
|
||||
- `__tests__/stats-server.test.ts` — 365d coverage (item 3).
|
||||
|
||||
## Commit plan
|
||||
|
||||
One PR, one feature per commit. Order picks low-risk mechanical changes first so failures in later commits don't block merging of earlier ones.
|
||||
|
||||
1. `feat(stats): add 365d range to trends dashboard` (item 3)
|
||||
2. `fix(stats): tighten word/reading column in Top 50 table` (item 5)
|
||||
3. `fix(stats): hide cards deleted from Anki in episode detail` (item 6)
|
||||
4. `feat(stats): delete episode from library detail view` (item 4)
|
||||
5. `feat(stats): collapsible series groups in library` (item 1)
|
||||
6. `feat(stats): roll up same-episode sessions within a day` (item 2)
|
||||
7. `feat(stats): gridlines and unified theme for trend charts` (item 7)
|
||||
|
||||
Each commit must pass `bun run typecheck`, `bun run test:fast`, and any change-specific checks listed below.
|
||||
|
||||
---
|
||||
|
||||
## Item 1 — Library collapsible series groups
|
||||
|
||||
### Current behavior
|
||||
|
||||
`LibraryTab.tsx` groups media via `groupMediaLibraryItems` and always renders the full grid of `MediaCard`s beneath each group header.
|
||||
|
||||
### Target behavior
|
||||
|
||||
Each group header becomes clickable. Groups with `items.length > 1` default to **collapsed**; single-video groups stay expanded (collapsing them would be visual noise).
|
||||
|
||||
### Implementation
|
||||
|
||||
- State: `const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(...)`. Initialize from `grouped` where `items.length > 1`.
|
||||
- Toggle helper: `toggleGroup(key: string)` adds/removes from the set.
|
||||
- Group header: wrap in a `<button>` with `aria-expanded` and a chevron icon (`▶`/`▼`). Keep the existing cover + title + subtitle layout inside the button.
|
||||
- Children grid is conditionally rendered on `!collapsedGroups.has(group.key)`.
|
||||
- Header summary (`N videos · duration · cards`) stays visible in both states so collapsed groups remain informative.
|
||||
|
||||
### Tests
|
||||
|
||||
- New `LibraryTab.test.tsx` (if not already present — check first) covering:
|
||||
- Multi-video group renders collapsed on first mount.
|
||||
- Single-video group renders expanded on first mount.
|
||||
- Clicking the header toggles visibility.
|
||||
- Header summary is visible in both states.
|
||||
|
||||
---
|
||||
|
||||
## Item 2 — Sessions episode rollup within a day
|
||||
|
||||
### Current behavior
|
||||
|
||||
`SessionsTab.tsx:10-24` groups sessions by day label only (`formatSessionDayLabel(startedAtMs)`). Multiple sessions of the same episode on the same day show as independent rows. `MediaSessionList.tsx` has the same problem inside the library detail view.
|
||||
|
||||
### Target behavior
|
||||
|
||||
Within each day, sessions with the same `videoId` collapse into one parent row showing combined totals. A chevron reveals the individual sessions. Single-session buckets render flat (no pointless nesting).
|
||||
|
||||
### Implementation
|
||||
|
||||
- New helper in `stats/src/lib/session-grouping.ts`:
|
||||
```ts
|
||||
export interface SessionBucket {
|
||||
key: string; // videoId as string, or `s-${sessionId}` for singletons
|
||||
videoId: number | null;
|
||||
sessions: SessionSummary[];
|
||||
totalActiveMs: number;
|
||||
totalCardsMined: number;
|
||||
representativeSession: SessionSummary; // most recent, for header display
|
||||
}
|
||||
export function groupSessionsByVideo(sessions: SessionSummary[]): SessionBucket[];
|
||||
```
|
||||
Sessions missing a `videoId` become singleton buckets.
|
||||
|
||||
- `SessionsTab.tsx`: after day grouping, pipe each `daySessions` through `groupSessionsByVideo`. Render each bucket:
|
||||
- `sessions.length === 1`: existing `SessionRow` behavior, unchanged.
|
||||
- `sessions.length >= 2`: render a **bucket row** that looks like `SessionRow` but shows combined totals and session count (e.g. `3 sessions · 1h 24m · 12 cards`). Chevron state stored in a second `Set<string>` on bucket key. Expanded buckets render the child `SessionRow`s indented (`pl-8`) beneath the header.
|
||||
- `MediaSessionList.tsx`: within the media detail view, a single video's sessions are all the same `videoId` by definition — grouping here is by day only, and within a day multiple sessions render nested under a day header. Re-use the same visual pattern; factor the bucket row into a shared `SessionBucketRow` component.
|
||||
|
||||
### Delete semantics
|
||||
|
||||
- Deleting a bucket header offers "Delete all N sessions in this group" (reuse `confirmDayGroupDelete` pattern with a bucket-specific message, or add `confirmBucketDelete`).
|
||||
- Deleting an individual session from inside an expanded bucket keeps the existing single-delete flow.
|
||||
|
||||
### Tests
|
||||
|
||||
- `session-grouping.test.ts`:
|
||||
- Empty input → empty output.
|
||||
- All unique videos → N singleton buckets.
|
||||
- Two sessions same videoId → one bucket with correct totals and representative (most recent start time).
|
||||
- Missing videoId → singleton bucket keyed by sessionId.
|
||||
- `SessionsTab.test.tsx` (extend or add) verifying the rendered bucket rows expand/collapse and delete hooks fire with the right ID set.
|
||||
|
||||
---
|
||||
|
||||
## Item 3 — 365d trends range
|
||||
|
||||
### Backend
|
||||
|
||||
`src/core/services/immersion-tracker/query-trends.ts`:
|
||||
- `type TrendRange = '7d' | '30d' | '90d' | '365d' | 'all';`
|
||||
- Add `'365d': 365` to `TREND_DAY_LIMITS`.
|
||||
- `getTrendDayLimit` picks up the new key automatically because of the `Exclude<TrendRange, 'all'>` generic.
|
||||
|
||||
`src/core/services/stats-server.ts`:
|
||||
- Search for any hardcoded range validation (e.g. allow-list in the trends route handler) and extend it.
|
||||
|
||||
### Frontend
|
||||
|
||||
- `hooks/useTrends.ts`: widen the `TimeRange` union.
|
||||
- `components/trends/DateRangeSelector.tsx`: add `'365d'` to the options list. Display label stays as `365d`.
|
||||
- `lib/api-client.ts` / `api-client.test.ts`: if the client validates ranges, add `365d`.
|
||||
|
||||
### Tests
|
||||
|
||||
- `query.test.ts`: extend the existing range table to cover `365d` returning 365 days of data.
|
||||
- `stats-server.test.ts`: ensure the route accepts `range=365d`.
|
||||
- `api-client.test.ts`: ensure the client emits the new range.
|
||||
|
||||
### Change-specific checks
|
||||
|
||||
- `bun run test:config` is not required here (no schema/defaults change).
|
||||
- Run `bun run typecheck` + `bun run test:fast`.
|
||||
|
||||
---
|
||||
|
||||
## Item 4 — Delete episode from library detail
|
||||
|
||||
### Current behavior
|
||||
|
||||
`MediaDetailView.tsx` provides session-level delete only. The backend `deleteVideo` exists (`query-maintenance.ts:509`), the API is exposed at `stats-server.ts:559`, and `api-client.deleteVideo` is already wired (`stats/src/lib/api-client.ts:146`). `EpisodeList.tsx:46` already uses it from the anime tab.
|
||||
|
||||
### Target behavior
|
||||
|
||||
A "Delete Episode" action in `MediaHeader` (top-right, small, `text-ctp-red`), gated by `confirmEpisodeDelete(title)`. On success, call `onBack()` and make sure the parent `LibraryTab` refetches.
|
||||
|
||||
### Implementation
|
||||
|
||||
- Add an `onDeleteEpisode?: () => void` prop to `MediaHeader` and render the button only if provided.
|
||||
- In `MediaDetailView`:
|
||||
- New handler `handleDeleteEpisode` that calls `apiClient.deleteVideo(videoId)`, then `onBack()`.
|
||||
- Reuse `confirmEpisodeDelete` from `stats/src/lib/delete-confirm.ts`.
|
||||
- In `LibraryTab`:
|
||||
- `useMediaLibrary` returns fresh data on mount. The simplest fix: pass a `refresh` function from the hook (extend the hook if it doesn't already expose one) and call it when the detail view signals back.
|
||||
- Alternative: force a remount by incrementing a `libraryVersion` key on the library list. Prefer `refresh` for clarity.
|
||||
|
||||
### Tests
|
||||
|
||||
- Extend the existing `MediaDetailView.test.tsx`: mock `apiClient.deleteVideo`, click the new button, confirm `onBack` fires after success.
|
||||
- `useMediaLibrary.test.ts`: if we add a `refresh` method, cover it.
|
||||
|
||||
---
|
||||
|
||||
## Item 5 — Vocabulary word/reading column collapse
|
||||
|
||||
### Current behavior
|
||||
|
||||
`FrequencyRankTable.tsx:110-144` uses a 5-column table: `Rank | Word | Reading | POS | Seen`. Word and Reading are auto-sized, producing a large gap.
|
||||
|
||||
### Target behavior
|
||||
|
||||
Merge Word + Reading into a single column titled "Word". Reading sits immediately after the headword in a muted, smaller style.
|
||||
|
||||
### Implementation
|
||||
|
||||
- Drop the `<th>Reading</th>` header and cell.
|
||||
- Word cell becomes:
|
||||
```tsx
|
||||
<td className="py-1.5 pr-3">
|
||||
<span className="text-ctp-text font-medium">{w.headword}</span>
|
||||
{reading && (
|
||||
<span className="text-ctp-subtext0 text-xs ml-1.5">
|
||||
【{reading}】
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
```
|
||||
where `reading = fullReading(w.headword, w.reading)` and differs from `headword`.
|
||||
- Keep `fullReading` import from `reading-utils`.
|
||||
|
||||
### Tests
|
||||
|
||||
- Extend `FrequencyRankTable.test.tsx` (if present — otherwise add a focused test) to assert:
|
||||
- Headword renders.
|
||||
- Reading renders when different from headword.
|
||||
- Reading does not render when equal to headword.
|
||||
|
||||
---
|
||||
|
||||
## Item 6 — Hide Anki-deleted cards in Cards Mined
|
||||
|
||||
### Current behavior
|
||||
|
||||
`EpisodeDetail.tsx:109-147` iterates `cardEvents`, fetches note info via `ankiNotesInfo(allNoteIds)`, and for each `noteId` renders a row even if no matching `info` came back — the user sees an empty word with an "Open in Anki" button that leads nowhere.
|
||||
|
||||
### Target behavior
|
||||
|
||||
After `ankiNotesInfo` resolves:
|
||||
- Drop `noteId`s that are not in the resolved map.
|
||||
- Drop `cardEvents` whose `noteIds` list was non-empty but is now empty after filtering.
|
||||
- Card events with a positive `cardsDelta` but no `noteIds` (legacy rollup path) still render as `+N cards` — we have no way to cross-reference them, so leave them alone.
|
||||
|
||||
### Implementation
|
||||
|
||||
- Compute `filteredCardEvents` as a `useMemo` depending on `data.cardEvents` and `noteInfos`.
|
||||
- Iterate `filteredCardEvents` instead of `cardEvents` in the render.
|
||||
- Surface a subtle note (optional, muted) "N cards hidden (deleted from Anki)" at the end of the list if any were filtered — helps the user understand why counts here diverge from session totals. Final decision on the note can be made at PR review; default: **show it**.
|
||||
|
||||
### Tests
|
||||
|
||||
- Add a test in `EpisodeDetail.test.tsx` (add the file if not present) that stubs `ankiNotesInfo` to return only a subset of notes and verifies the missing ones are not rendered.
|
||||
|
||||
### Other call sites
|
||||
|
||||
- Grep so far shows `ankiNotesInfo` is only used in `EpisodeDetail.tsx`. Re-verify before landing the commit; if another call site appears, apply the same filter.
|
||||
|
||||
---
|
||||
|
||||
## Item 7 — Trend/watch chart clarity pass
|
||||
|
||||
### Current behavior
|
||||
|
||||
`TrendChart.tsx`, `StackedTrendChart.tsx`, and `WatchTimeChart.tsx` render Recharts components with:
|
||||
- No `CartesianGrid` → no horizontal reference lines.
|
||||
- 9px axis ticks → borderline unreadable.
|
||||
- Height 120 → cramped.
|
||||
- Tooltip uses raw labels (`04/04` etc.).
|
||||
- No shared theme object; each chart redefines colors and tooltip styles inline.
|
||||
|
||||
`stats/src/lib/chart-theme.ts` already exists and currently exports a single `CHART_THEME` constant with tick/tooltip colors and `barFill`. It will be extended, not replaced, to preserve existing consumers.
|
||||
|
||||
### Target behavior
|
||||
|
||||
All three charts share a theme, have horizontal gridlines, readable ticks, and sensible tooltips.
|
||||
|
||||
### Implementation
|
||||
|
||||
Extend `stats/src/lib/chart-theme.ts` with the additional shared defaults (keeping the existing `CHART_THEME` export intact so current consumers don't break):
|
||||
```ts
|
||||
export const CHART_THEME = {
|
||||
tick: '#a5adcb',
|
||||
tooltipBg: '#363a4f',
|
||||
tooltipBorder: '#494d64',
|
||||
tooltipText: '#cad3f5',
|
||||
tooltipLabel: '#b8c0e0',
|
||||
barFill: '#8aadf4',
|
||||
grid: '#494d64',
|
||||
axisLine: '#494d64',
|
||||
} as const;
|
||||
|
||||
export const CHART_DEFAULTS = {
|
||||
height: 160,
|
||||
tickFontSize: 11,
|
||||
margin: { top: 8, right: 8, bottom: 0, left: 0 },
|
||||
grid: { strokeDasharray: '3 3', vertical: false },
|
||||
} as const;
|
||||
|
||||
export const TOOLTIP_CONTENT_STYLE = {
|
||||
background: CHART_THEME.tooltipBg,
|
||||
border: `1px solid ${CHART_THEME.tooltipBorder}`,
|
||||
borderRadius: 6,
|
||||
color: CHART_THEME.tooltipText,
|
||||
fontSize: 12,
|
||||
};
|
||||
```
|
||||
|
||||
Apply to each chart:
|
||||
- Import `CartesianGrid` from recharts.
|
||||
- Insert `<CartesianGrid stroke={CHART_THEME.grid} {...CHART_DEFAULTS.grid} />` inside each chart container.
|
||||
- `<XAxis tick={{ fontSize: CHART_DEFAULTS.tickFontSize, fill: CHART_THEME.tick }} />` and equivalent `YAxis`.
|
||||
- `YAxis` gains `axisLine={{ stroke: CHART_THEME.axisLine }}`.
|
||||
- `ResponsiveContainer` height changes from 120 → `CHART_DEFAULTS.height`.
|
||||
- `Tooltip` `contentStyle` uses `TOOLTIP_CONTENT_STYLE`, and charts pass a `labelFormatter` when the label is a date key (e.g. show `Fri Apr 4`).
|
||||
|
||||
### Unit formatters
|
||||
|
||||
- `TrendChart` already accepts a `formatter` prop — extend usage sites to pass unit-aware formatters where they aren't already (`formatDuration`, `formatNumber`, etc.).
|
||||
|
||||
### Tests
|
||||
|
||||
- `chart-theme.test.ts` (if present — otherwise add a trivial snapshot to keep the shape stable).
|
||||
- `TrendChart` snapshot/render tests: no regression, gridline element present.
|
||||
|
||||
---
|
||||
|
||||
## Verification gate
|
||||
|
||||
Before requesting code review, run:
|
||||
|
||||
```
|
||||
bun run typecheck
|
||||
bun run test:fast
|
||||
bun run test:env
|
||||
bun run test:runtime:compat # dist-sensitive check for the charts
|
||||
bun run build
|
||||
bun run test:smoke:dist
|
||||
```
|
||||
|
||||
No docs-site changes are planned in this spec; if `docs-site/` ends up touched (e.g. screenshots), also run `bun run docs:test` and `bun run docs:build`.
|
||||
|
||||
No config schema changes → `bun run test:config` and `bun run generate:config-example` are not required.
|
||||
|
||||
## Risks and open questions
|
||||
|
||||
- **MediaDetailView refresh**: `useMediaLibrary` may not expose a `refresh` function. If it doesn't, the simplest path is adding one; the alternative (keying a remount) works but is harder to test. Decide during implementation.
|
||||
- **Session bucket delete UX**: "Delete all N sessions in this group" is powerful. The copy must make it clear the underlying sessions are being removed, not just the grouping. Reuse `confirmBucketDelete` wording from existing confirm helpers if possible.
|
||||
- **Anki-deleted-cards hidden notice**: Showing a subtle "N cards hidden" footer is a call that can be made at PR review.
|
||||
- **Bucket delete helper**: `confirmBucketDelete` does not currently exist in `delete-confirm.ts`. Implementation either adds it or reuses `confirmDayGroupDelete` with bucket-specific wording — decide during the session-rollup commit.
|
||||
|
||||
## Changelog entry
|
||||
|
||||
User-visible PR → needs a fragment under `changes/*.md`. Suggested title:
|
||||
`Stats dashboard: collapsible series, session rollups, 365d trends, chart polish, episode delete.`
|
||||
@@ -3,7 +3,7 @@
|
||||
# Workflow
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Last verified: 2026-08-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: planning or executing nontrivial work in this repo
|
||||
|
||||
@@ -13,7 +13,7 @@ This section is the internal workflow map for contributors and agents.
|
||||
|
||||
- [Planning](./planning.md) - when to write a lightweight plan vs a full execution plan
|
||||
- [Verification](./verification.md) - maintained test/build lanes and handoff gate
|
||||
- [Agent Plugins](./agent-plugins.md) - repo-local plugin ownership for agent workflow skills
|
||||
- [Agent Skills](./agent-skills.md) - repo-local workflow skill ownership
|
||||
- [Release Guide](../RELEASING.md) - tagged release workflow
|
||||
|
||||
## Default Flow
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
<!-- read_when: using or modifying repo-local agent plugins -->
|
||||
|
||||
# Agent Plugins
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-05-23
|
||||
Owner: Kyle Yasuda
|
||||
Read when: packaging or migrating repo-local agent workflow skills into plugins
|
||||
|
||||
## SubMiner Workflow Plugin
|
||||
|
||||
- Canonical plugin path: `plugins/subminer-workflow/`
|
||||
- Marketplace catalog: `.agents/plugins/marketplace.json`
|
||||
- Canonical skill sources:
|
||||
- `plugins/subminer-workflow/skills/subminer-scrum-master/`
|
||||
- `plugins/subminer-workflow/skills/subminer-change-verification/`
|
||||
|
||||
## Migration Rule
|
||||
|
||||
- Plugin-owned skills are the source of truth.
|
||||
- `.agents/skills/subminer-*` remain only as compatibility shims.
|
||||
- Existing script entrypoints under `.agents/skills/subminer-change-verification/scripts/` stay as wrappers so historical commands do not break.
|
||||
|
||||
## Verification
|
||||
|
||||
- For plugin/docs-only changes, start with `bun run test:docs:kb`.
|
||||
- Use the plugin-owned verifier when the change crosses from docs into scripts or workflow logic.
|
||||
@@ -0,0 +1,31 @@
|
||||
<!-- read_when: using or modifying repo-local agent skills -->
|
||||
|
||||
# Agent Skills
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-08-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: using, adding, or changing a repo-local agent workflow skill
|
||||
|
||||
## Canonical Skills
|
||||
|
||||
- `.agents/skills/subminer-change-verification/`
|
||||
- Selects the cheapest sufficient repo-native verification lane.
|
||||
- Defers command ownership to `package.json` and `docs/workflow/verification.md`.
|
||||
|
||||
Repo-local workflows stay as standalone skills. Do not add plugin packaging, marketplace metadata, or compatibility shims unless the workflow is intentionally being distributed beyond this repository.
|
||||
|
||||
## Rules
|
||||
|
||||
- Keep each skill focused on one repeatable repository task.
|
||||
- Prefer instructions over helper scripts unless deterministic tooling provides clear value beyond existing package commands.
|
||||
- Keep trigger descriptions narrow enough to avoid invoking skills for unrelated requests.
|
||||
- Update this page and the documentation catalog when skill ownership changes.
|
||||
|
||||
## Verification
|
||||
|
||||
For skill or internal workflow documentation changes, run:
|
||||
|
||||
```bash
|
||||
bun run test:docs:kb
|
||||
```
|
||||
@@ -3,14 +3,14 @@
|
||||
# Verification
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-07-06
|
||||
Last verified: 2026-08-13
|
||||
Owner: Kyle Yasuda
|
||||
Read when: selecting the right verification lane for a change
|
||||
|
||||
## Lane Infrastructure
|
||||
|
||||
- Lane membership is defined once in `scripts/test-lanes.ts` and discovered by
|
||||
directory — new test files join their lane automatically; never hand-list test
|
||||
directory, so new test files join their lane automatically; never hand-list test
|
||||
files in `package.json`.
|
||||
- `scripts/run-test-lane.mjs` runs each test file in its own `bun test` process
|
||||
(per-file isolation with a wall timeout) so a hanging test or leaked global in
|
||||
@@ -43,8 +43,8 @@ bun run docs:build
|
||||
|
||||
## Cheap-First Lane Selection
|
||||
|
||||
- Docs-only boundary/content changes: `bun run docs:test`, `bun run docs:build`
|
||||
- Internal KB / `AGENTS.md` changes: `bun run test:docs:kb`
|
||||
- User-facing `docs-site/` changes: `bun run docs:test`, `bun run docs:build`
|
||||
- Internal KB, `AGENTS.md`, or `.agents/skills/**` changes: `bun run test:docs:kb`
|
||||
- Config/schema/defaults: `bun run test:config`, then `bun run generate:config-example` if template/defaults changed
|
||||
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||
- Runtime-compat / compiled behavior: `bun run test:runtime:compat`
|
||||
|
||||
@@ -157,6 +157,15 @@ export async function runStatsCommand(
|
||||
if (args.statsCleanupLifetime) {
|
||||
forwarded.push('--stats-cleanup-lifetime');
|
||||
}
|
||||
if (args.statsCleanupDuplicateLines) {
|
||||
forwarded.push('--stats-cleanup-duplicate-lines');
|
||||
}
|
||||
if (args.statsCleanupDryRun) {
|
||||
forwarded.push('--stats-cleanup-dry-run');
|
||||
}
|
||||
if (args.statsCleanupLookbackDays) {
|
||||
forwarded.push('--stats-cleanup-lookback-days', String(args.statsCleanupLookbackDays));
|
||||
}
|
||||
if (shouldForwardLogLevel(args.logLevel)) {
|
||||
forwarded.push('--log-level', args.logLevel);
|
||||
}
|
||||
|
||||
@@ -134,6 +134,9 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
@@ -185,6 +188,9 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
@@ -229,6 +235,9 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
@@ -271,6 +280,9 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
statsCleanupLookbackDays: null,
|
||||
statsLogLevel: null,
|
||||
syncTriggered: false,
|
||||
syncCliTokens: [],
|
||||
|
||||
@@ -162,6 +162,8 @@ export function createDefaultArgs(
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
logsExport: false,
|
||||
@@ -258,6 +260,11 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
if (invocations.statsCleanup) parsed.statsCleanup = true;
|
||||
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
|
||||
if (invocations.statsCleanupLifetime) parsed.statsCleanupLifetime = true;
|
||||
if (invocations.statsCleanupDuplicateLines) parsed.statsCleanupDuplicateLines = true;
|
||||
if (invocations.statsCleanupDryRun) parsed.statsCleanupDryRun = true;
|
||||
if (invocations.statsCleanupLookbackDays !== null) {
|
||||
parsed.statsCleanupLookbackDays = invocations.statsCleanupLookbackDays;
|
||||
}
|
||||
if (invocations.dictionaryTarget) {
|
||||
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
||||
} else if (
|
||||
|
||||
@@ -37,6 +37,9 @@ export interface CliInvocations {
|
||||
statsCleanup: boolean;
|
||||
statsCleanupVocab: boolean;
|
||||
statsCleanupLifetime: boolean;
|
||||
statsCleanupDuplicateLines: boolean;
|
||||
statsCleanupDryRun: boolean;
|
||||
statsCleanupLookbackDays: number | null;
|
||||
statsLogLevel: string | null;
|
||||
syncTriggered: boolean;
|
||||
syncCliTokens: string[];
|
||||
@@ -53,6 +56,16 @@ export interface CliInvocations {
|
||||
texthookerOpenBrowser: boolean;
|
||||
}
|
||||
|
||||
/** `--lookback-days` narrows the duplicate-line cleanup; fractions are floored. */
|
||||
function parseStatsLookbackDays(value: unknown): number | null {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') return null;
|
||||
const days = Number(value);
|
||||
if (!Number.isFinite(days) || days < 1) {
|
||||
throw new Error('Stats --lookback-days must be at least one day.');
|
||||
}
|
||||
return Math.floor(days);
|
||||
}
|
||||
|
||||
function applyRootOptions(program: Command): void {
|
||||
program
|
||||
.option(
|
||||
@@ -169,6 +182,9 @@ export function parseCliPrograms(
|
||||
let statsCleanup = false;
|
||||
let statsCleanupVocab = false;
|
||||
let statsCleanupLifetime = false;
|
||||
let statsCleanupDuplicateLines = false;
|
||||
let statsCleanupDryRun = false;
|
||||
let statsCleanupLookbackDays: number | null = null;
|
||||
let statsLogLevel: string | null = null;
|
||||
let syncTriggered = false;
|
||||
let syncCliTokens: string[] = [];
|
||||
@@ -269,6 +285,9 @@ export function parseCliPrograms(
|
||||
.option('-s, --stop', 'Stop the background stats server')
|
||||
.option('-v, --vocab', 'Clean vocabulary rows in the stats database')
|
||||
.option('-l, --lifetime', 'Rebuild lifetime summary rows from retained data')
|
||||
.option('-d, --duplicate-lines', 'Collapse repeated subtitle lines from typeset animations')
|
||||
.option('--dry-run', 'Report what a cleanup would remove without changing anything')
|
||||
.option('--lookback-days <days>', 'Only clean lines recorded in the last N days')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
statsTriggered = true;
|
||||
@@ -289,13 +308,35 @@ export function parseCliPrograms(
|
||||
if (normalizedAction && (statsBackground || statsStop)) {
|
||||
throw new Error('Stats background and stop flags cannot be combined with stats actions.');
|
||||
}
|
||||
if (normalizedAction !== 'cleanup' && (options.vocab === true || options.lifetime === true)) {
|
||||
throw new Error('Stats --vocab and --lifetime flags require the cleanup action.');
|
||||
if (
|
||||
normalizedAction !== 'cleanup' &&
|
||||
(options.vocab === true || options.lifetime === true || options.duplicateLines === true)
|
||||
) {
|
||||
throw new Error(
|
||||
'Stats --vocab, --lifetime and --duplicate-lines flags require the cleanup action.',
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.duplicateLines !== true &&
|
||||
(options.dryRun === true || options.lookbackDays !== undefined)
|
||||
) {
|
||||
throw new Error('Stats --dry-run and --lookback-days require --duplicate-lines.');
|
||||
}
|
||||
if (normalizedAction === 'cleanup') {
|
||||
statsCleanup = true;
|
||||
statsCleanupLifetime = options.lifetime === true;
|
||||
statsCleanupVocab = statsCleanupLifetime ? false : options.vocab !== false;
|
||||
statsCleanupDuplicateLines = options.duplicateLines === true;
|
||||
const explicitModeCount = [options.vocab, options.lifetime, options.duplicateLines].filter(
|
||||
(value) => value === true,
|
||||
).length;
|
||||
if (explicitModeCount > 1) {
|
||||
throw new Error('Stats cleanup runs one mode at a time.');
|
||||
}
|
||||
// Vocabulary cleanup stays the default so `stats cleanup` keeps its old meaning.
|
||||
statsCleanupVocab =
|
||||
statsCleanupLifetime || statsCleanupDuplicateLines ? false : options.vocab !== false;
|
||||
statsCleanupDryRun = options.dryRun === true;
|
||||
statsCleanupLookbackDays = parseStatsLookbackDays(options.lookbackDays);
|
||||
} else if (normalizedAction === 'rebuild' || normalizedAction === 'backfill') {
|
||||
statsCleanup = true;
|
||||
statsCleanupLifetime = true;
|
||||
@@ -483,6 +524,9 @@ export function parseCliPrograms(
|
||||
statsCleanup,
|
||||
statsCleanupVocab,
|
||||
statsCleanupLifetime,
|
||||
statsCleanupDuplicateLines,
|
||||
statsCleanupDryRun,
|
||||
statsCleanupLookbackDays,
|
||||
statsLogLevel,
|
||||
syncTriggered,
|
||||
syncCliTokens,
|
||||
|
||||
@@ -222,7 +222,7 @@ test('buildMpvEnv preserves native Wayland env for supported Hyprland and Sway a
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMpvBackendArgs forces an explicit X11 renderer stack when backend resolves to x11', () => {
|
||||
test('buildMpvBackendArgs pins the X11 window context when backend resolves to x11', () => {
|
||||
withPlatform('linux', () => {
|
||||
assert.deepEqual(
|
||||
buildMpvBackendArgs(makeArgs({ backend: 'x11' }), {
|
||||
@@ -230,12 +230,12 @@ test('buildMpvBackendArgs forces an explicit X11 renderer stack when backend res
|
||||
WAYLAND_DISPLAY: 'wayland-0',
|
||||
XDG_SESSION_TYPE: 'wayland',
|
||||
}),
|
||||
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
|
||||
['--gpu-context=x11vk,x11egl,x11'],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('buildMpvBackendArgs forces the same X11 renderer stack for unsupported Wayland auto fallback', () => {
|
||||
test('buildMpvBackendArgs pins the same X11 window context for unsupported Wayland auto fallback', () => {
|
||||
withPlatform('linux', () => {
|
||||
assert.deepEqual(
|
||||
buildMpvBackendArgs(makeArgs({ backend: 'auto' }), {
|
||||
@@ -245,7 +245,7 @@ test('buildMpvBackendArgs forces the same X11 renderer stack for unsupported Way
|
||||
XDG_CURRENT_DESKTOP: 'KDE',
|
||||
XDG_SESSION_DESKTOP: 'plasma',
|
||||
}),
|
||||
['--vo=gpu', '--gpu-api=opengl', '--gpu-context=x11egl,x11'],
|
||||
['--gpu-context=x11vk,x11egl,x11'],
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -292,9 +292,7 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
|
||||
'--secondary-sub-visibility=no',
|
||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||
'--vo=gpu',
|
||||
'--gpu-api=opengl',
|
||||
'--gpu-context=x11egl,x11',
|
||||
'--gpu-context=x11vk,x11egl,x11',
|
||||
'--window-maximized=yes',
|
||||
],
|
||||
);
|
||||
|
||||
@@ -232,6 +232,75 @@ test('parseArgs maps lifetime stats cleanup flag', () => {
|
||||
assert.equal(parsed.statsCleanupLifetime, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps duplicate-line stats cleanup flags', () => {
|
||||
const parsed = parseArgs(
|
||||
['stats', 'cleanup', '--duplicate-lines', '--dry-run', '--lookback-days', '30'],
|
||||
'subminer',
|
||||
{},
|
||||
);
|
||||
|
||||
assert.equal(parsed.statsCleanup, true);
|
||||
assert.equal(parsed.statsCleanupVocab, false);
|
||||
assert.equal(parsed.statsCleanupDuplicateLines, true);
|
||||
assert.equal(parsed.statsCleanupDryRun, true);
|
||||
assert.equal(parsed.statsCleanupLookbackDays, 30);
|
||||
|
||||
const fractional = parseArgs(
|
||||
['stats', 'cleanup', '--duplicate-lines', '--lookback-days', '1.5'],
|
||||
'subminer',
|
||||
{},
|
||||
);
|
||||
assert.equal(fractional.statsCleanupLookbackDays, 1);
|
||||
});
|
||||
|
||||
test('parseArgs rejects duplicate-line flags without the duplicate-lines mode', () => {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
parseArgs(['stats', 'cleanup', '--dry-run'], 'subminer', {});
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
assert.match(error.stderr, /--dry-run and --lookback-days require --duplicate-lines/);
|
||||
});
|
||||
|
||||
test('parseArgs rejects an empty lookback value outside duplicate-line cleanup', () => {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
parseArgs(['stats', '--lookback-days', ''], 'subminer', {});
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
assert.match(error.stderr, /--dry-run and --lookback-days require --duplicate-lines/);
|
||||
});
|
||||
|
||||
test('parseArgs rejects combining explicit cleanup modes', () => {
|
||||
for (const modes of [
|
||||
['--lifetime', '--duplicate-lines'],
|
||||
['--vocab', '--duplicate-lines'],
|
||||
['--vocab', '--lifetime'],
|
||||
]) {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
parseArgs(['stats', 'cleanup', ...modes], 'subminer', {});
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
assert.match(error.stderr, /Stats cleanup runs one mode at a time/);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseArgs rejects unusable lookback windows', () => {
|
||||
for (const value of ['0', '0.5', '-5', 'soon']) {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
parseArgs(
|
||||
['stats', 'cleanup', '--duplicate-lines', '--lookback-days', value],
|
||||
'subminer',
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
assert.match(error.stderr, /--lookback-days must be at least one day/);
|
||||
}
|
||||
});
|
||||
|
||||
test('parseArgs rejects cleanup-only stats flags without cleanup action', () => {
|
||||
const error = withProcessExitIntercept(() => {
|
||||
parseArgs(['stats', '--vocab'], 'subminer', {});
|
||||
@@ -239,7 +308,10 @@ test('parseArgs rejects cleanup-only stats flags without cleanup action', () =>
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
assert.match(error.message, /exit:1/);
|
||||
assert.match(error.stderr, /Stats --vocab and --lifetime flags require the cleanup action/);
|
||||
assert.match(
|
||||
error.stderr,
|
||||
/Stats --vocab, --lifetime and --duplicate-lines flags require the cleanup action/,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseArgs maps stats rebuild action to cleanup lifetime mode', () => {
|
||||
|
||||
@@ -80,6 +80,11 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
|
||||
{ headword: '食べる', word: '食べた', reading: 'たべた', count: 1 },
|
||||
],
|
||||
});
|
||||
withWritableDb(remotePath, (db) => {
|
||||
db.prepare(
|
||||
`UPDATE imm_videos SET anime_assignment_locked = 1 WHERE video_key = 'showb-e1'`,
|
||||
).run();
|
||||
});
|
||||
|
||||
const summary = mergeSnapshotIntoDb(localPath, remotePath);
|
||||
assert.equal(summary.sessionsMerged, 1);
|
||||
@@ -126,6 +131,14 @@ test('merges remote-only sessions with catalog, lifetime, and rollups', () => {
|
||||
`SELECT video_id FROM imm_videos WHERE video_key = 'showb-e1'`,
|
||||
)?.video_id,
|
||||
);
|
||||
assert.equal(
|
||||
queryOne<{ locked: number }>(
|
||||
localPath,
|
||||
'SELECT anime_assignment_locked AS locked FROM imm_videos WHERE video_id = ?',
|
||||
[mergedVideoId],
|
||||
)?.locked,
|
||||
1,
|
||||
);
|
||||
assert.equal(
|
||||
count(localPath, 'SELECT COUNT(*) AS n FROM imm_daily_rollups WHERE video_id = ?', [
|
||||
mergedVideoId,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Schema-version-18 shape of the tables the sync merge touches (plus the
|
||||
// Current schema shape of the tables the sync merge touches (plus the
|
||||
// app's indexes), mirroring ensureSchema / ensureLifetimeSummaryTables /
|
||||
// ensureStatsExcludedWordsTable in src/core/services/immersion-tracker/storage.ts.
|
||||
export const IMMERSION_DB_FIXTURE_DDL = `
|
||||
@@ -39,6 +39,7 @@ export const IMMERSION_DB_FIXTURE_DDL = `
|
||||
parser_source TEXT,
|
||||
parser_confidence REAL,
|
||||
parse_metadata_json TEXT,
|
||||
anime_assignment_locked INTEGER NOT NULL DEFAULT 0 CHECK(anime_assignment_locked IN (0, 1)),
|
||||
watched INTEGER NOT NULL DEFAULT 0,
|
||||
duration_ms INTEGER NOT NULL CHECK(duration_ms>=0),
|
||||
file_size_bytes INTEGER CHECK(file_size_bytes>=0),
|
||||
|
||||
@@ -142,6 +142,9 @@ export interface Args {
|
||||
statsCleanup?: boolean;
|
||||
statsCleanupVocab?: boolean;
|
||||
statsCleanupLifetime?: boolean;
|
||||
statsCleanupDuplicateLines?: boolean;
|
||||
statsCleanupDryRun?: boolean;
|
||||
statsCleanupLookbackDays?: number;
|
||||
dictionaryTarget?: string;
|
||||
doctor: boolean;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
|
||||
+11
-5
@@ -2,7 +2,7 @@
|
||||
"name": "subminer",
|
||||
"productName": "SubMiner",
|
||||
"desktopName": "SubMiner.desktop",
|
||||
"version": "0.19.0",
|
||||
"version": "0.19.4-beta.1",
|
||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"main": "dist/main-entry.js",
|
||||
@@ -84,16 +84,18 @@
|
||||
"overrides": {
|
||||
"@xmldom/xmldom": "0.8.13",
|
||||
"app-builder-lib": "26.15.3",
|
||||
"brace-expansion": "5.0.8",
|
||||
"brace-expansion": "5.0.9",
|
||||
"electron-builder-squirrel-windows": "26.15.3",
|
||||
"fast-uri": "3.1.5",
|
||||
"form-data": "4.0.6",
|
||||
"ip-address": "10.2.0",
|
||||
"js-yaml": "4.3.0",
|
||||
"js-yaml": "4.3.1",
|
||||
"lodash": "4.18.0",
|
||||
"minimatch": "10.2.5",
|
||||
"picomatch": "4.0.4",
|
||||
"tar": "7.5.21",
|
||||
"tmp": "0.2.7"
|
||||
"tmp": "0.2.7",
|
||||
"undici": "7.29.0"
|
||||
},
|
||||
"keywords": [
|
||||
"anki",
|
||||
@@ -125,7 +127,7 @@
|
||||
"@types/ws": "^8.18.1",
|
||||
"electron": "42.6.0",
|
||||
"electron-builder": "26.15.3",
|
||||
"undici": "7.28.0",
|
||||
"undici": "7.29.0",
|
||||
"esbuild": "^0.25.12",
|
||||
"eslint": "^10.8.0",
|
||||
"prettier": "^3.8.1",
|
||||
@@ -258,6 +260,10 @@
|
||||
{
|
||||
"from": "dist/launcher/subminer",
|
||||
"to": "launcher/subminer"
|
||||
},
|
||||
{
|
||||
"from": "CHANGELOG.md",
|
||||
"to": "CHANGELOG.md"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -106,8 +106,11 @@ function M.create(ctx)
|
||||
|
||||
local function get_subtitle_ass_property()
|
||||
local ass_text = mp.get_property("sub-text/ass")
|
||||
if type(ass_text) == "string" and ass_text ~= "" then
|
||||
return ass_text
|
||||
if ass_text ~= nil then
|
||||
if type(ass_text) == "string" and ass_text ~= "" then
|
||||
return ass_text
|
||||
end
|
||||
return nil
|
||||
end
|
||||
ass_text = mp.get_property("sub-text-ass")
|
||||
if type(ass_text) == "string" and ass_text ~= "" then
|
||||
|
||||
@@ -232,7 +232,7 @@ function M.create(ctx)
|
||||
elseif action_id == "triggerFieldGrouping" then
|
||||
return { "--trigger-field-grouping" }
|
||||
elseif action_id == "triggerSubsync" then
|
||||
return { "--trigger-subsync" }
|
||||
return { "--session-action", '{"actionId":"triggerSubsync"}' }
|
||||
elseif action_id == "mineSentence" then
|
||||
return { "--mine-sentence" }
|
||||
elseif action_id == "mineSentenceMultiple" then
|
||||
@@ -251,7 +251,7 @@ function M.create(ctx)
|
||||
elseif action_id == "markWatched" then
|
||||
return { "--mark-watched" }
|
||||
elseif action_id == "openRuntimeOptions" then
|
||||
return { "--open-runtime-options" }
|
||||
return { "--session-action", '{"actionId":"openRuntimeOptions"}' }
|
||||
elseif action_id == "openJimaku" then
|
||||
return { "--open-jimaku" }
|
||||
elseif action_id == "openTsukihime" or action_id == "openAnimetosho" then
|
||||
@@ -259,7 +259,7 @@ function M.create(ctx)
|
||||
elseif action_id == "openYoutubePicker" then
|
||||
return { "--open-youtube-picker" }
|
||||
elseif action_id == "openSessionHelp" then
|
||||
return { "--open-session-help" }
|
||||
return { "--session-action", '{"actionId":"openSessionHelp"}' }
|
||||
elseif action_id == "openCharacterDictionaryManager" then
|
||||
return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' }
|
||||
elseif action_id == "openControllerSelect" then
|
||||
|
||||
+13
-1
@@ -4,6 +4,7 @@ function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local input = ctx.input
|
||||
local process = ctx.process
|
||||
local state = ctx.state
|
||||
local subminer_log = ctx.log.subminer_log
|
||||
local show_osd = ctx.log.show_osd
|
||||
|
||||
@@ -93,7 +94,18 @@ function M.create(ctx)
|
||||
if not ensure_binary_for_menu() then
|
||||
return
|
||||
end
|
||||
process.run_control_command_async("open-session-help")
|
||||
process.run_binary_command_async({
|
||||
state.binary_path,
|
||||
"--session-action",
|
||||
'{"actionId":"openSessionHelp"}',
|
||||
}, 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)
|
||||
end
|
||||
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
{
|
||||
"name": "subminer-workflow",
|
||||
"version": "0.1.0",
|
||||
"description": "Repo-local SubMiner agent workflow plugin for orchestration and change verification.",
|
||||
"author": {
|
||||
"name": "Kyle Yasuda",
|
||||
"email": "suda@sudacode.com",
|
||||
"url": "https://github.com/sudacode"
|
||||
},
|
||||
"homepage": "https://github.com/sudacode/SubMiner/tree/main/plugins/subminer-workflow",
|
||||
"repository": "https://github.com/sudacode/SubMiner",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"keywords": ["subminer", "workflow", "verification", "skills"],
|
||||
"skills": "./skills/",
|
||||
"interface": {
|
||||
"displayName": "SubMiner Workflow",
|
||||
"shortDescription": "SubMiner orchestration and verification.",
|
||||
"longDescription": "Canonical repo-local plugin for SubMiner agent workflow packaging. Owns the scrum-master and change-verification skills plus helper scripts used to plan, verify, and validate changes reproducibly inside this repo.",
|
||||
"developerName": "Kyle Yasuda",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Interactive", "Write"],
|
||||
"websiteURL": "https://github.com/sudacode/SubMiner",
|
||||
"defaultPrompt": [
|
||||
"Use SubMiner workflow to plan and ship a feature.",
|
||||
"Verify a SubMiner change with the plugin-owned verifier.",
|
||||
"Plan and ship this SubMiner task."
|
||||
],
|
||||
"brandColor": "#2F6B4F"
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<!-- read_when: migrating or using the repo-local SubMiner workflow plugin -->
|
||||
|
||||
# SubMiner Workflow Plugin
|
||||
|
||||
Status: active
|
||||
Last verified: 2026-03-26
|
||||
Owner: Kyle Yasuda
|
||||
Read when: using or updating the repo-local plugin that owns SubMiner agent workflow skills
|
||||
|
||||
This plugin is the canonical source of truth for the SubMiner agent workflow packaging.
|
||||
|
||||
## Contents
|
||||
|
||||
- `skills/subminer-scrum-master/`
|
||||
- intake, planning, dispatch, and handoff workflow
|
||||
- `skills/subminer-change-verification/`
|
||||
- cheap-first verification workflow plus helper scripts
|
||||
|
||||
## Compatibility
|
||||
|
||||
- `.agents/skills/subminer-scrum-master/` is a compatibility shim that redirects to the plugin-owned skill.
|
||||
- `.agents/skills/subminer-change-verification/` is a compatibility shim.
|
||||
- `.agents/skills/subminer-change-verification/scripts/*.sh` remain as wrapper entrypoints so existing docs and shell history keep working.
|
||||
|
||||
## Verification
|
||||
|
||||
For plugin/doc/shim changes, prefer:
|
||||
|
||||
```bash
|
||||
bun run test:docs:kb
|
||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh --lane docs --lane core \
|
||||
plugins/subminer-workflow \
|
||||
.agents/skills/subminer-scrum-master/SKILL.md \
|
||||
.agents/skills/subminer-change-verification/SKILL.md \
|
||||
.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
|
||||
.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
||||
.agents/plugins/marketplace.json \
|
||||
docs/workflow/README.md \
|
||||
docs/workflow/agent-plugins.md
|
||||
```
|
||||
@@ -1,143 +0,0 @@
|
||||
---
|
||||
name: 'subminer-change-verification'
|
||||
description: 'Use when working in the SubMiner repo and you need to verify code changes actually work. Covers targeted regression checks during debugging and pre-handoff verification, with cheap-first lane selection for config, docs, launcher/plugin, runtime-compat, and optional real-runtime escalation.'
|
||||
---
|
||||
|
||||
# SubMiner Change Verification
|
||||
|
||||
Canonical source: this plugin path.
|
||||
|
||||
Use this skill for SubMiner code changes. Default to cheap, repo-native verification first. Escalate only when the changed behavior actually depends on Electron, mpv, overlay/window tracking, or other GUI-sensitive runtime behavior.
|
||||
|
||||
## Scripts
|
||||
|
||||
- `scripts/classify_subminer_diff.sh`
|
||||
- Emits suggested lanes and flags from explicit paths or current git changes.
|
||||
- `scripts/verify_subminer_change.sh`
|
||||
- Runs selected lanes, captures artifacts, and writes a compact summary.
|
||||
|
||||
If you need an explicit installed path, use the directory that contains this `SKILL.md`. The helper scripts live under:
|
||||
|
||||
```bash
|
||||
export SUBMINER_VERIFY_SKILL="<path-to-plugin-skill>"
|
||||
```
|
||||
|
||||
## Default workflow
|
||||
|
||||
1. Inspect the changed files or user-requested area.
|
||||
2. Run the classifier unless you already know the right lane.
|
||||
3. Run the verifier with the cheapest sufficient lane set.
|
||||
4. If the classifier emits `flag:real-runtime-candidate`, do not jump straight to runtime verification. First run the non-runtime lanes.
|
||||
5. Escalate to explicit `--lane real-runtime --allow-real-runtime` only when cheaper lanes cannot validate the behavior claim.
|
||||
6. Return:
|
||||
- verification summary
|
||||
- exact commands run
|
||||
- artifact paths
|
||||
- skipped lanes and blockers
|
||||
|
||||
## Quick start
|
||||
|
||||
Plugin-source quick start:
|
||||
|
||||
```bash
|
||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
||||
```
|
||||
|
||||
Installed-skill quick start:
|
||||
|
||||
```bash
|
||||
bash "$SUBMINER_VERIFY_SKILL/scripts/classify_subminer_diff.sh"
|
||||
```
|
||||
|
||||
Compatibility entrypoint:
|
||||
|
||||
```bash
|
||||
bash .agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh
|
||||
```
|
||||
|
||||
Classify explicit files:
|
||||
|
||||
```bash
|
||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/classify_subminer_diff.sh \
|
||||
launcher/main.ts \
|
||||
plugin/subminer/lifecycle.lua \
|
||||
src/main/runtime/mpv-client-runtime-service.ts
|
||||
```
|
||||
|
||||
Run automatic lane selection:
|
||||
|
||||
```bash
|
||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
||||
```
|
||||
|
||||
Installed-skill form:
|
||||
|
||||
```bash
|
||||
bash "$SUBMINER_VERIFY_SKILL/scripts/verify_subminer_change.sh"
|
||||
```
|
||||
|
||||
Compatibility entrypoint:
|
||||
|
||||
```bash
|
||||
bash .agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh
|
||||
```
|
||||
|
||||
Run targeted lanes:
|
||||
|
||||
```bash
|
||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
||||
--lane launcher-plugin \
|
||||
--lane runtime-compat
|
||||
```
|
||||
|
||||
Dry-run to inspect planned commands and artifact layout:
|
||||
|
||||
```bash
|
||||
bash plugins/subminer-workflow/skills/subminer-change-verification/scripts/verify_subminer_change.sh \
|
||||
--dry-run \
|
||||
launcher/main.ts \
|
||||
src/main.ts
|
||||
```
|
||||
|
||||
## Lane guidance
|
||||
|
||||
- `docs`
|
||||
- For `docs-site/`, `docs/`, and doc-only edits.
|
||||
- `config`
|
||||
- For `src/config/` and config-template-sensitive edits.
|
||||
- `stats`
|
||||
- For `stats/` dashboard UI edits.
|
||||
- `core`
|
||||
- For general source changes where `typecheck` + `test:fast` is the best cheap signal.
|
||||
- `launcher-plugin`
|
||||
- For `launcher/`, `plugin/subminer/`, plugin gating scripts, and wrapper/mpv routing work.
|
||||
- `runtime-compat`
|
||||
- For `src/main*`, runtime/composer wiring, mpv/overlay services, window trackers, and dist-sensitive behavior.
|
||||
- `real-runtime`
|
||||
- Only after deliberate escalation.
|
||||
|
||||
## Real Runtime Escalation
|
||||
|
||||
Escalate only when the change claim depends on actual runtime behavior, for example:
|
||||
|
||||
- overlay appears, hides, or tracks a real mpv window
|
||||
- mpv launch flags or pause-until-ready behavior
|
||||
- plugin/socket/auto-start handshake under a real player
|
||||
- macOS/window-tracker/focus-sensitive behavior
|
||||
|
||||
If the environment cannot support authoritative runtime verification, report the blocker explicitly. Do not silently downgrade a runtime-required claim to a pass.
|
||||
|
||||
## Artifact contract
|
||||
|
||||
The verifier writes under `.tmp/skill-verification/<timestamp>/`:
|
||||
|
||||
- `summary.json`
|
||||
- `summary.txt`
|
||||
- `classification.txt`
|
||||
- `env.txt`
|
||||
- `lanes.txt`
|
||||
- `steps.tsv`
|
||||
- `steps/*.stdout.log`
|
||||
- `steps/*.stderr.log`
|
||||
|
||||
On failure, quote the exact failing command and point at the artifact directory.
|
||||
-171
@@ -1,171 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: classify_subminer_diff.sh [path ...]
|
||||
|
||||
Emit suggested verification lanes for explicit paths or current local git changes.
|
||||
|
||||
Output format:
|
||||
lane:<name>
|
||||
flag:<name>
|
||||
reason:<text>
|
||||
EOF
|
||||
}
|
||||
|
||||
has_item() {
|
||||
local needle=$1
|
||||
shift || true
|
||||
local item
|
||||
for item in "$@"; do
|
||||
if [[ "$item" == "$needle" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
add_lane() {
|
||||
local lane=$1
|
||||
if ! has_item "$lane" "${LANES[@]:-}"; then
|
||||
LANES+=("$lane")
|
||||
fi
|
||||
}
|
||||
|
||||
add_flag() {
|
||||
local flag=$1
|
||||
if ! has_item "$flag" "${FLAGS[@]:-}"; then
|
||||
FLAGS+=("$flag")
|
||||
fi
|
||||
}
|
||||
|
||||
add_reason() {
|
||||
REASONS+=("$1")
|
||||
}
|
||||
|
||||
collect_git_paths() {
|
||||
local top_level
|
||||
if ! top_level=$(git rev-parse --show-toplevel 2>/dev/null); then
|
||||
return 0
|
||||
fi
|
||||
|
||||
(
|
||||
cd "$top_level"
|
||||
if git rev-parse --verify HEAD >/dev/null 2>&1; then
|
||||
git diff --name-only --relative HEAD --
|
||||
git diff --name-only --relative --cached --
|
||||
else
|
||||
git diff --name-only --relative --
|
||||
git diff --name-only --relative --cached --
|
||||
fi
|
||||
git ls-files --others --exclude-standard
|
||||
) | awk 'NF' | sort -u
|
||||
}
|
||||
|
||||
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
|
||||
usage
|
||||
exit 0
|
||||
fi
|
||||
|
||||
declare -a PATHS=()
|
||||
declare -a LANES=()
|
||||
declare -a FLAGS=()
|
||||
declare -a REASONS=()
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
while [[ $# -gt 0 ]]; do
|
||||
PATHS+=("$1")
|
||||
shift
|
||||
done
|
||||
else
|
||||
while IFS= read -r line; do
|
||||
[[ -n "$line" ]] && PATHS+=("$line")
|
||||
done < <(collect_git_paths)
|
||||
fi
|
||||
|
||||
if [[ ${#PATHS[@]} -eq 0 ]]; then
|
||||
add_lane "core"
|
||||
add_reason "no changed paths detected -> default to core"
|
||||
fi
|
||||
|
||||
for path in "${PATHS[@]}"; do
|
||||
specialized=0
|
||||
|
||||
case "$path" in
|
||||
docs-site/*|docs/*|changes/*|README.md)
|
||||
add_lane "docs"
|
||||
add_reason "$path -> docs"
|
||||
specialized=1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
src/config/*|src/generate-config-example.ts|src/verify-config-example.ts|docs-site/public/config.example.jsonc|config.example.jsonc)
|
||||
add_lane "config"
|
||||
add_reason "$path -> config"
|
||||
specialized=1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
stats/*)
|
||||
add_lane "stats"
|
||||
add_reason "$path -> stats"
|
||||
specialized=1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
launcher/*|plugin/subminer/*|plugin/subminer.conf|scripts/test-plugin-*|scripts/get-mpv-window-*|scripts/configure-plugin-binary-path.mjs)
|
||||
add_lane "launcher-plugin"
|
||||
add_reason "$path -> launcher-plugin"
|
||||
add_flag "real-runtime-candidate"
|
||||
add_reason "$path -> real-runtime-candidate"
|
||||
specialized=1
|
||||
;;
|
||||
esac
|
||||
|
||||
case "$path" in
|
||||
src/main.ts|src/main-entry.ts|src/preload.ts|src/main/*|src/core/services/mpv*|src/core/services/overlay*|src/renderer/*|src/window-trackers/*|scripts/prepare-build-assets.mjs)
|
||||
add_lane "runtime-compat"
|
||||
add_reason "$path -> runtime-compat"
|
||||
add_flag "real-runtime-candidate"
|
||||
add_reason "$path -> real-runtime-candidate"
|
||||
specialized=1
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ "$specialized" == "0" ]]; then
|
||||
case "$path" in
|
||||
src/*|package.json|tsconfig*.json|scripts/*|Makefile)
|
||||
add_lane "core"
|
||||
add_reason "$path -> core"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
case "$path" in
|
||||
package.json|src/main.ts|src/main-entry.ts|src/preload.ts)
|
||||
add_flag "broad-impact"
|
||||
add_reason "$path -> broad-impact"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ ${#LANES[@]} -eq 0 ]]; then
|
||||
add_lane "core"
|
||||
add_reason "no lane-specific matches -> default to core"
|
||||
fi
|
||||
|
||||
for lane in "${LANES[@]}"; do
|
||||
printf 'lane:%s\n' "$lane"
|
||||
done
|
||||
|
||||
for flag in "${FLAGS[@]}"; do
|
||||
printf 'flag:%s\n' "$flag"
|
||||
done
|
||||
|
||||
for reason in "${REASONS[@]}"; do
|
||||
printf 'reason:%s\n' "$reason"
|
||||
done
|
||||
-537
@@ -1,537 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: verify_subminer_change.sh [options] [path ...]
|
||||
|
||||
Options:
|
||||
--lane <name> Force a verification lane. Repeatable.
|
||||
--artifact-dir <dir> Use an explicit artifact directory.
|
||||
--allow-real-runtime Allow explicit real-runtime execution.
|
||||
--allow-real-gui Deprecated alias for --allow-real-runtime.
|
||||
--dry-run Record planned steps without executing commands.
|
||||
--help Show this help text.
|
||||
|
||||
If no lanes are supplied, the script classifies the provided paths. If no paths are
|
||||
provided, it classifies the current local git changes.
|
||||
|
||||
Authoritative real-runtime verification should be requested with explicit path
|
||||
arguments instead of relying on inferred local git changes.
|
||||
EOF
|
||||
}
|
||||
|
||||
timestamp() {
|
||||
date +%Y%m%d-%H%M%S
|
||||
}
|
||||
|
||||
timestamp_iso() {
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||
}
|
||||
|
||||
generate_session_id() {
|
||||
local tmp_dir
|
||||
tmp_dir=$(mktemp -d "${TMPDIR:-/tmp}/subminer-verify-$(timestamp)-XXXXXX")
|
||||
basename "$tmp_dir"
|
||||
rmdir "$tmp_dir"
|
||||
}
|
||||
|
||||
has_item() {
|
||||
local needle=$1
|
||||
shift || true
|
||||
local item
|
||||
for item in "$@"; do
|
||||
if [[ "$item" == "$needle" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
normalize_lane_name() {
|
||||
case "$1" in
|
||||
real-gui)
|
||||
printf '%s' "real-runtime"
|
||||
;;
|
||||
*)
|
||||
printf '%s' "$1"
|
||||
;;
|
||||
esac
|
||||
}
|
||||
|
||||
add_lane() {
|
||||
local lane
|
||||
lane=$(normalize_lane_name "$1")
|
||||
if ! has_item "$lane" "${SELECTED_LANES[@]:-}"; then
|
||||
SELECTED_LANES+=("$lane")
|
||||
fi
|
||||
}
|
||||
|
||||
add_blocker() {
|
||||
BLOCKERS+=("$1")
|
||||
BLOCKED=1
|
||||
}
|
||||
|
||||
validate_artifact_dir() {
|
||||
local candidate=$1
|
||||
if [[ ! "$candidate" =~ ^[A-Za-z0-9._/@:+-]+$ ]]; then
|
||||
echo "Invalid characters in --artifact-dir path" >&2
|
||||
exit 2
|
||||
fi
|
||||
}
|
||||
|
||||
append_step_record() {
|
||||
printf '%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"$1" "$2" "$3" "$4" "$5" "$6" "$7" "$8" >>"$STEPS_TSV"
|
||||
}
|
||||
|
||||
record_env() {
|
||||
{
|
||||
printf 'repo_root=%s\n' "$REPO_ROOT"
|
||||
printf 'session_id=%s\n' "$SESSION_ID"
|
||||
printf 'artifact_dir=%s\n' "$ARTIFACT_DIR"
|
||||
printf 'path_selection_mode=%s\n' "$PATH_SELECTION_MODE"
|
||||
printf 'dry_run=%s\n' "$DRY_RUN"
|
||||
printf 'allow_real_runtime=%s\n' "$ALLOW_REAL_RUNTIME"
|
||||
printf 'session_home=%s\n' "$SESSION_HOME"
|
||||
printf 'session_xdg_config_home=%s\n' "$SESSION_XDG_CONFIG_HOME"
|
||||
printf 'session_mpv_dir=%s\n' "$SESSION_MPV_DIR"
|
||||
printf 'session_logs_dir=%s\n' "$SESSION_LOGS_DIR"
|
||||
printf 'session_mpv_log=%s\n' "$SESSION_MPV_LOG"
|
||||
printf 'pwd=%s\n' "$(pwd)"
|
||||
git rev-parse --short HEAD 2>/dev/null | sed 's/^/git_head=/' || true
|
||||
git status --short 2>/dev/null || true
|
||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||
printf 'requested_paths=\n'
|
||||
printf ' %s\n' "${PATH_ARGS[@]}"
|
||||
fi
|
||||
} >"$ARTIFACT_DIR/env.txt"
|
||||
}
|
||||
|
||||
run_step() {
|
||||
local lane=$1
|
||||
local name=$2
|
||||
local command=$3
|
||||
local note=${4:-}
|
||||
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
|
||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||
local step_slug="${lane_slug}--${slug}"
|
||||
local stdout_rel="steps/${step_slug}.stdout.log"
|
||||
local stderr_rel="steps/${step_slug}.stderr.log"
|
||||
local stdout_path="$ARTIFACT_DIR/$stdout_rel"
|
||||
local stderr_path="$ARTIFACT_DIR/$stderr_rel"
|
||||
local status exit_code
|
||||
|
||||
COMMANDS_RUN+=("$command")
|
||||
printf '%s\n' "$command" >"$ARTIFACT_DIR/steps/${step_slug}.command.txt"
|
||||
|
||||
if [[ "$DRY_RUN" == "1" ]]; then
|
||||
printf '[dry-run] %s\n' "$command" >"$stdout_path"
|
||||
: >"$stderr_path"
|
||||
status="dry-run"
|
||||
exit_code=0
|
||||
else
|
||||
if HOME="$SESSION_HOME" \
|
||||
XDG_CONFIG_HOME="$SESSION_XDG_CONFIG_HOME" \
|
||||
SUBMINER_SESSION_LOGS_DIR="$SESSION_LOGS_DIR" \
|
||||
SUBMINER_SESSION_MPV_LOG="$SESSION_MPV_LOG" \
|
||||
bash -c "cd \"$REPO_ROOT\" && $command" >"$stdout_path" 2>"$stderr_path"; then
|
||||
status="passed"
|
||||
exit_code=0
|
||||
EXECUTED_REAL_STEPS=1
|
||||
else
|
||||
exit_code=$?
|
||||
status="failed"
|
||||
FAILED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
append_step_record "$lane" "$name" "$status" "$exit_code" "$command" "$stdout_rel" "$stderr_rel" "$note"
|
||||
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
||||
|
||||
if [[ "$status" == "failed" ]]; then
|
||||
FAILURE_STEP="$name"
|
||||
FAILURE_COMMAND="$command"
|
||||
FAILURE_STDOUT="$stdout_rel"
|
||||
FAILURE_STDERR="$stderr_rel"
|
||||
return "$exit_code"
|
||||
fi
|
||||
}
|
||||
|
||||
record_nonpassing_step() {
|
||||
local lane=$1
|
||||
local name=$2
|
||||
local status=$3
|
||||
local note=$4
|
||||
local lane_slug=${lane//[^a-zA-Z0-9_-]/-}
|
||||
local slug=${name//[^a-zA-Z0-9_-]/-}
|
||||
local step_slug="${lane_slug}--${slug}"
|
||||
local stdout_rel="steps/${step_slug}.stdout.log"
|
||||
local stderr_rel="steps/${step_slug}.stderr.log"
|
||||
printf '%s\n' "$note" >"$ARTIFACT_DIR/$stdout_rel"
|
||||
: >"$ARTIFACT_DIR/$stderr_rel"
|
||||
append_step_record "$lane" "$name" "$status" "0" "" "$stdout_rel" "$stderr_rel" "$note"
|
||||
printf '%s\t%s\t%s\n' "$lane" "$name" "$status"
|
||||
}
|
||||
|
||||
record_skipped_step() {
|
||||
record_nonpassing_step "$1" "$2" "skipped" "$3"
|
||||
}
|
||||
|
||||
record_blocked_step() {
|
||||
add_blocker "$3"
|
||||
record_nonpassing_step "$1" "$2" "blocked" "$3"
|
||||
}
|
||||
|
||||
record_failed_step() {
|
||||
FAILED=1
|
||||
FAILURE_STEP=$2
|
||||
FAILURE_COMMAND=${FAILURE_COMMAND:-"(validation)"}
|
||||
local lane_slug=${1//[^a-zA-Z0-9_-]/-}
|
||||
local step_slug=${2//[^a-zA-Z0-9_-]/-}
|
||||
FAILURE_STDOUT="steps/${lane_slug}--${step_slug}.stdout.log"
|
||||
FAILURE_STDERR="steps/${lane_slug}--${step_slug}.stderr.log"
|
||||
add_blocker "$3"
|
||||
record_nonpassing_step "$1" "$2" "failed" "$3"
|
||||
}
|
||||
|
||||
find_real_runtime_helper() {
|
||||
local candidate
|
||||
for candidate in \
|
||||
"$SCRIPT_DIR/run_real_runtime_smoke.sh" \
|
||||
"$SCRIPT_DIR/run_real_mpv_smoke.sh"; do
|
||||
if [[ -x "$candidate" ]]; then
|
||||
printf '%s' "$candidate"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
acquire_real_runtime_lease() {
|
||||
local lease_root="$REPO_ROOT/.tmp/skill-verification/locks"
|
||||
local lease_dir="$lease_root/exclusive-real-runtime"
|
||||
mkdir -p "$lease_root"
|
||||
if mkdir "$lease_dir" 2>/dev/null; then
|
||||
REAL_RUNTIME_LEASE_DIR="$lease_dir"
|
||||
printf '%s\n' "$SESSION_ID" >"$lease_dir/session_id"
|
||||
return 0
|
||||
fi
|
||||
|
||||
local owner=""
|
||||
if [[ -f "$lease_dir/session_id" ]]; then
|
||||
owner=$(cat "$lease_dir/session_id")
|
||||
fi
|
||||
REAL_RUNTIME_LEASE_ERROR="real-runtime lease already held${owner:+ by $owner}"
|
||||
return 1
|
||||
}
|
||||
|
||||
release_real_runtime_lease() {
|
||||
if [[ -n "$REAL_RUNTIME_LEASE_DIR" && -d "$REAL_RUNTIME_LEASE_DIR" ]]; then
|
||||
if [[ -f "$REAL_RUNTIME_LEASE_DIR/session_id" ]]; then
|
||||
local owner
|
||||
owner=$(cat "$REAL_RUNTIME_LEASE_DIR/session_id")
|
||||
if [[ "$owner" != "$SESSION_ID" ]]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
rm -rf "$REAL_RUNTIME_LEASE_DIR"
|
||||
fi
|
||||
}
|
||||
|
||||
compute_final_status() {
|
||||
if [[ "$FAILED" == "1" ]]; then
|
||||
FINAL_STATUS="failed"
|
||||
elif [[ "$BLOCKED" == "1" ]]; then
|
||||
FINAL_STATUS="blocked"
|
||||
elif [[ "$EXECUTED_REAL_STEPS" == "1" ]]; then
|
||||
FINAL_STATUS="passed"
|
||||
else
|
||||
FINAL_STATUS="skipped"
|
||||
fi
|
||||
}
|
||||
|
||||
write_summary_files() {
|
||||
local lane_lines
|
||||
lane_lines=$(printf '%s\n' "${SELECTED_LANES[@]}")
|
||||
printf '%s\n' "$lane_lines" >"$ARTIFACT_DIR/lanes.txt"
|
||||
# bash 3.2 raises "unbound variable" under set -u when expanding an empty
|
||||
# array, so guard on length (matching the idiom used elsewhere here).
|
||||
if [[ ${#BLOCKERS[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${BLOCKERS[@]}" >"$ARTIFACT_DIR/blockers.txt"
|
||||
else
|
||||
: >"$ARTIFACT_DIR/blockers.txt"
|
||||
fi
|
||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||
printf '%s\n' "${PATH_ARGS[@]}" >"$ARTIFACT_DIR/requested-paths.txt"
|
||||
else
|
||||
: >"$ARTIFACT_DIR/requested-paths.txt"
|
||||
fi
|
||||
|
||||
ARTIFACT_DIR_ENV="$ARTIFACT_DIR" \
|
||||
SESSION_ID_ENV="$SESSION_ID" \
|
||||
FINAL_STATUS_ENV="$FINAL_STATUS" \
|
||||
PATH_SELECTION_MODE_ENV="$PATH_SELECTION_MODE" \
|
||||
ALLOW_REAL_RUNTIME_ENV="$ALLOW_REAL_RUNTIME" \
|
||||
SESSION_HOME_ENV="$SESSION_HOME" \
|
||||
SESSION_XDG_CONFIG_HOME_ENV="$SESSION_XDG_CONFIG_HOME" \
|
||||
SESSION_MPV_DIR_ENV="$SESSION_MPV_DIR" \
|
||||
SESSION_LOGS_DIR_ENV="$SESSION_LOGS_DIR" \
|
||||
SESSION_MPV_LOG_ENV="$SESSION_MPV_LOG" \
|
||||
STARTED_AT_ENV="$STARTED_AT" \
|
||||
FINISHED_AT_ENV="$FINISHED_AT" \
|
||||
FAILED_ENV="$FAILED" \
|
||||
FAILURE_COMMAND_ENV="${FAILURE_COMMAND:-}" \
|
||||
FAILURE_STDOUT_ENV="${FAILURE_STDOUT:-}" \
|
||||
FAILURE_STDERR_ENV="${FAILURE_STDERR:-}" \
|
||||
bun -e '
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const lines = fs
|
||||
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "steps.tsv"), "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.slice(1)
|
||||
.map((line) => {
|
||||
const [lane, name, status, exitCode, command, stdout, stderr, note] = line.split("\t");
|
||||
return { lane, name, status, exitCode: Number(exitCode), command, stdout, stderr, note };
|
||||
});
|
||||
|
||||
const payload = {
|
||||
sessionId: process.env.SESSION_ID_ENV,
|
||||
startedAt: process.env.STARTED_AT_ENV,
|
||||
finishedAt: process.env.FINISHED_AT_ENV,
|
||||
status: process.env.FINAL_STATUS_ENV,
|
||||
pathSelectionMode: process.env.PATH_SELECTION_MODE_ENV,
|
||||
allowRealRuntime: process.env.ALLOW_REAL_RUNTIME_ENV === "1",
|
||||
sessionHome: process.env.SESSION_HOME_ENV,
|
||||
sessionXdgConfigHome: process.env.SESSION_XDG_CONFIG_HOME_ENV,
|
||||
sessionMpvDir: process.env.SESSION_MPV_DIR_ENV,
|
||||
sessionLogsDir: process.env.SESSION_LOGS_DIR_ENV,
|
||||
sessionMpvLog: process.env.SESSION_MPV_LOG_ENV,
|
||||
failed: process.env.FAILED_ENV === "1",
|
||||
failure: process.env.FAILURE_COMMAND_ENV
|
||||
? {
|
||||
command: process.env.FAILURE_COMMAND_ENV,
|
||||
stdout: process.env.FAILURE_STDOUT_ENV,
|
||||
stderr: process.env.FAILURE_STDERR_ENV,
|
||||
}
|
||||
: null,
|
||||
blockers: fs
|
||||
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "blockers.txt"), "utf8")
|
||||
.split("\n")
|
||||
.filter(Boolean),
|
||||
lanes: fs
|
||||
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "lanes.txt"), "utf8")
|
||||
.split("\n")
|
||||
.filter(Boolean),
|
||||
requestedPaths: fs
|
||||
.readFileSync(path.join(process.env.ARTIFACT_DIR_ENV, "requested-paths.txt"), "utf8")
|
||||
.split("\n")
|
||||
.filter(Boolean),
|
||||
steps: lines,
|
||||
};
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.ARTIFACT_DIR_ENV, "summary.json"),
|
||||
JSON.stringify(payload, null, 2) + "\n",
|
||||
);
|
||||
|
||||
const summaryLines = [
|
||||
`status: ${payload.status}`,
|
||||
`session: ${payload.sessionId}`,
|
||||
`artifacts: ${process.env.ARTIFACT_DIR_ENV}`,
|
||||
`lanes: ${payload.lanes.join(", ") || "(none)"}`,
|
||||
];
|
||||
|
||||
if (payload.requestedPaths.length > 0) {
|
||||
summaryLines.push("requested paths:");
|
||||
for (const entry of payload.requestedPaths) {
|
||||
summaryLines.push(`- ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (payload.failure) {
|
||||
summaryLines.push(`failure command: ${payload.failure.command}`);
|
||||
summaryLines.push(`failure stdout: ${payload.failure.stdout}`);
|
||||
summaryLines.push(`failure stderr: ${payload.failure.stderr}`);
|
||||
}
|
||||
|
||||
if (payload.blockers.length > 0) {
|
||||
summaryLines.push("blockers:");
|
||||
for (const blocker of payload.blockers) {
|
||||
summaryLines.push(`- ${blocker}`);
|
||||
}
|
||||
}
|
||||
|
||||
summaryLines.push("steps:");
|
||||
for (const step of payload.steps) {
|
||||
summaryLines.push(`- ${step.lane}/${step.name}: ${step.status}`);
|
||||
}
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(process.env.ARTIFACT_DIR_ENV, "summary.txt"),
|
||||
summaryLines.join("\n") + "\n",
|
||||
);
|
||||
'
|
||||
}
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
|
||||
SKILL_DIR=$(cd "$SCRIPT_DIR/.." && pwd)
|
||||
REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || pwd)
|
||||
|
||||
declare -a PATH_ARGS=()
|
||||
declare -a SELECTED_LANES=()
|
||||
declare -a COMMANDS_RUN=()
|
||||
declare -a BLOCKERS=()
|
||||
|
||||
ALLOW_REAL_RUNTIME=0
|
||||
DRY_RUN=0
|
||||
FAILED=0
|
||||
BLOCKED=0
|
||||
EXECUTED_REAL_STEPS=0
|
||||
FAILURE_STEP=""
|
||||
FAILURE_COMMAND=""
|
||||
FAILURE_STDOUT=""
|
||||
FAILURE_STDERR=""
|
||||
REAL_RUNTIME_LEASE_DIR=""
|
||||
REAL_RUNTIME_LEASE_ERROR=""
|
||||
PATH_SELECTION_MODE="auto"
|
||||
|
||||
trap 'release_real_runtime_lease' EXIT
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--lane)
|
||||
shift
|
||||
[[ $# -gt 0 ]] || {
|
||||
echo "Missing value for --lane" >&2
|
||||
exit 2
|
||||
}
|
||||
add_lane "$1"
|
||||
PATH_SELECTION_MODE="explicit-lanes"
|
||||
;;
|
||||
--artifact-dir)
|
||||
shift
|
||||
[[ $# -gt 0 ]] || {
|
||||
echo "Missing value for --artifact-dir" >&2
|
||||
exit 2
|
||||
}
|
||||
ARTIFACT_DIR=$1
|
||||
;;
|
||||
--allow-real-runtime|--allow-real-gui)
|
||||
ALLOW_REAL_RUNTIME=1
|
||||
;;
|
||||
--dry-run)
|
||||
DRY_RUN=1
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
PATH_ARGS+=("$1")
|
||||
;;
|
||||
esac
|
||||
shift || true
|
||||
done
|
||||
|
||||
if [[ -z "${ARTIFACT_DIR:-}" ]]; then
|
||||
SESSION_ID=$(generate_session_id)
|
||||
ARTIFACT_DIR="$REPO_ROOT/.tmp/skill-verification/$SESSION_ID"
|
||||
else
|
||||
validate_artifact_dir "$ARTIFACT_DIR"
|
||||
SESSION_ID=$(basename "$ARTIFACT_DIR")
|
||||
fi
|
||||
|
||||
mkdir -p "$ARTIFACT_DIR/steps"
|
||||
STEPS_TSV="$ARTIFACT_DIR/steps.tsv"
|
||||
printf 'lane\tstep\tstatus\texit_code\tcommand\tstdout\tstderr\tnote\n' >"$STEPS_TSV"
|
||||
|
||||
STARTED_AT=$(timestamp_iso)
|
||||
SESSION_HOME="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/home"
|
||||
SESSION_XDG_CONFIG_HOME="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/xdg-config"
|
||||
SESSION_MPV_DIR="$SESSION_XDG_CONFIG_HOME/mpv"
|
||||
SESSION_LOGS_DIR="$REPO_ROOT/.tmp/skill-verification/runtime/$SESSION_ID/logs"
|
||||
SESSION_MPV_LOG="$SESSION_LOGS_DIR/mpv.log"
|
||||
mkdir -p "$SESSION_HOME" "$SESSION_MPV_DIR" "$SESSION_LOGS_DIR"
|
||||
|
||||
CLASSIFIER_OUTPUT="$ARTIFACT_DIR/classification.txt"
|
||||
if [[ ${#SELECTED_LANES[@]} -eq 0 ]]; then
|
||||
if [[ ${#PATH_ARGS[@]} -gt 0 ]]; then
|
||||
PATH_SELECTION_MODE="explicit-paths"
|
||||
fi
|
||||
if "$SCRIPT_DIR/classify_subminer_diff.sh" "${PATH_ARGS[@]}" >"$CLASSIFIER_OUTPUT"; then
|
||||
while IFS= read -r line; do
|
||||
case "$line" in
|
||||
lane:*)
|
||||
add_lane "${line#lane:}"
|
||||
;;
|
||||
esac
|
||||
done <"$CLASSIFIER_OUTPUT"
|
||||
else
|
||||
record_failed_step "meta" "classify" "classification failed"
|
||||
fi
|
||||
else
|
||||
: >"$CLASSIFIER_OUTPUT"
|
||||
fi
|
||||
|
||||
record_env
|
||||
|
||||
if [[ ${#SELECTED_LANES[@]} -eq 0 ]]; then
|
||||
add_lane "core"
|
||||
fi
|
||||
|
||||
for lane in "${SELECTED_LANES[@]}"; do
|
||||
case "$lane" in
|
||||
docs)
|
||||
run_step "$lane" "docs-kb" "bun run test:docs:kb" || break
|
||||
;;
|
||||
config)
|
||||
run_step "$lane" "config" "bun run test:config" || break
|
||||
;;
|
||||
stats)
|
||||
run_step "$lane" "stats" "bun run test:stats" || break
|
||||
;;
|
||||
core)
|
||||
run_step "$lane" "typecheck" "bun run typecheck" || break
|
||||
run_step "$lane" "fast-tests" "bun run test:fast" || break
|
||||
;;
|
||||
launcher-plugin)
|
||||
run_step "$lane" "launcher" "bun run test:launcher" || break
|
||||
run_step "$lane" "plugin-src" "bun run test:plugin:src" || break
|
||||
;;
|
||||
runtime-compat)
|
||||
run_step "$lane" "runtime-compat" "bun run test:runtime:compat" || break
|
||||
;;
|
||||
real-runtime)
|
||||
if [[ "$ALLOW_REAL_RUNTIME" != "1" ]]; then
|
||||
record_blocked_step "$lane" "real-runtime" "real-runtime requested without --allow-real-runtime"
|
||||
continue
|
||||
fi
|
||||
if ! acquire_real_runtime_lease; then
|
||||
record_blocked_step "$lane" "real-runtime-lease" "$REAL_RUNTIME_LEASE_ERROR"
|
||||
continue
|
||||
fi
|
||||
helper=$(find_real_runtime_helper || true)
|
||||
if [[ -z "${helper:-}" ]]; then
|
||||
record_blocked_step "$lane" "real-runtime-helper" "no real-runtime helper script available in $SCRIPT_DIR"
|
||||
continue
|
||||
fi
|
||||
run_step "$lane" "real-runtime" "\"$helper\" \"$SESSION_ID\" \"$ARTIFACT_DIR\"" || break
|
||||
;;
|
||||
*)
|
||||
record_blocked_step "$lane" "unknown-lane" "unknown lane: $lane"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
release_real_runtime_lease
|
||||
FINISHED_AT=$(timestamp_iso)
|
||||
compute_final_status
|
||||
write_summary_files
|
||||
|
||||
printf 'summary:%s\n' "$ARTIFACT_DIR/summary.txt"
|
||||
cat "$ARTIFACT_DIR/summary.txt"
|
||||
@@ -1,118 +0,0 @@
|
||||
---
|
||||
name: 'subminer-scrum-master'
|
||||
description: 'Use in the SubMiner repo when a request should be turned into planned work and driven through execution. Records a plan, dispatches one or more subagents when useful, and requires verification before handoff.'
|
||||
---
|
||||
|
||||
# SubMiner Scrum Master
|
||||
|
||||
Canonical source: this plugin path.
|
||||
|
||||
Own workflow, not code by default.
|
||||
|
||||
Use this skill when the user gives a feature request, bug report, issue, refactor, or implementation ask and the agent should manage intake, planning, worker dispatch, and verification through completion.
|
||||
|
||||
## Core Rules
|
||||
|
||||
1. Keep the process light for questions, obvious mechanical edits, and tiny isolated changes.
|
||||
2. Record a plan before dispatching coding work.
|
||||
3. Split multi-part work into clear phases and ownership areas.
|
||||
4. Dispatch conservatively. Parallelize only disjoint write scopes.
|
||||
5. Require verification before handoff, typically via `subminer-change-verification`.
|
||||
6. Report dispatched workers, verification, blockers, and remaining risks.
|
||||
|
||||
## Intake Workflow
|
||||
|
||||
1. Parse the request.
|
||||
Classify it as question, mechanical edit, bugfix, feature, refactor, investigation, or follow-up.
|
||||
2. Write a short working plan in-thread when the work is nontrivial.
|
||||
3. Choose execution mode:
|
||||
- no subagents for trivial work
|
||||
- one worker for focused work
|
||||
- parallel workers only for disjoint scopes
|
||||
4. Run verification before handoff.
|
||||
|
||||
## Dispatch Rules
|
||||
|
||||
The scrum master orchestrates. Workers implement.
|
||||
|
||||
- Do not become the default implementer unless delegation is unnecessary.
|
||||
- Do not parallelize overlapping files or tightly coupled runtime work.
|
||||
- Give every worker explicit ownership of files/modules.
|
||||
- Tell every worker other agents may be active and they must not revert unrelated edits.
|
||||
- Require each worker to report:
|
||||
- changed files
|
||||
- tests run
|
||||
- blockers
|
||||
|
||||
Use worker agents for implementation and explorer agents only for bounded codebase questions.
|
||||
|
||||
## Verification
|
||||
|
||||
Every nontrivial code task gets verification.
|
||||
|
||||
Preferred flow:
|
||||
|
||||
1. use `subminer-change-verification`
|
||||
2. start with the cheapest sufficient lane
|
||||
3. escalate only when needed
|
||||
4. if worker verification is sufficient, accept it or run one final consolidating pass
|
||||
|
||||
Never hand off nontrivial work without stating what was verified and what was skipped.
|
||||
|
||||
## Pre-Handoff Policy Checks
|
||||
|
||||
Before handoff, always ask and answer both questions explicitly:
|
||||
|
||||
1. Docs update required?
|
||||
2. Changelog fragment required?
|
||||
|
||||
Rules:
|
||||
|
||||
- Do not assume silence implies "no."
|
||||
- If the answer is yes, complete the update or report the blocker.
|
||||
- Include final yes/no answers in the handoff summary even when both answers are "no."
|
||||
|
||||
## Failure / Scope Handling
|
||||
|
||||
- If a worker hits ambiguity, pause and ask the user.
|
||||
- If verification fails, either:
|
||||
- send the worker back with exact failure context, or
|
||||
- fix it directly if it is tiny and clearly in scope
|
||||
- If new scope appears, pause and re-plan before silently expanding work.
|
||||
|
||||
## Representative Flows
|
||||
|
||||
### Trivial work
|
||||
|
||||
- keep a short plan
|
||||
- implement directly or with one worker if helpful
|
||||
- run targeted verification
|
||||
- report outcome concisely
|
||||
|
||||
### Focused implementation
|
||||
|
||||
- record plan
|
||||
- dispatch one worker
|
||||
- integrate
|
||||
- verify
|
||||
- report outcome
|
||||
|
||||
### Multi-part execution
|
||||
|
||||
- define distinct deliverables/phases
|
||||
- record sequencing in the plan
|
||||
- dispatch workers only where scopes are disjoint
|
||||
- integrate
|
||||
- run consolidated verification
|
||||
- report outcome
|
||||
|
||||
## Output Expectations
|
||||
|
||||
At the end, report:
|
||||
|
||||
- which workers were dispatched and what they owned
|
||||
- what verification ran
|
||||
- explicit answers to:
|
||||
- docs update required?
|
||||
- changelog fragment required?
|
||||
- blockers, skips, and risks
|
||||
+38
-66
@@ -1,80 +1,52 @@
|
||||
> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.
|
||||
|
||||
<!-- prerelease-base-version: 0.19.0 -->
|
||||
<!-- prerelease-base-version: 0.19.4 -->
|
||||
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
- **Sync Stats & History**
|
||||
- New **Sync Stats & History** window (tray menu) and `subminer sync <host>` command keep mining stats and watch history in sync between machines over SSH, with saved devices, per-host sync direction, and live stage-by-stage progress.
|
||||
- Merges are safe to repeat: data combines without duplicates, and hosts with auto-sync enabled sync automatically in the background on a schedule, reporting results as overlay notifications.
|
||||
- Manual snapshot tools (create, merge, reveal, delete) and connection testing cover one-off transfers; Windows machines running the built-in OpenSSH Server work as sync remotes too, with no setup needed beyond SSH access. Power users can script transfers directly with `--push`/`--pull`, `--check`, `--snapshot`/`--merge`, and `--json` flags.
|
||||
|
||||
- **TsukiHime Subtitle Downloads**
|
||||
- Download Japanese and secondary-language subtitles for the current video directly from TsukiHime, mirroring the existing Jimaku flow.
|
||||
- Press `Ctrl+Shift+T` to search by tabs for the primary and secondary languages; the matching release is found automatically from the video filename and loads straight into mpv, no API key required.
|
||||
|
||||
- **Post-Playback History Menu**
|
||||
- After a watch-history episode ends or mpv closes, the fzf/rofi launcher returns to that series with options to play the previous or next episode, rewatch, pick another episode, or quit SubMiner.
|
||||
- Previous/Next continue across season directories, so you can binge a show without manually browsing folders.
|
||||
- The menu shown right after picking a series from `subminer -H` now offers the previous episode too, matching the post-playback menu.
|
||||
|
||||
- **Known-Word Highlighting by Anki Maturity**
|
||||
- Subtitle highlights for known words can now be colored by Anki card maturity (new, learning, young, mature), similar to asbplayer. Enable it with `ankiConnect.knownWords.maturityEnabled`, or toggle it live during a session.
|
||||
- The mature-interval threshold and the four tier colors are configurable, and the in-session help legend shows the active tier colors while maturity highlighting is on.
|
||||
- Tiers follow Anki's own card state: a lapsed card correctly shows as learning rather than young, and a note is treated as mature if any of its cards are mature. Stats and other known-word tools stay accurate with this new data.
|
||||
|
||||
- **Stats Library Entry Deletion**
|
||||
- Added a "Delete Entry" action in the stats Library detail view that removes an entire title in one step: every episode, session, subtitle line, rollup, cover, and vocabulary count derived from it. Previously a mistaken entry had to be cleared episode by episode and still lingered in the Library.
|
||||
- Delete progress (session, session group, episode, or full entry) now shows app-wide as a progress bar plus a status toast, staying visible across tabs and windows instead of disappearing when you switch away.
|
||||
- Deletes are dramatically faster on large libraries, and opening the Vocabulary tab no longer stalls; the first launch after upgrading migrates the stats database in place to support this.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Clipboard-Video Shortcut**
|
||||
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of being fixed.
|
||||
- **Library Merge and Move**
|
||||
- Duplicate library cards for the same show can now be combined: select cards in the library grid, choose "Merge Selected," and pick which entry to keep. Sessions, mined cards, and watch time move over, and future episodes stay matched to the merged card.
|
||||
- Episodes can be reassigned to a different library entry with a "→" button on the episode row, fixing cases where a file lands under the wrong title. Manual assignments survive later filename parsing, Jellyfin refreshes, and season repair.
|
||||
- Exact AniList matches with compatible seasons now merge automatically, while fuzzy matches show up as a dismissible "Possible duplicate" prompt instead of merging without confirmation.
|
||||
|
||||
### Fixed
|
||||
- **Anki Audio Generation on Network Drives**
|
||||
- Fixed sentence-audio generation timing out on slow network-mounted video files with many subtitle and font-attachment streams.
|
||||
- Extraction now uses bounded probing and a two-minute budget, and failures show a clear error instead of a cryptic one.
|
||||
- **Duplicate Subtitle Line Stats**
|
||||
- Fixed karaoke openings and animated signs (which record one subtitle event per animation frame) inflating word and kanji counts and skewing "Top Repeated Words." Ordinary repeated dialogue and rewatches are unaffected.
|
||||
- Already-inflated stats can be cleaned up with the new "Duplicates" button in the Vocabulary tab, or `subminer stats cleanup --duplicate-lines` (supports `--dry-run` and `--lookback-days`). Only the affected subtitle lines and vocabulary counts are touched; watch time and lines-seen totals are untouched.
|
||||
- **Overlay Modals on macOS and Windows**
|
||||
- Fixed overlay modals and the stats window opening on the wrong macOS Space, or forcing a Space switch, when mpv is fullscreen. They now open above fullscreen mpv on its current Space.
|
||||
- Modals are now prewarmed on macOS and Windows so shortcuts open them promptly, and Windows keeps the hidden modal responsive between sessions.
|
||||
- **Wayland File Drag-and-Drop**
|
||||
- Fixed dragging subtitle and video files from file managers like Thunar onto the overlay on native Wayland; dropped files are now resolved and sent to mpv.
|
||||
- **Windows Mouse Lag**
|
||||
- Fixed system-wide mouse lag while SubMiner is running on Windows, caused by a global mouse hook and blocking window lookups during click-through tracking.
|
||||
- **Mining Clip Accuracy**
|
||||
- Fixed mined audio and animated image clips sometimes capturing the wrong subtitle line when audio extraction was slow. The clip range is now locked in at the moment of lookup, so audio and image clips always match.
|
||||
- **Linux Notifications**
|
||||
- Character dictionary progress notifications on Linux now update in place instead of flickering off and back on with every status change.
|
||||
- **Stats Delete Performance**
|
||||
- Fixed stats deletes freezing the dashboard; deletes now reliably run off the main thread, with automatic retry if the delete worker crashes.
|
||||
- Deletes, library merges/moves, and AniList reassignments are now much faster because totals are updated incrementally instead of rebuilt from scratch, and no longer erase lifetime totals older than the recent session history.
|
||||
- Session deletes on large libraries dropped from minutes to milliseconds.
|
||||
|
||||
- **Word Highlighting Accuracy**
|
||||
- Fixed several incorrect word highlighting and annotation cases: inconsistent part-of-speech exclusions on merged quote-particle tokens, missing annotations for rare kanji, katakana punctuation wrongly treated as non-kana noise, and certain kanji vocabulary skipped for next-level ("N+1") highlighting.
|
||||
|
||||
- **AniList Season Resolution**
|
||||
- Season 2 and later episodes now resolve to the correct AniList entry by walking sequel relations instead of guessing from the title, so watch progress, the character dictionary, and cover art for later seasons no longer silently fall back to season 1.
|
||||
- Manual AniList overrides now stay in effect for every episode in the same season (by folder and detected season), and setting an override now fixes both the character dictionary and AniList watch progress together instead of needing separate corrections.
|
||||
|
||||
- **Startup Playback Pausing Too Early**
|
||||
- Fixed playback resuming before subtitle processing finished warming up, which could briefly show untranslated subtitles right after opening a video.
|
||||
- Most noticeable when resuming mid-episode or when a subtitle cue starts within the first couple of seconds.
|
||||
|
||||
- **Linux AppImage Crash Notification on Quit**
|
||||
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
||||
- If needed, the mount-keepalive behavior behind this fix can be disabled with `SUBMINER_NO_APPIMAGE_MOUNT_KEEPALIVE=1`.
|
||||
|
||||
- **AnkiConnect Proxy Port Conflict**
|
||||
- Fixed video playback failing to start when another process already held the configured AnkiConnect proxy port; SubMiner now shows a notification explaining how to resolve the conflict instead of crashing.
|
||||
|
||||
- **Stats & Settings Reliability**
|
||||
- Fixed session stats reporting zero known words after the known-word cache gained maturity tiers.
|
||||
- Hardened the stats server against malformed requests, stalled AniList lookups, media mismatches during word mining, and missing Yomitan connections.
|
||||
- AnkiConnect settings validation now preserves valid custom configurations while safely falling back on invalid values instead of failing.
|
||||
|
||||
- **Stats Library Cover After Relink**
|
||||
- Relinking a title to a different AniList entry now updates its cover art in the stats Library grid, not just the detail view, so unrelated titles no longer end up sharing the wrong cover.
|
||||
|
||||
- **Rofi Menu Prompt Spacing**
|
||||
- Rofi menu prompts now keep a space between the prompt label and the input field instead of crowding the search placeholder text.
|
||||
### Docs
|
||||
- **Feature Demos Page**
|
||||
- Hidden the unfinished feature demos page from the documentation sidebar; it's still reachable by direct URL.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
||||
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
|
||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
||||
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
|
||||
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
|
||||
- feat(stats): add library entry merge and episode move by @ksyasuda in #190
|
||||
- fix(stats): stop counting duplicate typeset subtitle lines by @ksyasuda in #191
|
||||
- fix(media): tolerate slow MKV audio extraction by @ksyasuda in #195
|
||||
- fix(stats): subtract lifetime totals incrementally on delete by @ksyasuda in #196
|
||||
- fix(anki): snapshot mining media clip timing by @ksyasuda in #197
|
||||
- fix(notifications): replace Linux progress updates in place by @ksyasuda in #198
|
||||
- fix(overlay): support native Wayland file drag-and-drop by @ksyasuda in #199
|
||||
- fix(overlay): keep macOS modal windows on fullscreen Spaces by @ksyasuda in #200
|
||||
- fix(overlay): prevent Windows mouse lag during click-through tracking by @ksyasuda in #201
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
## Highlights
|
||||
### Added
|
||||
- **Anki Maturity Known-Word Highlighting**
|
||||
- Subtitle words you already know can now be color-coded by their Anki card maturity (new, learning, young, mature), like asbplayer's known-word coloring.
|
||||
- Enable it with `ankiConnect.knownWords.maturityEnabled` (or toggle it mid-session); tier colors and the "mature" day threshold are configurable, and the in-session help legend shows the active colors.
|
||||
- **Cross-Machine Sync for Stats & Watch History**
|
||||
- Sync immersion stats and watch history between machines over SSH from a new Sync window (tray menu → Sync Stats & History, or `subminer sync --ui`) or the CLI (`subminer sync <host>`).
|
||||
- Save multiple devices with per-host sync direction, run one-click syncs with live progress, and take manual database snapshots for backup or transfer.
|
||||
- Windows remotes are supported over OpenSSH, and hosts can auto-sync in the background on a schedule, even during playback.
|
||||
- **History Menu After Playback**
|
||||
- After a watch-history episode ends (or mpv closes), the fzf/rofi launcher now offers to play the previous or next episode, rewatch, pick another episode, or quit, right from where you left off. Previous/Next continue across season folders.
|
||||
- **Delete Entire Library Titles from Stats**
|
||||
- The stats Library detail view now has a "Delete Entry" action that removes a whole title in one step, episodes, sessions, subtitle lines, rollups, cover art, and vocabulary counts, instead of clearing it episode by episode.
|
||||
- Delete progress (sessions, episodes, or whole titles) now shows app-wide with a progress bar and status toast visible from any tab or window.
|
||||
- **TsukiHime English Subtitle Downloads**
|
||||
- Download subtitles for the currently playing video directly from TsukiHime, with Japanese loaded as the primary track and your configured secondary language alongside it.
|
||||
|
||||
### Changed
|
||||
- **Configurable Clipboard-Video Shortcut**
|
||||
- The "append clipboard video to queue" shortcut is now configurable via `shortcuts.appendClipboardVideoToQueue` instead of fixed.
|
||||
|
||||
### Fixed
|
||||
- **AniList Season Matching**
|
||||
- Season 2+ episodes now resolve to the correct AniList entry instead of silently falling back to season 1. SubMiner follows AniList's sequel relations to find the right season, and cover art and watch progress now use the same season-aware match.
|
||||
- If a season still can't be found, SubMiner no longer force-writes progress or a cover to the season 1 entry, it skips the update and points you to a manual AniList override, which now fixes the character dictionary and watch progress together.
|
||||
- Manual overrides now stay applied consistently across every episode in a season folder, even when filenames guess differently episode to episode.
|
||||
- **Subtitle Highlighting Accuracy**
|
||||
- Fixed several known-word/annotation edge cases: part-of-speech exclusions now apply consistently to merged quote-particle tokens, annotations for rarer kanji are preserved, katakana punctuation is no longer mistaken for plain kana, and a specific noun-tagging case no longer loses its known+1 highlight.
|
||||
- **AnkiConnect Proxy Port Conflicts**
|
||||
- Fixed a crash on video startup when another process already held the configured AnkiConnect proxy port; you'll now get a notification explaining how to resolve it instead.
|
||||
- **AppImage Crash Notification on Quit**
|
||||
- Fixed a spurious "Service Crash" desktop notification appearing after closing a video when running the Linux AppImage.
|
||||
- **Startup Playback Pause Timing**
|
||||
- Fixed playback occasionally resuming a couple seconds before subtitle tokenization actually finished warming up, most noticeable when resuming mid-episode or when a subtitle appears in the first two seconds.
|
||||
- **Stats Library Cover After Relinking**
|
||||
- Fixed the stats Library grid showing a stale cover image after relinking a title to a different AniList entry.
|
||||
- **Faster Stats Deletes and Vocabulary Tab**
|
||||
- Deleting sessions, episodes, and titles from stats is now dramatically faster and no longer stalls playback while it runs; the Vocabulary tab also loads much faster.
|
||||
- The first launch after updating runs a one-time database migration (a few seconds, database grows about 20%); no action needed.
|
||||
- **Settings Validation and Stats Server Hardening**
|
||||
- Invalid AnkiConnect settings now fall back safely with a warning instead of silently breaking, and the stats server is hardened against malformed requests, stalled AniList searches, and other edge cases that could previously crash it.
|
||||
- **Rofi Prompt Spacing**
|
||||
- Fixed rofi menu prompts running into the search placeholder text with no space between them.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- feat(shortcuts): make clipboard-video-append shortcut configurable by @ksyasuda in #158
|
||||
- refactor(tokenizer): extract subtitle annotation filter into rule table by @ksyasuda in #162
|
||||
- refactor(tsukihime): swap Animetosho backend for TsukiHime API by @ksyasuda in #165
|
||||
- refactor: split anki-connect and stats-server resolvers into modules by @ksyasuda in #169
|
||||
- feat(launcher): add post-playback history menu with previous episode by @ksyasuda in #170
|
||||
- Anki maturity-based known-word highlighting by @ksyasuda in #172
|
||||
- fix(anilist): resolve later seasons via sequel relations, not title guessing by @ksyasuda in #173
|
||||
- feat(stats): add library entry deletion and app-wide delete progress by @ksyasuda in #174
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -35,6 +35,17 @@ const archiveCacheRoot = join(repoRoot, '.tmp/docs-versioned-archive-cache');
|
||||
const maxCloudflareFiles = 20_000;
|
||||
const maxCloudflareFileBytes = 25 * 1024 * 1024;
|
||||
|
||||
// Cloudflare Pages header rules for the whole deployment. Mirrors the `noindex,follow`
|
||||
// meta tag the non-root channels emit, so the duplicate trees stay out of the index
|
||||
// even for responses a crawler takes without parsing the HTML.
|
||||
const deployHeaders = `# Generated by scripts/build-versioned-docs.ts. Do not edit by hand.
|
||||
/main/*
|
||||
X-Robots-Tag: noindex, follow
|
||||
|
||||
/v/*
|
||||
X-Robots-Tag: noindex, follow
|
||||
`;
|
||||
|
||||
function run(
|
||||
command: string,
|
||||
args: string[],
|
||||
@@ -173,6 +184,7 @@ function buildDocs(options: {
|
||||
SUBMINER_DOCS_BASE: options.base,
|
||||
SUBMINER_DOCS_OUT_DIR: options.outDir,
|
||||
SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite,
|
||||
SUBMINER_DOCS_REPO_DIR: currentDocsSite,
|
||||
SUBMINER_DOCS_CHANNEL: options.channel,
|
||||
SUBMINER_DOCS_VERSION: options.version ?? '',
|
||||
SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
|
||||
@@ -378,6 +390,7 @@ function main() {
|
||||
});
|
||||
|
||||
writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
writeFileSync(join(aggregateOutDir, '_headers'), deployHeaders);
|
||||
assertCloudflarePagesLimits(aggregateOutDir);
|
||||
const prunedArchives = pruneArchiveCacheGenerations({
|
||||
cacheRoot: archiveCacheRoot,
|
||||
|
||||
@@ -19,6 +19,7 @@ const requiredDocs = [
|
||||
'docs/knowledge-base/catalog.md',
|
||||
'docs/knowledge-base/quality.md',
|
||||
'docs/workflow/README.md',
|
||||
'docs/workflow/agent-skills.md',
|
||||
'docs/workflow/planning.md',
|
||||
'docs/workflow/verification.md',
|
||||
] as const;
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import test from 'node:test';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const classifyScript = path.join(
|
||||
repoRoot,
|
||||
'.agents/skills/subminer-change-verification/scripts/classify_subminer_diff.sh',
|
||||
);
|
||||
const verifyScript = path.join(
|
||||
repoRoot,
|
||||
'.agents/skills/subminer-change-verification/scripts/verify_subminer_change.sh',
|
||||
);
|
||||
|
||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-change-verification-test-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function runBash(args: string[]) {
|
||||
return spawnSync('bash', args, {
|
||||
cwd: repoRoot,
|
||||
env: process.env,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
function parseArtifactDir(stdout: string): string {
|
||||
const match = stdout.match(/^artifacts: (.+)$/m);
|
||||
assert.ok(match, `expected artifact_dir in stdout, got:\n${stdout}`);
|
||||
return match[1] ?? '';
|
||||
}
|
||||
|
||||
function readSummaryJson(artifactDir: string) {
|
||||
return JSON.parse(fs.readFileSync(path.join(artifactDir, 'summary.json'), 'utf8')) as {
|
||||
sessionId: string;
|
||||
status: string;
|
||||
lanes: string[];
|
||||
blockers?: string[];
|
||||
artifactDir: string;
|
||||
pathSelectionMode?: string;
|
||||
steps: Array<{
|
||||
lane: string;
|
||||
name: string;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
note: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
test('classifier marks launcher and plugin paths as real-runtime candidates', () => {
|
||||
const result = runBash([classifyScript, 'launcher/mpv.ts', 'plugin/subminer/process.lua']);
|
||||
|
||||
assert.equal(result.status, 0, result.stderr || result.stdout);
|
||||
assert.match(result.stdout, /^lane:launcher-plugin$/m);
|
||||
assert.match(result.stdout, /^flag:real-runtime-candidate$/m);
|
||||
assert.doesNotMatch(result.stdout, /real-gui-candidate/);
|
||||
});
|
||||
|
||||
test('verifier blocks requested real-runtime lane when runtime execution is not allowed', () => {
|
||||
withTempDir((root) => {
|
||||
const artifactDir = path.join(root, 'artifacts');
|
||||
const result = runBash([
|
||||
verifyScript,
|
||||
'--dry-run',
|
||||
'--artifact-dir',
|
||||
artifactDir,
|
||||
'--lane',
|
||||
'real-runtime',
|
||||
'launcher/mpv.ts',
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
|
||||
const summary = readSummaryJson(artifactDir);
|
||||
assert.equal(summary.status, 'blocked');
|
||||
assert.deepEqual(summary.lanes, ['real-runtime']);
|
||||
assert.ok(summary.sessionId.length > 0);
|
||||
assert.ok(summary.blockers?.some((entry) => entry.includes('--allow-real-runtime')));
|
||||
assert.equal(fs.existsSync(path.join(artifactDir, 'summary.json')), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('verifier fails closed for unknown lanes', () => {
|
||||
withTempDir((root) => {
|
||||
const artifactDir = path.join(root, 'artifacts');
|
||||
const result = runBash([
|
||||
verifyScript,
|
||||
'--dry-run',
|
||||
'--artifact-dir',
|
||||
artifactDir,
|
||||
'--lane',
|
||||
'not-a-lane',
|
||||
'src/main.ts',
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
|
||||
const summary = readSummaryJson(artifactDir);
|
||||
assert.equal(summary.status, 'blocked');
|
||||
assert.deepEqual(summary.lanes, ['not-a-lane']);
|
||||
assert.ok(summary.blockers?.some((entry) => entry.includes('unknown lane')));
|
||||
});
|
||||
});
|
||||
|
||||
test('verifier keeps non-passing step artifacts distinct across lanes', () => {
|
||||
withTempDir((root) => {
|
||||
const artifactDir = path.join(root, 'artifacts');
|
||||
const result = runBash([
|
||||
verifyScript,
|
||||
'--dry-run',
|
||||
'--artifact-dir',
|
||||
artifactDir,
|
||||
'--lane',
|
||||
'docs',
|
||||
'--lane',
|
||||
'not-a-lane',
|
||||
'src/main.ts',
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
|
||||
const summary = readSummaryJson(artifactDir);
|
||||
const docsStep = summary.steps.find((step) => step.lane === 'docs' && step.name === 'docs-kb');
|
||||
const unknownStep = summary.steps.find(
|
||||
(step) => step.lane === 'not-a-lane' && step.name === 'unknown-lane',
|
||||
);
|
||||
|
||||
assert.ok(docsStep);
|
||||
assert.ok(unknownStep);
|
||||
assert.notEqual(docsStep?.stdout, unknownStep?.stdout);
|
||||
assert.equal(fs.existsSync(path.join(artifactDir, docsStep!.stdout)), true);
|
||||
assert.equal(fs.existsSync(path.join(artifactDir, unknownStep!.stdout)), true);
|
||||
});
|
||||
});
|
||||
|
||||
test('verifier records the real-runtime lease blocker once', () => {
|
||||
withTempDir((root) => {
|
||||
const artifactDir = path.join(root, 'artifacts');
|
||||
const leaseDir = path.join(
|
||||
repoRoot,
|
||||
'.tmp',
|
||||
'skill-verification',
|
||||
'locks',
|
||||
'exclusive-real-runtime',
|
||||
);
|
||||
fs.mkdirSync(leaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(leaseDir, 'session_id'), 'other-session');
|
||||
|
||||
try {
|
||||
const result = runBash([
|
||||
verifyScript,
|
||||
'--dry-run',
|
||||
'--artifact-dir',
|
||||
artifactDir,
|
||||
'--allow-real-runtime',
|
||||
'--lane',
|
||||
'real-runtime',
|
||||
'launcher/mpv.ts',
|
||||
]);
|
||||
|
||||
assert.equal(result.status, 0, result.stdout);
|
||||
|
||||
const summary = readSummaryJson(artifactDir);
|
||||
assert.deepEqual(summary.blockers, ['real-runtime lease already held by other-session']);
|
||||
} finally {
|
||||
fs.rmSync(leaseDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('verifier allocates unique session ids and artifact roots by default', () => {
|
||||
const first = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
||||
const second = runBash([verifyScript, '--dry-run', '--lane', 'core', 'src/main.ts']);
|
||||
|
||||
assert.equal(first.status, 0, first.stderr || first.stdout);
|
||||
assert.equal(second.status, 0, second.stderr || second.stdout);
|
||||
|
||||
const firstArtifactDir = parseArtifactDir(first.stdout);
|
||||
const secondArtifactDir = parseArtifactDir(second.stdout);
|
||||
|
||||
try {
|
||||
const firstSummary = readSummaryJson(firstArtifactDir);
|
||||
const secondSummary = readSummaryJson(secondArtifactDir);
|
||||
|
||||
assert.notEqual(firstSummary.sessionId, secondSummary.sessionId);
|
||||
assert.notEqual(firstArtifactDir, secondArtifactDir);
|
||||
assert.equal(firstSummary.pathSelectionMode, 'explicit-lanes');
|
||||
assert.equal(secondSummary.pathSelectionMode, 'explicit-lanes');
|
||||
} finally {
|
||||
fs.rmSync(firstArtifactDir, { recursive: true, force: true });
|
||||
fs.rmSync(secondArtifactDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+9
-74
@@ -22,10 +22,12 @@ import { MediaGenerator } from './media-generator';
|
||||
import path from 'path';
|
||||
import {
|
||||
AnkiConnectConfig,
|
||||
type CardKind,
|
||||
KikuDuplicateCardInfo,
|
||||
KikuFieldGroupingChoice,
|
||||
KikuMergePreviewResponse,
|
||||
NotificationOptions,
|
||||
type WordCardKind,
|
||||
} from './types/anki';
|
||||
import { AiConfig } from './types/integrations';
|
||||
import type { KnownWordMaturityTier } from './types/subtitle';
|
||||
@@ -40,6 +42,7 @@ import {
|
||||
getPreferredWordValueFromExtractedFields,
|
||||
} from './anki-field-config';
|
||||
import { createLogger } from './logger';
|
||||
import { captureLiveSubtitleMiningContext } from './core/services/mining';
|
||||
import {
|
||||
createUiFeedbackState,
|
||||
beginUpdateProgress,
|
||||
@@ -50,6 +53,7 @@ import {
|
||||
withUpdateProgress,
|
||||
UiFeedbackState,
|
||||
} from './anki-integration/ui-feedback';
|
||||
import { applyCardKindFlagFields, resolveWordCardKindSetting } from './anki-integration/card-kinds';
|
||||
import { KnownWordCacheManager } from './anki-integration/known-word-cache';
|
||||
import { PollingRunner } from './anki-integration/polling';
|
||||
import type { AnkiConnectProxyServer } from './anki-integration/anki-connect-proxy';
|
||||
@@ -83,8 +87,6 @@ interface NoteInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||
|
||||
function trimToNonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const trimmed = value.trim();
|
||||
@@ -668,6 +670,7 @@ export class AnkiIntegration {
|
||||
formatMiscInfoPattern: (fallbackFilename, startTimeSeconds) =>
|
||||
this.formatMiscInfoPattern(fallbackFilename, startTimeSeconds),
|
||||
consumeSubtitleMiningContext: () => this.consumeSubtitleMiningContext(),
|
||||
captureSubtitleMediaContext: () => captureLiveSubtitleMiningContext(this.mpvClient),
|
||||
queuePendingYoutubeMediaUpdate: (job) => this.queuePendingYoutubeMediaUpdateForNote(job),
|
||||
addConfiguredTagsToNote: (noteId) => this.addConfiguredTagsToNote(noteId),
|
||||
showNotification: (noteId, label) => this.showNotification(noteId, label),
|
||||
@@ -840,6 +843,7 @@ export class AnkiIntegration {
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
kikuDeleteDuplicateInAuto: boolean;
|
||||
wordCardKind: WordCardKind;
|
||||
} {
|
||||
const lapis = this.getLapisConfig();
|
||||
const kiku = this.getKikuConfig();
|
||||
@@ -852,6 +856,7 @@ export class AnkiIntegration {
|
||||
kikuEnabled: kiku.enabled,
|
||||
kikuFieldGrouping: (kiku.fieldGrouping || 'disabled') as 'auto' | 'manual' | 'disabled',
|
||||
kikuDeleteDuplicateInAuto: kiku.deleteDuplicateInAuto !== false,
|
||||
wordCardKind: resolveWordCardKindSetting(this.config.lapisKiku?.wordCardKind),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1315,79 +1320,9 @@ export class AnkiIntegration {
|
||||
availableFieldNames: string[],
|
||||
cardKind: CardKind,
|
||||
): void {
|
||||
const audioFlagNames = ['IsAudioCard'];
|
||||
|
||||
if (cardKind === 'word-and-sentence') {
|
||||
const wordAndSentenceFlag = this.resolveFieldName(
|
||||
availableFieldNames,
|
||||
'IsWordAndSentenceCard',
|
||||
);
|
||||
if (!wordAndSentenceFlag) {
|
||||
return;
|
||||
}
|
||||
updatedFields[wordAndSentenceFlag] = 'x';
|
||||
|
||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||
if (sentenceFlag && sentenceFlag !== wordAndSentenceFlag) {
|
||||
updatedFields[sentenceFlag] = '';
|
||||
}
|
||||
|
||||
for (const audioFlagName of audioFlagNames) {
|
||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (cardKind === 'sentence') {
|
||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||
if (sentenceFlag) {
|
||||
updatedFields[sentenceFlag] = 'x';
|
||||
}
|
||||
|
||||
for (const audioFlagName of audioFlagNames) {
|
||||
const resolved = this.resolveFieldName(availableFieldNames, audioFlagName);
|
||||
if (resolved && resolved !== sentenceFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
|
||||
const wordAndSentenceFlag = this.resolveFieldName(
|
||||
availableFieldNames,
|
||||
'IsWordAndSentenceCard',
|
||||
);
|
||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== sentenceFlag) {
|
||||
updatedFields[wordAndSentenceFlag] = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resolvedAudioFlags = Array.from(
|
||||
new Set(
|
||||
audioFlagNames
|
||||
.map((name) => this.resolveFieldName(availableFieldNames, name))
|
||||
.filter((name): name is string => Boolean(name)),
|
||||
),
|
||||
applyCardKindFlagFields(updatedFields, cardKind, (preferredName) =>
|
||||
this.resolveFieldName(availableFieldNames, preferredName),
|
||||
);
|
||||
const audioFlagName = resolvedAudioFlags[0] || null;
|
||||
if (audioFlagName) {
|
||||
updatedFields[audioFlagName] = 'x';
|
||||
}
|
||||
for (const extraAudioFlag of resolvedAudioFlags.slice(1)) {
|
||||
updatedFields[extraAudioFlag] = '';
|
||||
}
|
||||
|
||||
const sentenceFlag = this.resolveFieldName(availableFieldNames, 'IsSentenceCard');
|
||||
if (sentenceFlag && sentenceFlag !== audioFlagName) {
|
||||
updatedFields[sentenceFlag] = '';
|
||||
}
|
||||
|
||||
const wordAndSentenceFlag = this.resolveFieldName(availableFieldNames, 'IsWordAndSentenceCard');
|
||||
if (wordAndSentenceFlag && wordAndSentenceFlag !== audioFlagName) {
|
||||
updatedFields[wordAndSentenceFlag] = '';
|
||||
}
|
||||
}
|
||||
|
||||
private async showNotification(
|
||||
|
||||
@@ -4,29 +4,23 @@ import test from 'node:test';
|
||||
import { CardCreationService } from './card-creation';
|
||||
import { toMpvEdlValue } from './mpv-edl-test-utils';
|
||||
import type { MediaInput } from '../media-generator';
|
||||
import type { AnkiConnectConfig } from '../types/anki';
|
||||
import type { AnkiConnectConfig, CardKind } from '../types/anki';
|
||||
import { applyCardKindFlagFields } from './card-kinds';
|
||||
|
||||
type CardCreationDeps = ConstructorParameters<typeof CardCreationService>[0];
|
||||
|
||||
function setWordAndSentenceCardTypeFields(
|
||||
function setCardTypeFields(
|
||||
updatedFields: Record<string, string>,
|
||||
availableFieldNames: string[],
|
||||
cardKind: 'sentence' | 'audio' | 'word-and-sentence',
|
||||
cardKind: CardKind,
|
||||
): void {
|
||||
if (cardKind !== 'word-and-sentence') return;
|
||||
|
||||
const resolveFieldName = (preferredName: string): string | null =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||
if (!wordAndSentenceFlag) return;
|
||||
|
||||
updatedFields[wordAndSentenceFlag] = 'x';
|
||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||
const resolved = resolveFieldName(flagName);
|
||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
applyCardKindFlagFields(
|
||||
updatedFields,
|
||||
cardKind,
|
||||
(preferredName) =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
function createManualUpdateService(overrides: Partial<CardCreationDeps> = {}): {
|
||||
@@ -217,7 +211,7 @@ test('manual clipboard subtitle update marks Kiku word cards as word-and-sentenc
|
||||
kikuFieldGrouping: 'disabled',
|
||||
kikuDeleteDuplicateInAuto: false,
|
||||
}),
|
||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||
setCardTypeFields,
|
||||
});
|
||||
|
||||
await service.updateLastAddedFromClipboard('字幕');
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
getConfiguredWordFieldName,
|
||||
getPreferredWordValueFromExtractedFields,
|
||||
} from '../anki-field-config';
|
||||
import { AnkiConnectConfig } from '../types/anki';
|
||||
import { AnkiConnectConfig, type CardKind, type WordCardKind } from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import type { MediaInput } from '../media-input';
|
||||
import { SubtitleTimingTracker } from '../subtitle-timing-tracker';
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
resolveAudioStreamIndexForMediaGeneration,
|
||||
type MediaGenerationInputResolverOptions,
|
||||
} from './media-source';
|
||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||
import { resolveWordCardKind } from './note-field-utils';
|
||||
import type { PendingYoutubeMediaUpdate } from './pending-youtube-media';
|
||||
import { resolveMpvVolumeScale } from './mpv-volume';
|
||||
|
||||
@@ -42,8 +42,6 @@ export interface CardCreationNoteInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
|
||||
type CardKind = 'sentence' | 'audio' | 'word-and-sentence';
|
||||
|
||||
interface CardCreationClient {
|
||||
addNote(
|
||||
deck: string,
|
||||
@@ -136,6 +134,7 @@ interface CardCreationDeps {
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
kikuDeleteDuplicateInAuto: boolean;
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
getFallbackDurationSeconds: () => number;
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: CardCreationNoteInfo) => void;
|
||||
@@ -274,12 +273,9 @@ export class CardCreationService {
|
||||
if (sentenceField) {
|
||||
const processedSentence = this.deps.processSentence(sentence, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||
this.deps.setCardTypeFields(
|
||||
updatedFields,
|
||||
Object.keys(noteInfo.fields),
|
||||
'word-and-sentence',
|
||||
);
|
||||
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||
if (wordCardKind) {
|
||||
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||
}
|
||||
updatePerformed = true;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { applyCardKindFlagFields } from './card-kinds';
|
||||
|
||||
function resolverFor(availableFieldNames: string[]) {
|
||||
return (preferredName: string): string | null =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
const KIKU_FLAG_FIELDS = ['IsWordAndSentenceCard', 'IsClickCard', 'IsSentenceCard', 'IsAudioCard'];
|
||||
|
||||
test('flags the requested card kind and clears the others', () => {
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
applyCardKindFlagFields(fields, 'click', resolverFor(KIKU_FLAG_FIELDS));
|
||||
|
||||
assert.deepEqual(fields, {
|
||||
IsClickCard: 'x',
|
||||
IsWordAndSentenceCard: '',
|
||||
IsSentenceCard: '',
|
||||
IsAudioCard: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('matches flag fields case-insensitively', () => {
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
applyCardKindFlagFields(fields, 'word-and-sentence', resolverFor(['iswordandsentencecard']));
|
||||
|
||||
assert.deepEqual(fields, { iswordandsentencecard: 'x' });
|
||||
});
|
||||
|
||||
test('leaves flags untouched when the note type has no flag for a word card kind', () => {
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
applyCardKindFlagFields(
|
||||
fields,
|
||||
'click',
|
||||
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||
);
|
||||
|
||||
assert.deepEqual(fields, {});
|
||||
});
|
||||
|
||||
test('clears stale flags for explicit mine actions even without the target flag', () => {
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
applyCardKindFlagFields(
|
||||
fields,
|
||||
'audio',
|
||||
resolverFor(['IsWordAndSentenceCard', 'IsSentenceCard']),
|
||||
);
|
||||
|
||||
assert.deepEqual(fields, { IsWordAndSentenceCard: '', IsSentenceCard: '' });
|
||||
});
|
||||
|
||||
test('does not blank the target flag it just set', () => {
|
||||
const fields: Record<string, string> = {};
|
||||
|
||||
applyCardKindFlagFields(fields, 'sentence', resolverFor(['IsSentenceCard']));
|
||||
|
||||
assert.deepEqual(fields, { IsSentenceCard: 'x' });
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { CardKind, WordCardKind } from '../types/anki';
|
||||
|
||||
/**
|
||||
* Kiku/Lapis note types decide which card a note generates from mutually exclusive
|
||||
* `Is...Card` flag fields. Setting one always means clearing the others.
|
||||
*/
|
||||
export const CARD_KIND_FLAG_FIELDS: Record<CardKind, string> = {
|
||||
'word-and-sentence': 'IsWordAndSentenceCard',
|
||||
click: 'IsClickCard',
|
||||
sentence: 'IsSentenceCard',
|
||||
audio: 'IsAudioCard',
|
||||
};
|
||||
|
||||
export const WORD_CARD_KINDS: readonly WordCardKind[] = [
|
||||
'word-and-sentence',
|
||||
'click',
|
||||
'sentence',
|
||||
'audio',
|
||||
'none',
|
||||
];
|
||||
|
||||
export const DEFAULT_WORD_CARD_KIND: WordCardKind = 'word-and-sentence';
|
||||
|
||||
/**
|
||||
* Card kinds SubMiner marks on its own initiative (word cards). They are only applied
|
||||
* when the note type actually carries the matching flag field, so plain note types keep
|
||||
* their fields untouched.
|
||||
*/
|
||||
const IMPLICIT_CARD_KINDS = new Set<CardKind>(['word-and-sentence', 'click']);
|
||||
|
||||
export function isWordCardKind(value: unknown): value is WordCardKind {
|
||||
return typeof value === 'string' && WORD_CARD_KINDS.includes(value as WordCardKind);
|
||||
}
|
||||
|
||||
export function resolveWordCardKindSetting(value: unknown): WordCardKind {
|
||||
return isWordCardKind(value) ? value : DEFAULT_WORD_CARD_KIND;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flags `cardKind` on the note and clears every other card-kind flag it has, so the note
|
||||
* never ends up claiming to be two kinds of card at once.
|
||||
*/
|
||||
export function applyCardKindFlagFields(
|
||||
updatedFields: Record<string, string>,
|
||||
cardKind: CardKind,
|
||||
resolveFieldName: (preferredName: string) => string | null,
|
||||
): void {
|
||||
const targetFlag = resolveFieldName(CARD_KIND_FLAG_FIELDS[cardKind]);
|
||||
if (!targetFlag && IMPLICIT_CARD_KINDS.has(cardKind)) {
|
||||
return;
|
||||
}
|
||||
if (targetFlag) {
|
||||
updatedFields[targetFlag] = 'x';
|
||||
}
|
||||
|
||||
for (const [kind, flagName] of Object.entries(CARD_KIND_FLAG_FIELDS)) {
|
||||
if (kind === cardKind) continue;
|
||||
const resolved = resolveFieldName(flagName);
|
||||
if (resolved && resolved !== targetFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveWordCardKind, type NoteFieldValueInfo } from './note-field-utils';
|
||||
|
||||
function kikuNote(values: Record<string, string> = {}): NoteFieldValueInfo {
|
||||
const defaults: Record<string, string> = {
|
||||
Expression: '単語',
|
||||
Sentence: '',
|
||||
IsWordAndSentenceCard: '',
|
||||
IsClickCard: '',
|
||||
IsSentenceCard: '',
|
||||
IsAudioCard: '',
|
||||
};
|
||||
return {
|
||||
fields: Object.fromEntries(
|
||||
Object.entries({ ...defaults, ...values }).map(([name, value]) => [name, { value }]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
test('marks word-and-sentence cards by default when Kiku is enabled', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote(), { lapisEnabled: false, kikuEnabled: true }),
|
||||
'word-and-sentence',
|
||||
);
|
||||
});
|
||||
|
||||
test('honors the configured word card kind', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote(), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
wordCardKind: 'click',
|
||||
}),
|
||||
'click',
|
||||
);
|
||||
});
|
||||
|
||||
test('marks nothing when neither Kiku nor Lapis is enabled', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote(), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: false,
|
||||
wordCardKind: 'click',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('marks nothing when the word card kind is "none"', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote(), {
|
||||
lapisEnabled: true,
|
||||
kikuEnabled: false,
|
||||
wordCardKind: 'none',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to the default kind for an unrecognized setting', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote(), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
wordCardKind: 'bogus' as never,
|
||||
}),
|
||||
'word-and-sentence',
|
||||
);
|
||||
});
|
||||
|
||||
test('marks nothing when the note type lacks the configured flag field', () => {
|
||||
const note: NoteFieldValueInfo = {
|
||||
fields: { Expression: { value: '単語' }, Sentence: { value: '' } },
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
resolveWordCardKind(note, { lapisEnabled: false, kikuEnabled: true, wordCardKind: 'click' }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('leaves cards already mined as sentence or audio cards alone', () => {
|
||||
for (const flagField of ['IsSentenceCard', 'IsAudioCard']) {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote({ [flagField]: 'x' }), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
wordCardKind: 'click',
|
||||
}),
|
||||
null,
|
||||
flagField,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('re-affirms the configured kind when the note already carries its flag', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote({ IsSentenceCard: 'x' }), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
wordCardKind: 'sentence',
|
||||
}),
|
||||
'sentence',
|
||||
);
|
||||
});
|
||||
|
||||
test('overrides a differently flagged word card', () => {
|
||||
assert.equal(
|
||||
resolveWordCardKind(kikuNote({ IsWordAndSentenceCard: 'x' }), {
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
wordCardKind: 'click',
|
||||
}),
|
||||
'click',
|
||||
);
|
||||
});
|
||||
@@ -1,3 +1,13 @@
|
||||
import type { CardKind, WordCardKind } from '../types/anki';
|
||||
import { createLogger } from '../logger';
|
||||
import {
|
||||
CARD_KIND_FLAG_FIELDS,
|
||||
DEFAULT_WORD_CARD_KIND,
|
||||
resolveWordCardKindSetting,
|
||||
} from './card-kinds';
|
||||
|
||||
const log = createLogger('anki').child('integration.note-fields');
|
||||
|
||||
export interface NoteFieldValueInfo {
|
||||
fields: Record<string, { value: string }>;
|
||||
}
|
||||
@@ -16,22 +26,57 @@ export function hasNoteFieldValue(noteInfo: NoteFieldValueInfo, preferredName: s
|
||||
return (getNoteFieldValue(noteInfo, preferredName) ?? '').trim().length > 0;
|
||||
}
|
||||
|
||||
export function shouldMarkWordAndSentenceCard(
|
||||
noteInfo: NoteFieldValueInfo,
|
||||
sentenceCardConfig: { lapisEnabled: boolean; kikuEnabled: boolean },
|
||||
): boolean {
|
||||
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||
return false;
|
||||
}
|
||||
/** Flags set only by an explicit mine action; a note carrying one is not a word card. */
|
||||
const EXPLICIT_CARD_FLAG_FIELDS = [CARD_KIND_FLAG_FIELDS.sentence, CARD_KIND_FLAG_FIELDS.audio];
|
||||
|
||||
const wordAndSentenceValue = getNoteFieldValue(noteInfo, 'IsWordAndSentenceCard');
|
||||
if (wordAndSentenceValue === null) {
|
||||
return false;
|
||||
const warnedMissingFlagFields = new Set<CardKind>();
|
||||
|
||||
function warnMissingFlagFieldOnce(wordCardKind: CardKind, flagField: string): void {
|
||||
if (wordCardKind === DEFAULT_WORD_CARD_KIND || warnedMissingFlagFields.has(wordCardKind)) {
|
||||
// The default kind is also the fallback for plain note types, so its absence is expected.
|
||||
return;
|
||||
}
|
||||
if (wordAndSentenceValue.trim().length > 0) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
!hasNoteFieldValue(noteInfo, 'IsSentenceCard') && !hasNoteFieldValue(noteInfo, 'IsAudioCard')
|
||||
warnedMissingFlagFields.add(wordCardKind);
|
||||
log.warn(
|
||||
`Word card type "${wordCardKind}" is configured but the note has no ${flagField} field; leaving card type flags unchanged.`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Card kind to flag when SubMiner fills a word card's sentence, or null to leave the
|
||||
* card-kind flags alone. Kiku/Lapis only: other note types have no such fields.
|
||||
*/
|
||||
export function resolveWordCardKind(
|
||||
noteInfo: NoteFieldValueInfo,
|
||||
sentenceCardConfig: {
|
||||
lapisEnabled: boolean;
|
||||
kikuEnabled: boolean;
|
||||
wordCardKind?: WordCardKind;
|
||||
},
|
||||
): CardKind | null {
|
||||
if (!sentenceCardConfig.lapisEnabled && !sentenceCardConfig.kikuEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const wordCardKind = resolveWordCardKindSetting(sentenceCardConfig.wordCardKind);
|
||||
if (wordCardKind === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const flagField = CARD_KIND_FLAG_FIELDS[wordCardKind];
|
||||
const flagValue = getNoteFieldValue(noteInfo, flagField);
|
||||
if (flagValue === null) {
|
||||
// Note type has no flag field for the configured kind.
|
||||
warnMissingFlagFieldOnce(wordCardKind, flagField);
|
||||
return null;
|
||||
}
|
||||
if (flagValue.trim().length > 0) {
|
||||
return wordCardKind;
|
||||
}
|
||||
|
||||
const alreadyExplicitCard = EXPLICIT_CARD_FLAG_FIELDS.some(
|
||||
(fieldName) =>
|
||||
fieldName.toLowerCase() !== flagField.toLowerCase() && hasNoteFieldValue(noteInfo, fieldName),
|
||||
);
|
||||
return alreadyExplicitCard ? null : wordCardKind;
|
||||
}
|
||||
|
||||
@@ -6,26 +6,21 @@ import {
|
||||
type NoteUpdateWorkflowNoteInfo,
|
||||
} from './note-update-workflow';
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
import type { CardKind } from '../types/anki';
|
||||
import { applyCardKindFlagFields } from './card-kinds';
|
||||
|
||||
function setWordAndSentenceCardTypeFields(
|
||||
function setCardTypeFields(
|
||||
updatedFields: Record<string, string>,
|
||||
availableFieldNames: string[],
|
||||
cardKind: 'word-and-sentence',
|
||||
cardKind: CardKind,
|
||||
): void {
|
||||
assert.equal(cardKind, 'word-and-sentence');
|
||||
const resolveFieldName = (preferredName: string): string | null =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ?? null;
|
||||
|
||||
const wordAndSentenceFlag = resolveFieldName('IsWordAndSentenceCard');
|
||||
if (!wordAndSentenceFlag) return;
|
||||
|
||||
updatedFields[wordAndSentenceFlag] = 'x';
|
||||
for (const flagName of ['IsSentenceCard', 'IsAudioCard']) {
|
||||
const resolved = resolveFieldName(flagName);
|
||||
if (resolved && resolved !== wordAndSentenceFlag) {
|
||||
updatedFields[resolved] = '';
|
||||
}
|
||||
}
|
||||
applyCardKindFlagFields(
|
||||
updatedFields,
|
||||
cardKind,
|
||||
(preferredName) =>
|
||||
availableFieldNames.find((name) => name.toLowerCase() === preferredName.toLowerCase()) ??
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
function createWorkflowHarness() {
|
||||
@@ -79,7 +74,7 @@ function createWorkflowHarness() {
|
||||
handleFieldGroupingManual: async (_originalNoteId, _newNoteId, _newNoteInfo, _expression) =>
|
||||
false,
|
||||
processSentence: (text: string, _noteFields: Record<string, string>) => text,
|
||||
setCardTypeFields: setWordAndSentenceCardTypeFields,
|
||||
setCardTypeFields,
|
||||
resolveConfiguredFieldName: (noteInfo: NoteUpdateWorkflowNoteInfo, preferred?: string) => {
|
||||
if (!preferred) return null;
|
||||
const names = Object.keys(noteInfo.fields);
|
||||
@@ -183,6 +178,73 @@ test('NoteUpdateWorkflow marks enriched Kiku word cards as word-and-sentence car
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow marks the configured word card kind instead of word-and-sentence', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
wordCardKind: 'click',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: 'x' },
|
||||
IsClickCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
IsClickCard: 'x',
|
||||
IsWordAndSentenceCard: '',
|
||||
IsSentenceCard: '',
|
||||
IsAudioCard: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow leaves card type flags alone when the word card kind is none', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.getEffectiveSentenceCardConfig = () => ({
|
||||
sentenceField: 'Sentence',
|
||||
lapisEnabled: false,
|
||||
kikuEnabled: true,
|
||||
kikuFieldGrouping: 'manual',
|
||||
wordCardKind: 'none',
|
||||
});
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(harness.updates.length, 1);
|
||||
assert.deepEqual(harness.updates[0]?.fields, {
|
||||
Sentence: 'subtitle-text',
|
||||
});
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow does not set Kiku card flags when Lapis and Kiku are disabled', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
@@ -410,6 +472,71 @@ test('NoteUpdateWorkflow uses subtitle sidebar context for sentence media timing
|
||||
assert.equal(miscInfoStartTime, 10);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow snapshots one media range for audio and image without a mining context', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const capturedContext: SubtitleMiningContext = {
|
||||
source: 'overlay',
|
||||
text: 'subtitle-text',
|
||||
startTime: 31.5,
|
||||
endTime: 34.25,
|
||||
};
|
||||
let captureCalls = 0;
|
||||
let audioContext: SubtitleMiningContext | null = null;
|
||||
let imageContext: SubtitleMiningContext | null = null;
|
||||
let miscInfoStartTime: number | undefined;
|
||||
|
||||
harness.deps.client.notesInfo = async () =>
|
||||
[
|
||||
{
|
||||
noteId: 42,
|
||||
fields: {
|
||||
Expression: { value: 'taberu' },
|
||||
Sentence: { value: '' },
|
||||
SentenceAudio: { value: '' },
|
||||
Picture: { value: '' },
|
||||
MiscInfo: { value: '' },
|
||||
},
|
||||
},
|
||||
] satisfies NoteUpdateWorkflowNoteInfo[];
|
||||
harness.deps.getConfig = () => ({
|
||||
fields: {
|
||||
sentence: 'Sentence',
|
||||
image: 'Picture',
|
||||
miscInfo: 'MiscInfo',
|
||||
},
|
||||
media: {
|
||||
generateAudio: true,
|
||||
generateImage: true,
|
||||
imageType: 'avif',
|
||||
},
|
||||
behavior: {},
|
||||
});
|
||||
harness.deps.getResolvedSentenceAudioFieldName = () => 'SentenceAudio';
|
||||
harness.deps.captureSubtitleMediaContext = () => {
|
||||
captureCalls += 1;
|
||||
return capturedContext;
|
||||
};
|
||||
harness.deps.generateAudio = async (context?: SubtitleMiningContext) => {
|
||||
audioContext = context ?? null;
|
||||
return Buffer.from('audio');
|
||||
};
|
||||
harness.deps.generateImage = async (_leadInSeconds?: number, context?: SubtitleMiningContext) => {
|
||||
imageContext = context ?? null;
|
||||
return Buffer.from('image');
|
||||
};
|
||||
harness.deps.formatMiscInfoPattern = (_fallbackFilename, startTimeSeconds) => {
|
||||
miscInfoStartTime = startTimeSeconds;
|
||||
return `start:${startTimeSeconds}`;
|
||||
};
|
||||
|
||||
await harness.workflow.execute(42);
|
||||
|
||||
assert.equal(captureCalls, 1);
|
||||
assert.deepEqual(audioContext, capturedContext);
|
||||
assert.deepEqual(imageContext, capturedContext);
|
||||
assert.equal(miscInfoStartTime, 31.5);
|
||||
});
|
||||
|
||||
test('NoteUpdateWorkflow queues media updates when YouTube cache is pending', async () => {
|
||||
const harness = createWorkflowHarness();
|
||||
const queuedUpdates: Array<{
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { DEFAULT_ANKI_CONNECT_CONFIG } from '../config';
|
||||
import { getPreferredWordValueFromExtractedFields } from '../anki-field-config';
|
||||
import type { SubtitleMiningContext } from '../types/subtitle';
|
||||
import { shouldMarkWordAndSentenceCard } from './note-field-utils';
|
||||
import type { CardKind, WordCardKind } from '../types/anki';
|
||||
import { resolveWordCardKind } from './note-field-utils';
|
||||
|
||||
export interface NoteUpdateWorkflowNoteInfo {
|
||||
noteId: number;
|
||||
@@ -39,6 +40,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
lapisEnabled: boolean;
|
||||
kikuEnabled: boolean;
|
||||
kikuFieldGrouping: 'auto' | 'manual' | 'disabled';
|
||||
wordCardKind?: WordCardKind;
|
||||
};
|
||||
appendKnownWordsFromNoteInfo: (noteInfo: NoteUpdateWorkflowNoteInfo) => void;
|
||||
extractFields: (fields: Record<string, { value: string }>) => Record<string, string>;
|
||||
@@ -67,7 +69,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
setCardTypeFields: (
|
||||
updatedFields: Record<string, string>,
|
||||
availableFieldNames: string[],
|
||||
cardKind: 'word-and-sentence',
|
||||
cardKind: CardKind,
|
||||
) => void;
|
||||
resolveConfiguredFieldName: (
|
||||
noteInfo: NoteUpdateWorkflowNoteInfo,
|
||||
@@ -85,6 +87,7 @@ export interface NoteUpdateWorkflowDeps {
|
||||
) => Promise<Buffer | null>;
|
||||
formatMiscInfoPattern: (fallbackFilename: string, startTimeSeconds?: number) => string;
|
||||
consumeSubtitleMiningContext?: () => SubtitleMiningContext | null;
|
||||
captureSubtitleMediaContext?: () => SubtitleMiningContext | null;
|
||||
queuePendingYoutubeMediaUpdate?: (job: {
|
||||
noteId: number;
|
||||
noteInfo: NoteUpdateWorkflowNoteInfo;
|
||||
@@ -201,18 +204,20 @@ export class NoteUpdateWorkflow {
|
||||
sentenceField,
|
||||
config.fields?.sentence,
|
||||
);
|
||||
// Audio and image generation run sequentially and audio extraction can take tens of
|
||||
// seconds, so resolve the clip range exactly once up front; reading live mpv sub
|
||||
// timings per generator clips whichever line is on screen when each one starts.
|
||||
const mediaTimingContext =
|
||||
subtitleMiningContext ?? this.deps.captureSubtitleMediaContext?.() ?? null;
|
||||
const noteLabel = hasExpressionText ? expressionText : noteId;
|
||||
|
||||
const currentSubtitleText = subtitleMiningContext?.text ?? this.deps.getCurrentSubtitleText();
|
||||
if (sentenceField && currentSubtitleText) {
|
||||
const processedSentence = this.deps.processSentence(currentSubtitleText, fields);
|
||||
updatedFields[sentenceField] = processedSentence;
|
||||
if (shouldMarkWordAndSentenceCard(noteInfo, sentenceCardConfig)) {
|
||||
this.deps.setCardTypeFields(
|
||||
updatedFields,
|
||||
Object.keys(noteInfo.fields),
|
||||
'word-and-sentence',
|
||||
);
|
||||
const wordCardKind = resolveWordCardKind(noteInfo, sentenceCardConfig);
|
||||
if (wordCardKind) {
|
||||
this.deps.setCardTypeFields(updatedFields, Object.keys(noteInfo.fields), wordCardKind);
|
||||
}
|
||||
updatePerformed = true;
|
||||
}
|
||||
@@ -241,7 +246,7 @@ export class NoteUpdateWorkflow {
|
||||
? await this.deps.queuePendingYoutubeMediaUpdate({
|
||||
noteId,
|
||||
noteInfo,
|
||||
context: subtitleMiningContext ?? undefined,
|
||||
context: mediaTimingContext ?? undefined,
|
||||
label: noteLabel,
|
||||
})
|
||||
: false;
|
||||
@@ -249,7 +254,7 @@ export class NoteUpdateWorkflow {
|
||||
if (!mediaCacheQueued && generateAudio) {
|
||||
try {
|
||||
const audioFilename = this.deps.generateAudioFilename();
|
||||
const audioBuffer = await this.deps.generateAudio(subtitleMiningContext ?? undefined);
|
||||
const audioBuffer = await this.deps.generateAudio(mediaTimingContext ?? undefined);
|
||||
|
||||
if (audioBuffer) {
|
||||
await this.deps.client.storeMediaFile(audioFilename, audioBuffer);
|
||||
@@ -277,7 +282,7 @@ export class NoteUpdateWorkflow {
|
||||
const imageFilename = this.deps.generateImageFilename();
|
||||
const imageBuffer = await this.deps.generateImage(
|
||||
animatedLeadInSeconds,
|
||||
subtitleMiningContext ?? undefined,
|
||||
mediaTimingContext ?? undefined,
|
||||
);
|
||||
|
||||
if (imageBuffer) {
|
||||
@@ -309,7 +314,7 @@ export class NoteUpdateWorkflow {
|
||||
if (!mediaCacheQueued && config.fields?.miscInfo) {
|
||||
const miscInfo = this.deps.formatMiscInfoPattern(
|
||||
miscInfoFilename || '',
|
||||
subtitleMiningContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
|
||||
mediaTimingContext?.startTime ?? this.deps.getCurrentSubtitleStart(),
|
||||
);
|
||||
const miscInfoField = this.deps.resolveConfiguredFieldName(
|
||||
noteInfo,
|
||||
|
||||
@@ -116,6 +116,10 @@ export function normalizeAnkiIntegrationConfig(config: AnkiConnectConfig): AnkiC
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.isKiku,
|
||||
...(config.isKiku ?? {}),
|
||||
},
|
||||
lapisKiku: {
|
||||
...DEFAULT_ANKI_CONNECT_CONFIG.lapisKiku,
|
||||
...(config.lapisKiku ?? {}),
|
||||
},
|
||||
} as AnkiConnectConfig;
|
||||
}
|
||||
|
||||
@@ -205,6 +209,10 @@ export class AnkiIntegrationRuntime {
|
||||
patch.isKiku !== undefined
|
||||
? { ...this.config.isKiku, ...patch.isKiku }
|
||||
: this.config.isKiku,
|
||||
lapisKiku:
|
||||
patch.lapisKiku !== undefined
|
||||
? { ...this.config.lapisKiku, ...patch.lapisKiku }
|
||||
: this.config.lapisKiku,
|
||||
};
|
||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||
this.deps.onConfigChanged?.(this.config);
|
||||
|
||||
@@ -399,6 +399,30 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(statsLifetimeRebuild.statsCleanupLifetime, true);
|
||||
assert.equal(statsLifetimeRebuild.statsCleanupVocab, false);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
parseArgs([
|
||||
'--stats',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-duplicate-lines',
|
||||
'--stats-cleanup-lookback-days',
|
||||
'0.5',
|
||||
]),
|
||||
/at least one day/,
|
||||
);
|
||||
assert.equal(
|
||||
parseArgs([
|
||||
'--stats',
|
||||
'--stats-cleanup',
|
||||
'--stats-cleanup-duplicate-lines',
|
||||
'--stats-cleanup-lookback-days',
|
||||
'1.5',
|
||||
]).statsCleanupLookbackDays,
|
||||
1,
|
||||
);
|
||||
assert.equal(parseArgs(['--stats-cleanup-lookback-days=30']).statsCleanupLookbackDays, 30);
|
||||
assert.throws(() => parseArgs(['--stats-cleanup-lookback-days=30=oops']), /at least one day/);
|
||||
|
||||
const jellyfinLibraries = parseArgs(['--jellyfin-libraries']);
|
||||
assert.equal(jellyfinLibraries.jellyfinLibraries, true);
|
||||
assert.equal(hasExplicitCommand(jellyfinLibraries), true);
|
||||
|
||||
+22
-1
@@ -64,6 +64,9 @@ export interface CliArgs {
|
||||
statsCleanup?: boolean;
|
||||
statsCleanupVocab?: boolean;
|
||||
statsCleanupLifetime?: boolean;
|
||||
statsCleanupDuplicateLines?: boolean;
|
||||
statsCleanupDryRun?: boolean;
|
||||
statsCleanupLookbackDays?: number;
|
||||
statsResponsePath?: string;
|
||||
jellyfin: boolean;
|
||||
jellyfinLogin: boolean;
|
||||
@@ -109,6 +112,14 @@ export interface CliArgs {
|
||||
|
||||
export type CliCommandSource = 'initial' | 'second-instance';
|
||||
|
||||
function parseStatsCleanupLookbackDays(value: string | undefined): number {
|
||||
const days = Number(value);
|
||||
if (!Number.isFinite(days) || days < 1) {
|
||||
throw new Error('Stats --lookback-days must be at least one day.');
|
||||
}
|
||||
return Math.floor(days);
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): CliArgs {
|
||||
const args: CliArgs = {
|
||||
background: false,
|
||||
@@ -167,6 +178,8 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsCleanupDuplicateLines: false,
|
||||
statsCleanupDryRun: false,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
@@ -368,7 +381,15 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
} else if (arg === '--stats-cleanup') args.statsCleanup = true;
|
||||
else if (arg === '--stats-cleanup-vocab') args.statsCleanupVocab = true;
|
||||
else if (arg === '--stats-cleanup-lifetime') args.statsCleanupLifetime = true;
|
||||
else if (arg.startsWith('--stats-response-path=')) {
|
||||
else if (arg === '--stats-cleanup-duplicate-lines') args.statsCleanupDuplicateLines = true;
|
||||
else if (arg === '--stats-cleanup-dry-run') args.statsCleanupDryRun = true;
|
||||
else if (arg.startsWith('--stats-cleanup-lookback-days=')) {
|
||||
args.statsCleanupLookbackDays = parseStatsCleanupLookbackDays(
|
||||
arg.slice('--stats-cleanup-lookback-days='.length),
|
||||
);
|
||||
} else if (arg === '--stats-cleanup-lookback-days') {
|
||||
args.statsCleanupLookbackDays = parseStatsCleanupLookbackDays(readValue(argv[i + 1]));
|
||||
} else if (arg.startsWith('--stats-response-path=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
if (value) args.statsResponsePath = value;
|
||||
} else if (arg === '--stats-response-path') {
|
||||
|
||||
@@ -2738,6 +2738,43 @@ test('ignores deprecated isLapis sentence-card field overrides', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts a Kiku/Lapis word card kind and warns on an unknown one', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"isKiku": { "enabled": true },
|
||||
"lapisKiku": { "wordCardKind": "click" }
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
assert.equal(service.getConfig().ankiConnect.lapisKiku.wordCardKind, 'click');
|
||||
assert.equal(service.getWarnings().length, 0);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"lapisKiku": { "wordCardKind": "isClickCard" }
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(invalidService.getConfig().ankiConnect.lapisKiku.wordCardKind, 'word-and-sentence');
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'ankiConnect.lapisKiku.wordCardKind'),
|
||||
);
|
||||
});
|
||||
|
||||
test('accepts valid ankiConnect knownWords deck object', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -91,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
fieldGrouping: 'disabled',
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
lapisKiku: {
|
||||
wordCardKind: 'word-and-sentence',
|
||||
},
|
||||
},
|
||||
jimaku: {
|
||||
apiBaseUrl: 'https://jimaku.cc',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import { WORD_CARD_KINDS } from '../../anki-integration/card-kinds';
|
||||
import { MPV_LAUNCH_MODE_VALUES } from '../../shared/mpv-launch-mode';
|
||||
import {
|
||||
NOTIFICATION_TYPE_VALUES,
|
||||
@@ -374,6 +375,21 @@ export function buildIntegrationConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.ankiConnect.isLapis.sentenceCardModel,
|
||||
description: 'Note type name used by Lapis sentence cards.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.lapisKiku.wordCardKind',
|
||||
kind: 'enum',
|
||||
enumValues: WORD_CARD_KINDS,
|
||||
enumLabels: {
|
||||
'word-and-sentence': 'Word and sentence card (IsWordAndSentenceCard)',
|
||||
click: 'Click card (IsClickCard)',
|
||||
sentence: 'Sentence card (IsSentenceCard)',
|
||||
audio: 'Audio card (IsAudioCard)',
|
||||
none: 'Leave card type flags untouched',
|
||||
},
|
||||
defaultValue: defaultConfig.ankiConnect.lapisKiku.wordCardKind,
|
||||
description:
|
||||
'Card-type flag SubMiner marks on Kiku/Lapis word cards. Only one flag is set at a time; the others are cleared. Requires isKiku.enabled or isLapis.enabled.',
|
||||
},
|
||||
{
|
||||
path: 'ankiConnect.metadata.pattern',
|
||||
kind: 'string',
|
||||
|
||||
@@ -135,7 +135,7 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
title: 'AnkiConnect Integration',
|
||||
description: ['Automatic Anki updates and media generation options.'],
|
||||
notes: [
|
||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, and isKiku.fieldGrouping update live while SubMiner is running.',
|
||||
'Hot-reload: ankiConnect.ai.enabled, media.normalizeAudio/mirrorMpvVolume, knownWords, nPlusOne, fields.word/audio/image/sentence/miscInfo, behavior.autoUpdateNewCards, isLapis.sentenceCardModel, isKiku.fieldGrouping, and lapisKiku.wordCardKind update live while SubMiner is running.',
|
||||
'Shared AI provider transport settings are read from top-level ai and typically require restart.',
|
||||
'Most other AnkiConnect settings still require restart.',
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ResolveContext } from './context';
|
||||
import { initializeAnkiConnectResolution } from './anki-connect/initialize';
|
||||
import { applyAnkiKikuResolution } from './anki-connect/kiku';
|
||||
import { applyAnkiLapisKikuResolution } from './anki-connect/lapis-kiku';
|
||||
import { applyAnkiKnownWordsResolution } from './anki-connect/known-words';
|
||||
import { applyAnkiLegacyResolution } from './anki-connect/legacy';
|
||||
import { applyAnkiModernResolution } from './anki-connect/modern';
|
||||
@@ -22,4 +23,5 @@ export function applyAnkiConnectResolution(context: ResolveContext): void {
|
||||
applyAnkiLegacyResolution(context, ankiConnect, behavior, fields, media, metadata);
|
||||
applyAnkiKnownWordsResolution(context, ankiConnect, behavior);
|
||||
applyAnkiKikuResolution(context);
|
||||
applyAnkiLapisKikuResolution(context, ankiConnect);
|
||||
}
|
||||
|
||||
@@ -77,5 +77,8 @@ export function initializeAnkiConnectResolution(
|
||||
? (ankiConnect.isKiku as (typeof context.resolved)['ankiConnect']['isKiku'])
|
||||
: {}),
|
||||
},
|
||||
lapisKiku: {
|
||||
...context.resolved.ankiConnect.lapisKiku,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { isWordCardKind, WORD_CARD_KINDS } from '../../../anki-integration/card-kinds';
|
||||
import { DEFAULT_CONFIG } from '../../definitions';
|
||||
import type { ResolveContext } from '../context';
|
||||
import { isObject } from '../shared';
|
||||
|
||||
export function applyAnkiLapisKikuResolution(
|
||||
context: ResolveContext,
|
||||
ankiConnect: Record<string, unknown>,
|
||||
): void {
|
||||
if (!isObject(ankiConnect.lapisKiku)) {
|
||||
if (ankiConnect.lapisKiku !== undefined) {
|
||||
context.warn(
|
||||
'ankiConnect.lapisKiku',
|
||||
ankiConnect.lapisKiku,
|
||||
context.resolved.ankiConnect.lapisKiku,
|
||||
'Expected object.',
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const wordCardKind = ankiConnect.lapisKiku.wordCardKind;
|
||||
if (wordCardKind === undefined) {
|
||||
return;
|
||||
}
|
||||
if (isWordCardKind(wordCardKind)) {
|
||||
context.resolved.ankiConnect.lapisKiku.wordCardKind = wordCardKind;
|
||||
return;
|
||||
}
|
||||
|
||||
context.warn(
|
||||
'ankiConnect.lapisKiku.wordCardKind',
|
||||
wordCardKind,
|
||||
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind,
|
||||
`Expected one of ${WORD_CARD_KINDS.join(', ')}.`,
|
||||
);
|
||||
context.resolved.ankiConnect.lapisKiku.wordCardKind =
|
||||
DEFAULT_CONFIG.ankiConnect.lapisKiku.wordCardKind;
|
||||
}
|
||||
@@ -221,6 +221,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
||||
'ankiConnect.nPlusOne.enabled': 'Enabled',
|
||||
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||
'ankiConnect.lapisKiku.wordCardKind': 'Word Card Type',
|
||||
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||
@@ -255,6 +256,8 @@ const DESCRIPTION_OVERRIDES: Record<string, string> = {
|
||||
'Enable Lapis-specific mining behavior and sentence-card model targeting. When Kiku is enabled, Lapis features still work and Kiku-specific features are added on top.',
|
||||
'ankiConnect.isLapis.sentenceCardModel':
|
||||
'Anki note type used for Lapis sentence cards. Select from note types reported by AnkiConnect.',
|
||||
'ankiConnect.lapisKiku.wordCardKind':
|
||||
'Card-type flag marked on mined word cards. Only one flag is set at a time; the others are cleared. Requires Kiku or Lapis to be enabled.',
|
||||
'subtitleStyle.css':
|
||||
'CSS declarations applied to primary subtitles. Includes color, background-color, and all font properties.',
|
||||
'subtitleStyle.secondary.css':
|
||||
@@ -401,7 +404,11 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
||||
if (path.startsWith('ankiConnect.media.')) {
|
||||
return { category: 'mining-anki', section: 'Media Capture' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.isKiku.') || path.startsWith('ankiConnect.isLapis.')) {
|
||||
if (
|
||||
path.startsWith('ankiConnect.isKiku.') ||
|
||||
path.startsWith('ankiConnect.isLapis.') ||
|
||||
path.startsWith('ankiConnect.lapisKiku.')
|
||||
) {
|
||||
return { category: 'mining-anki', section: 'Kiku/Lapis Features' };
|
||||
}
|
||||
if (path.startsWith('ankiConnect.ai.')) {
|
||||
@@ -702,6 +709,7 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
||||
path === 'ankiConnect.fields.miscInfo' ||
|
||||
path === 'ankiConnect.isLapis.sentenceCardModel' ||
|
||||
path === 'ankiConnect.isKiku.fieldGrouping' ||
|
||||
path === 'ankiConnect.lapisKiku.wordCardKind' ||
|
||||
path === 'mpv.aniskipEnabled' ||
|
||||
path === 'mpv.aniskipButtonKey' ||
|
||||
path === 'stats.toggleKey' ||
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { DatabaseSync } from '../immersion-tracker/sqlite';
|
||||
|
||||
type ImmersionTrackerService = import('../immersion-tracker-service').ImmersionTrackerService;
|
||||
type ImmersionTrackerServiceCtor =
|
||||
typeof import('../immersion-tracker-service').ImmersionTrackerService;
|
||||
|
||||
let trackerCtor: ImmersionTrackerServiceCtor | null = null;
|
||||
|
||||
async function loadTrackerCtor(): Promise<ImmersionTrackerServiceCtor> {
|
||||
if (trackerCtor) return trackerCtor;
|
||||
const mod = await import('../immersion-tracker-service');
|
||||
trackerCtor = mod.ImmersionTrackerService;
|
||||
return trackerCtor;
|
||||
}
|
||||
|
||||
function makeDbPath(): string {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-write-queue-test-'));
|
||||
return path.join(dir, 'immersion.sqlite');
|
||||
}
|
||||
|
||||
function cleanupDbPath(dbPath: string): void {
|
||||
const dir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dir)) return;
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
interface TrackerInternals {
|
||||
db: DatabaseSync;
|
||||
queue: unknown[];
|
||||
recordWrite: (write: Record<string, unknown>) => void;
|
||||
deleteSession: (sessionId: number) => Promise<void>;
|
||||
mergeAnime: (targetAnimeId: number, sourceAnimeIds: number[]) => Promise<unknown>;
|
||||
moveVideoToAnime: (videoId: number, targetAnimeId: number) => Promise<unknown>;
|
||||
rebuildLifetimeSummaries: () => Promise<unknown>;
|
||||
reassignAnimeAnilist: (animeId: number, info: { anilistId: number }) => Promise<void>;
|
||||
flushNow: () => void;
|
||||
writeLock: { locked: boolean };
|
||||
}
|
||||
|
||||
test('delete maintenance fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
let deleteRunnerCalls = 0;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor(
|
||||
{ dbPath, policy: { batchSize: 2 } },
|
||||
{
|
||||
runDeleteMaintenanceTask: async () => {
|
||||
deleteRunnerCalls += 1;
|
||||
},
|
||||
},
|
||||
);
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
let flushCalls = 0;
|
||||
internals.flushNow = () => {
|
||||
flushCalls += 1;
|
||||
if (flushCalls > 1) throw new Error('bounded no-progress sentinel');
|
||||
};
|
||||
|
||||
await assert.rejects(internals.deleteSession(1), /queue did not drain/i);
|
||||
|
||||
assert.equal(flushCalls, 1);
|
||||
assert.equal(deleteRunnerCalls, 0);
|
||||
assert.equal(internals.writeLock.locked, false);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('reassignAnimeAnilist fails closed before resolving a conflict when writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
internals.db.prepare('UPDATE imm_anime SET anilist_id = 123 WHERE anime_id = 2').run();
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(
|
||||
internals.reassignAnimeAnilist(1, { anilistId: 123 }),
|
||||
/queue did not drain/i,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
internals.db
|
||||
.prepare(
|
||||
'SELECT anime_id AS animeId, anilist_id AS anilistId FROM imm_anime ORDER BY anime_id',
|
||||
)
|
||||
.all(),
|
||||
[
|
||||
{ animeId: 1, anilistId: null },
|
||||
{ animeId: 2, anilistId: 123 },
|
||||
],
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('mergeAnime fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.mergeAnime(1, [2]), /queue did not drain/i);
|
||||
|
||||
assert.deepEqual(
|
||||
internals.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_anime ORDER BY anime_id')
|
||||
.all()
|
||||
.map((row) => (row as { animeId: number }).animeId),
|
||||
[1, 2],
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('moveVideoToAnime fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.moveVideoToAnime(2, 1), /queue did not drain/i);
|
||||
assert.equal(
|
||||
(
|
||||
internals.db
|
||||
.prepare('SELECT anime_id AS animeId FROM imm_videos WHERE video_id = 2')
|
||||
.get() as {
|
||||
animeId: number;
|
||||
}
|
||||
).animeId,
|
||||
2,
|
||||
);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('rebuildLifetimeSummaries fails closed when queued writes cannot drain', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 1);
|
||||
internals.flushNow = () => {};
|
||||
|
||||
await assert.rejects(internals.rebuildLifetimeSummaries(), /queue did not drain/i);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
function seedTwoEntries(db: DatabaseSync): void {
|
||||
db.exec(`
|
||||
INSERT INTO imm_anime (anime_id, normalized_title_key, canonical_title, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'show', 'Show', 1000, 1000), (2, 'show season 1', 'Show Season 1', 1000, 1000);
|
||||
INSERT INTO imm_videos (video_id, video_key, canonical_title, anime_id, source_type, watched, duration_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'local:/tmp/a.mkv', 'A', 1, 1, 0, 1440000, 1000, 1000),
|
||||
(2, 'local:/tmp/b.mkv', 'B', 2, 1, 0, 1440000, 1000, 1000);
|
||||
INSERT INTO imm_sessions (session_id, session_uuid, video_id, started_at_ms, ended_at_ms, status, active_watched_ms, CREATED_DATE, LAST_UPDATE_DATE)
|
||||
VALUES (1, 'drain-session', 2, '1000', '2000', 2, 1000, 1000, 2000);
|
||||
`);
|
||||
}
|
||||
|
||||
function queueSubtitleLines(tracker: TrackerInternals, count: number): void {
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
tracker.recordWrite({
|
||||
kind: 'subtitleLine',
|
||||
sessionId: 1,
|
||||
videoId: 2,
|
||||
lineIndex: index,
|
||||
segmentStartMs: index * 1000,
|
||||
segmentEndMs: index * 1000 + 900,
|
||||
text: `line ${index}`,
|
||||
wordOccurrences: [],
|
||||
kanjiOccurrences: [],
|
||||
firstSeen: 1000,
|
||||
lastSeen: 2000,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queued last so it sits past the first batch. Lifetime `total_lines_seen`
|
||||
* reads this counter, not a COUNT over imm_subtitle_lines, so the rebuilt
|
||||
* summary only reflects the session once the queue is drained all the way.
|
||||
*/
|
||||
function queueTelemetry(tracker: TrackerInternals, linesSeen: number): void {
|
||||
tracker.recordWrite({
|
||||
kind: 'telemetry',
|
||||
sessionId: 1,
|
||||
sampleMs: 3000,
|
||||
lastMediaMs: 3000,
|
||||
totalWatchedMs: 4000,
|
||||
activeWatchedMs: 3500,
|
||||
linesSeen,
|
||||
tokensSeen: linesSeen * 5,
|
||||
cardsMined: 2,
|
||||
lookupCount: 0,
|
||||
lookupHits: 0,
|
||||
yomitanLookupCount: 0,
|
||||
pauseCount: 0,
|
||||
pauseMs: 0,
|
||||
seekForwardCount: 0,
|
||||
seekBackwardCount: 0,
|
||||
mediaBufferEvents: 0,
|
||||
});
|
||||
}
|
||||
|
||||
/** The queued telemetry sample only exists in the database once the queue drained fully. */
|
||||
function latestTelemetryLinesSeen(db: DatabaseSync, sessionId: number): number | null {
|
||||
const row = db
|
||||
.prepare(
|
||||
`SELECT lines_seen AS linesSeen
|
||||
FROM imm_session_telemetry
|
||||
WHERE session_id = ?
|
||||
ORDER BY sample_ms DESC, telemetry_id DESC
|
||||
LIMIT 1`,
|
||||
)
|
||||
.get(sessionId) as { linesSeen: number } | undefined;
|
||||
return row ? Number(row.linesSeen) : null;
|
||||
}
|
||||
|
||||
function countLinesForAnime(db: DatabaseSync, animeId: number): number {
|
||||
const row = db
|
||||
.prepare('SELECT COUNT(*) AS total FROM imm_subtitle_lines WHERE anime_id = ?')
|
||||
.get(animeId) as { total: number };
|
||||
return Number(row.total);
|
||||
}
|
||||
|
||||
/**
|
||||
* Both entry points must see a settled database before changing episode
|
||||
* ownership. A single flushNow() only writes one batch off the front of the
|
||||
* queue, so anything past `batchSize` would still be unwritten when the merge
|
||||
* repoints rows.
|
||||
*/
|
||||
test('mergeAnime drains a queue larger than one batch before repointing rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 8);
|
||||
queueTelemetry(internals, 8);
|
||||
assert.ok(internals.queue.length > 2, 'expected more queued writes than one batch');
|
||||
|
||||
await internals.mergeAnime(1, [2]);
|
||||
|
||||
assert.equal(internals.queue.length, 0);
|
||||
// Every queued line landed, attributed to the surviving entry.
|
||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
|
||||
test('moveVideoToAnime drains a queue larger than one batch before repointing rows', async () => {
|
||||
const dbPath = makeDbPath();
|
||||
let tracker: ImmersionTrackerService | null = null;
|
||||
|
||||
try {
|
||||
const Ctor = await loadTrackerCtor();
|
||||
tracker = new Ctor({ dbPath, policy: { batchSize: 2 } });
|
||||
const internals = tracker as unknown as TrackerInternals;
|
||||
|
||||
seedTwoEntries(internals.db);
|
||||
queueSubtitleLines(internals, 8);
|
||||
queueTelemetry(internals, 8);
|
||||
|
||||
await internals.moveVideoToAnime(2, 1);
|
||||
|
||||
assert.equal(internals.queue.length, 0);
|
||||
assert.equal(countLinesForAnime(internals.db, 1), 8);
|
||||
assert.equal(latestTelemetryLinesSeen(internals.db, 1), 8);
|
||||
} finally {
|
||||
tracker?.destroy();
|
||||
cleanupDbPath(dbPath);
|
||||
}
|
||||
});
|
||||
@@ -1032,6 +1032,180 @@ describe('stats server API routes', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('POST /api/stats/maintenance/duplicate-lines forwards the window and dry-run flag', async () => {
|
||||
let seenOptions: unknown = null;
|
||||
const summary = {
|
||||
dryRun: true,
|
||||
lookbackDays: 30,
|
||||
scannedLines: 900,
|
||||
burstGroups: 2,
|
||||
removedLines: 180,
|
||||
removedWordOccurrences: 540,
|
||||
removedKanjiOccurrences: 120,
|
||||
samples: [],
|
||||
};
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
cleanupDuplicateSubtitleLines: async (options: unknown) => {
|
||||
seenOptions = options;
|
||||
return summary;
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dryRun: true, lookbackDays: 30 }),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), summary);
|
||||
assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 30 });
|
||||
});
|
||||
|
||||
it('POST /api/stats/maintenance/duplicate-lines rejects cross-origin simple requests', async () => {
|
||||
let cleanupCalls = 0;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
cleanupDuplicateSubtitleLines: async () => {
|
||||
cleanupCalls += 1;
|
||||
throw new Error('cleanup must not run');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
Origin: 'https://attacker.example',
|
||||
},
|
||||
body: JSON.stringify({ dryRun: false, lookbackDays: null }),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 415);
|
||||
assert.equal(cleanupCalls, 0);
|
||||
});
|
||||
|
||||
it('POST /api/stats/maintenance/duplicate-lines rejects a window shorter than a day', async () => {
|
||||
let cleanupCalls = 0;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
cleanupDuplicateSubtitleLines: async () => {
|
||||
cleanupCalls += 1;
|
||||
return {
|
||||
dryRun: true,
|
||||
lookbackDays: null,
|
||||
scannedLines: 0,
|
||||
burstGroups: 0,
|
||||
removedLines: 0,
|
||||
removedWordOccurrences: 0,
|
||||
removedKanjiOccurrences: 0,
|
||||
samples: [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dryRun: true, lookbackDays: 0.5 }),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(cleanupCalls, 0);
|
||||
});
|
||||
|
||||
it('POST /api/stats/maintenance/duplicate-lines floors a fractional multi-day window', async () => {
|
||||
let seenOptions: unknown = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
cleanupDuplicateSubtitleLines: async (options: unknown) => {
|
||||
seenOptions = options;
|
||||
return {
|
||||
dryRun: true,
|
||||
lookbackDays: 1,
|
||||
scannedLines: 0,
|
||||
burstGroups: 0,
|
||||
removedLines: 0,
|
||||
removedWordOccurrences: 0,
|
||||
removedKanjiOccurrences: 0,
|
||||
samples: [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ dryRun: true, lookbackDays: 1.5 }),
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(seenOptions, { dryRun: true, lookbackDays: 1 });
|
||||
});
|
||||
|
||||
it('POST /api/stats/maintenance/duplicate-lines accepts an explicit empty object for all history', async () => {
|
||||
let seenOptions: unknown = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
cleanupDuplicateSubtitleLines: async (options: unknown) => {
|
||||
seenOptions = options;
|
||||
return {
|
||||
dryRun: false,
|
||||
lookbackDays: null,
|
||||
scannedLines: 0,
|
||||
burstGroups: 0,
|
||||
removedLines: 0,
|
||||
removedWordOccurrences: 0,
|
||||
removedKanjiOccurrences: 0,
|
||||
samples: [],
|
||||
};
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(seenOptions, { dryRun: false, lookbackDays: null });
|
||||
});
|
||||
|
||||
for (const malformed of [
|
||||
{ name: 'a missing body', body: undefined },
|
||||
{ name: 'malformed JSON', body: '{' },
|
||||
{ name: 'JSON null', body: 'null' },
|
||||
{ name: 'a JSON array', body: '[]' },
|
||||
]) {
|
||||
it(`POST /api/stats/maintenance/duplicate-lines rejects ${malformed.name}`, async () => {
|
||||
let cleanupCalls = 0;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
cleanupDuplicateSubtitleLines: async () => {
|
||||
cleanupCalls += 1;
|
||||
throw new Error('cleanup must not run');
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/maintenance/duplicate-lines', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: malformed.body,
|
||||
});
|
||||
|
||||
assert.equal(res.status, 400);
|
||||
assert.equal(cleanupCalls, 0);
|
||||
});
|
||||
}
|
||||
|
||||
it('PUT /api/stats/excluded-words rejects malformed rows', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
|
||||
@@ -1053,6 +1227,55 @@ describe('stats server API routes', () => {
|
||||
assert.equal(body[0].canonicalTitle, 'Little Witch Academia');
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime/merge-recommendations returns pending duplicate pairs', async () => {
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
getAnimeMergeRecommendations: async () => [{ recommendationId: 4, animeIds: [1, 2] }],
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/merge-recommendations');
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(await res.json(), {
|
||||
recommendations: [{ recommendationId: 4, animeIds: [1, 2] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('DELETE /api/stats/anime/merge-recommendations/:id dismisses a pending pair', async () => {
|
||||
let dismissedId: number | null = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
dismissAnimeMergeRecommendation: async (recommendationId: number) => {
|
||||
dismissedId = recommendationId;
|
||||
return true;
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/merge-recommendations/4', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(dismissedId, 4);
|
||||
assert.deepEqual(await res.json(), { ok: true });
|
||||
});
|
||||
|
||||
it('DELETE /api/stats/anime/merge-recommendations/:id reports missing recommendations', async () => {
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
dismissAnimeMergeRecommendation: async () => false,
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/merge-recommendations/99', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
it('GET /api/stats/anime/:animeId returns anime detail with episodes', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const res = await app.request('/api/stats/anime/1');
|
||||
@@ -2454,6 +2677,80 @@ Aligned English subtitle
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card marks the configured Kiku word card kind', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
fs.writeFileSync(sourcePath, 'fake media');
|
||||
|
||||
await withFakeAnkiConnect(
|
||||
async (requests, url) => {
|
||||
const app = createStatsApp(createMockTracker(), {
|
||||
addYomitanNote: async () => 777,
|
||||
createMediaGenerator: () => ({
|
||||
generateAudio: async () => null,
|
||||
generateScreenshot: async () => null,
|
||||
generateAnimatedImage: async () => null,
|
||||
}),
|
||||
ankiConnectConfig: {
|
||||
url,
|
||||
deck: 'Mining',
|
||||
fields: {
|
||||
image: 'Picture',
|
||||
sentence: 'Sentence',
|
||||
},
|
||||
media: {
|
||||
generateAudio: false,
|
||||
generateImage: false,
|
||||
},
|
||||
isKiku: {
|
||||
enabled: true,
|
||||
fieldGrouping: 'disabled',
|
||||
deleteDuplicateInAuto: true,
|
||||
},
|
||||
lapisKiku: {
|
||||
wordCardKind: 'click',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await app.request('/api/stats/mine-card?mode=word', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
sourcePath,
|
||||
startMs: 1_000,
|
||||
endMs: 2_000,
|
||||
sentence: '猫を見た',
|
||||
word: '猫',
|
||||
videoTitle: 'Episode 1',
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await res.json();
|
||||
assert.equal(res.status, 200, JSON.stringify(body));
|
||||
|
||||
const updateRequest = requests.find((request) => request.action === 'updateNoteFields');
|
||||
const fields = updateRequest?.params?.note?.fields ?? {};
|
||||
assert.equal(fields.IsClickCard, 'x');
|
||||
assert.equal(fields.IsWordAndSentenceCard, '');
|
||||
assert.equal(fields.IsSentenceCard, '');
|
||||
assert.equal(fields.IsAudioCard, '');
|
||||
},
|
||||
{
|
||||
notesInfoFields: {
|
||||
Expression: { value: '猫' },
|
||||
Sentence: { value: '' },
|
||||
Picture: { value: '' },
|
||||
IsWordAndSentenceCard: { value: '' },
|
||||
IsClickCard: { value: '' },
|
||||
IsSentenceCard: { value: '' },
|
||||
IsAudioCard: { value: '' },
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/mine-card writes word mining sentence audio and image together', async () => {
|
||||
await withTempDir(async (dir) => {
|
||||
const sourcePath = path.join(dir, 'episode.mkv');
|
||||
@@ -2950,6 +3247,148 @@ Aligned English subtitle
|
||||
assert.equal(deleteCalls, 0);
|
||||
});
|
||||
|
||||
it('POST /api/stats/anime/:animeId/merge folds the given entries into the target', async () => {
|
||||
let merged: { targetAnimeId: number; sourceAnimeIds: number[] } | null = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
mergeAnime: async (targetAnimeId: number, sourceAnimeIds: number[]) => {
|
||||
merged = { targetAnimeId, sourceAnimeIds };
|
||||
return {
|
||||
survivingAnimeId: targetAnimeId,
|
||||
mergedAnimeIds: sourceAnimeIds,
|
||||
movedVideos: 3,
|
||||
};
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/7/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
// The target repeated in the sources must not delete the entry we keep.
|
||||
body: '{"sourceAnimeIds":[8,9,8,7]}',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(merged, { targetAnimeId: 7, sourceAnimeIds: [8, 9] });
|
||||
assert.deepEqual(await res.json(), {
|
||||
ok: true,
|
||||
animeId: 7,
|
||||
mergedAnimeIds: [8, 9],
|
||||
movedVideos: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/anime/:animeId/merge rejects an empty or malformed source list', async () => {
|
||||
let mergeCalls = 0;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
mergeAnime: async () => {
|
||||
mergeCalls += 1;
|
||||
return { survivingAnimeId: 7, mergedAnimeIds: [], movedVideos: 0 };
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
for (const body of [
|
||||
'{"sourceAnimeIds":[]}',
|
||||
'{"sourceAnimeIds":[7]}',
|
||||
'{"sourceAnimeIds":0}',
|
||||
]) {
|
||||
const res = await app.request('/api/stats/anime/7/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
}
|
||||
assert.equal(mergeCalls, 0);
|
||||
});
|
||||
|
||||
it('PATCH /api/stats/media/:videoId/anime moves the episode to another entry', async () => {
|
||||
let moved: { videoId: number; animeId: number } | null = null;
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
moveVideoToAnime: async (videoId: number, animeId: number) => {
|
||||
moved = { videoId, animeId };
|
||||
return { targetAnimeId: animeId, previousAnimeId: 4, removedPreviousAnime: true };
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/media/12/anime', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"animeId":7}',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 200);
|
||||
assert.deepEqual(moved, { videoId: 12, animeId: 7 });
|
||||
assert.deepEqual(await res.json(), {
|
||||
ok: true,
|
||||
animeId: 7,
|
||||
previousAnimeId: 4,
|
||||
removedPreviousAnime: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('POST /api/stats/anime/:animeId/merge reports a merge that folded nothing as 404', async () => {
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
mergeAnime: async (targetAnimeId: number) => ({
|
||||
survivingAnimeId: targetAnimeId,
|
||||
mergedAnimeIds: [],
|
||||
movedVideos: 0,
|
||||
}),
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/anime/7/merge', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"sourceAnimeIds":[8]}',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
it('PATCH /api/stats/media/:videoId/anime reports an unknown target as 404', async () => {
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
moveVideoToAnime: async () => {
|
||||
throw new Error('Unknown episode or target library entry');
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/media/12/anime', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"animeId":99}',
|
||||
});
|
||||
|
||||
assert.equal(res.status, 404);
|
||||
});
|
||||
|
||||
it('PATCH /api/stats/media/:videoId/anime does not disguise storage failures as 404', async () => {
|
||||
const app = createStatsApp(
|
||||
createMockTracker({
|
||||
moveVideoToAnime: async () => {
|
||||
throw new Error('database is locked');
|
||||
},
|
||||
} as Partial<ImmersionTrackerService>),
|
||||
);
|
||||
|
||||
const res = await app.request('/api/stats/media/12/anime', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{"animeId":7}',
|
||||
});
|
||||
|
||||
assert.notEqual(res.status, 404);
|
||||
assert.equal(res.status >= 500, true);
|
||||
});
|
||||
|
||||
it('POST /api/stats/anki/browse returns 400 for missing noteId', async () => {
|
||||
const app = createStatsApp(createMockTracker());
|
||||
const res = await app.request('/api/stats/anki/browse', { method: 'POST' });
|
||||
|
||||
@@ -327,6 +327,7 @@ export function createCoverArtFetcher(
|
||||
titleEnglish: selected.title?.english ?? null,
|
||||
titleNative: selected.title?.native ?? null,
|
||||
episodesTotal: selected.episodes ?? null,
|
||||
exactTitleMatch: resolution?.exactTitleMatch ?? false,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
|
||||
@@ -156,6 +156,79 @@ test('season 1 resolves to the anchor without relation lookups', async () => {
|
||||
assert.deepEqual(relationLookups, []);
|
||||
});
|
||||
|
||||
test('a sequel resolution is not certified by the anchor exact-title evidence', async () => {
|
||||
// The anchor matched the search title exactly, but the hopped-to entry is a
|
||||
// different inference (a split-cour chain can land one season short), so the
|
||||
// sequel result must report its own title evidence, not the anchor's.
|
||||
const { execute } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'My Teen Romantic Comedy SNAFU', season: 2, episode: 1 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 20698);
|
||||
assert.equal(result?.via, 'sequel-chain');
|
||||
assert.equal(result?.exactTitleMatch, false);
|
||||
});
|
||||
|
||||
test('a sequel resolution whose own title matches the parsed title stays exact', async () => {
|
||||
const anchor: AnilistSeasonMedia = {
|
||||
id: 1,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
title: { english: 'Show' },
|
||||
};
|
||||
const sequel: AnilistSeasonMedia = {
|
||||
id: 2,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
title: { english: 'Show 2nd Season' },
|
||||
};
|
||||
const { execute } = createExecutor([anchor], {
|
||||
1: [{ relationType: 'SEQUEL', node: sequel }],
|
||||
});
|
||||
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
{ title: 'Show 2nd Season', season: 2, episode: 1 },
|
||||
{ execute },
|
||||
);
|
||||
|
||||
assert.equal(result?.id, 2);
|
||||
assert.equal(result?.via, 'sequel-chain');
|
||||
assert.equal(result?.exactTitleMatch, true);
|
||||
});
|
||||
|
||||
test('reports an exact normalized synonym match as strong evidence', async () => {
|
||||
const { execute } = createExecutor([
|
||||
{
|
||||
id: 1,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
title: { english: 'Hitori Gotoh Story' },
|
||||
synonyms: ['BOCCHI THE ROCK'],
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await resolveAnilistSeasonMedia({ title: 'Bocchi the Rock!' }, { execute });
|
||||
|
||||
assert.equal(result?.exactTitleMatch, true);
|
||||
});
|
||||
|
||||
test('reports a fuzzy-only search result as weak evidence', async () => {
|
||||
const { execute } = createExecutor([
|
||||
{
|
||||
id: 1,
|
||||
episodes: 12,
|
||||
format: 'TV',
|
||||
title: { english: 'Actual Show' },
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await resolveAnilistSeasonMedia({ title: 'Unrelated Release' }, { execute });
|
||||
|
||||
assert.equal(result?.exactTitleMatch, false);
|
||||
});
|
||||
|
||||
test('strips a season marker already present in the parsed title', async () => {
|
||||
const { execute, searches } = createExecutor(OREGAIRU_SEARCH, OREGAIRU_RELATIONS);
|
||||
const result = await resolveAnilistSeasonMedia(
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* reports `seasonResolved: false` so callers can refuse to act instead of guessing.
|
||||
*/
|
||||
|
||||
import { normalizeTitleIdentity } from '../../utils/title-normalization';
|
||||
|
||||
export interface AnilistSeasonMediaTitle {
|
||||
romaji?: string | null;
|
||||
english?: string | null;
|
||||
@@ -42,6 +44,8 @@ export interface AnilistSeasonResolution {
|
||||
seasonResolved: boolean;
|
||||
requestedSeason: number | null;
|
||||
via: AnilistSeasonResolutionVia;
|
||||
/** Exact normalized match against an AniList title or synonym. */
|
||||
exactTitleMatch: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveAnilistSeasonMediaInput {
|
||||
@@ -115,10 +119,6 @@ const SEASONAL_FORMAT_PRIORITY = ['TV', 'TV_SHORT', 'ONA'];
|
||||
|
||||
const MAX_SEQUEL_HOPS = 12;
|
||||
|
||||
function normalizeTitle(value: string): string {
|
||||
return value.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops season markers a release name carries but AniList titles never do,
|
||||
* so "Some Show Season 3" and "Some Show S3" both search as "Some Show".
|
||||
@@ -136,7 +136,7 @@ function mediaTitles(media: AnilistSeasonMedia): string[] {
|
||||
const synonyms = Array.isArray(media.synonyms) ? media.synonyms : [];
|
||||
return [media.title?.english, media.title?.romaji, media.title?.native, ...synonyms]
|
||||
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
.map((value) => normalizeTitle(value));
|
||||
.map((value) => normalizeTitleIdentity(value));
|
||||
}
|
||||
|
||||
function displayTitle(media: AnilistSeasonMedia, fallback: string): string {
|
||||
@@ -176,6 +176,7 @@ function toResolution(
|
||||
season: number | null,
|
||||
via: AnilistSeasonResolutionVia,
|
||||
seasonResolved: boolean,
|
||||
exactTitleMatch: boolean,
|
||||
): AnilistSeasonResolution {
|
||||
return {
|
||||
id: media.id,
|
||||
@@ -185,6 +186,7 @@ function toResolution(
|
||||
seasonResolved,
|
||||
requestedSeason: season,
|
||||
via,
|
||||
exactTitleMatch,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -209,9 +211,10 @@ export function pickAnchorMedia(
|
||||
: media;
|
||||
const pool = episodeFiltered.length > 0 ? episodeFiltered : media;
|
||||
|
||||
const targets = [normalizeTitle(title), normalizeTitle(stripSeasonSuffix(title))].filter(
|
||||
(value, index, all) => value.length > 0 && all.indexOf(value) === index,
|
||||
);
|
||||
const targets = [
|
||||
normalizeTitleIdentity(title),
|
||||
normalizeTitleIdentity(stripSeasonSuffix(title)),
|
||||
].filter((value, index, all) => value.length > 0 && all.indexOf(value) === index);
|
||||
|
||||
const scored = pool.map((entry, index) => {
|
||||
const candidateTitles = mediaTitles(entry);
|
||||
@@ -367,9 +370,20 @@ export async function resolveAnilistSeasonMedia(
|
||||
episode: season === null || season <= 1 ? input.episode : null,
|
||||
});
|
||||
if (!anchor) return null;
|
||||
// Certifies the media actually returned, never the anchor on its behalf: a
|
||||
// sequel-chain hop can land one season short (split-cour entries) while the
|
||||
// anchor title still matches perfectly, and that certainty must not carry
|
||||
// over to the hopped-to entry.
|
||||
const exactMatchFor = (candidate: AnilistSeasonMedia): boolean => {
|
||||
const titles = mediaTitles(candidate);
|
||||
return (
|
||||
titles.includes(normalizeTitleIdentity(searchTitle)) ||
|
||||
titles.includes(normalizeTitleIdentity(input.title))
|
||||
);
|
||||
};
|
||||
|
||||
if (season === null || season <= 1) {
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', true);
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', true, exactMatchFor(anchor));
|
||||
}
|
||||
|
||||
let chainError: unknown = null;
|
||||
@@ -383,7 +397,14 @@ export async function resolveAnilistSeasonMedia(
|
||||
deps.logInfo?.(
|
||||
`[anilist] season ${season} of "${searchTitle}" resolved via sequel chain: ${displayTitle(viaChain, searchTitle)} (${viaChain.id})`,
|
||||
);
|
||||
return toResolution(viaChain, searchTitle, season, 'sequel-chain', true);
|
||||
return toResolution(
|
||||
viaChain,
|
||||
searchTitle,
|
||||
season,
|
||||
'sequel-chain',
|
||||
true,
|
||||
exactMatchFor(viaChain),
|
||||
);
|
||||
}
|
||||
|
||||
const viaAirOrder = pickByAirOrder(anchor, season, media);
|
||||
@@ -391,7 +412,14 @@ export async function resolveAnilistSeasonMedia(
|
||||
deps.logInfo?.(
|
||||
`[anilist] season ${season} of "${searchTitle}" resolved via air order: ${displayTitle(viaAirOrder, searchTitle)} (${viaAirOrder.id})`,
|
||||
);
|
||||
return toResolution(viaAirOrder, searchTitle, season, 'air-order', true);
|
||||
return toResolution(
|
||||
viaAirOrder,
|
||||
searchTitle,
|
||||
season,
|
||||
'air-order',
|
||||
true,
|
||||
exactMatchFor(viaAirOrder),
|
||||
);
|
||||
}
|
||||
|
||||
// The chain failed for transport reasons rather than because the season is absent;
|
||||
@@ -403,5 +431,5 @@ export async function resolveAnilistSeasonMedia(
|
||||
deps.logInfo?.(
|
||||
`[anilist] could not resolve season ${season} of "${searchTitle}"; falling back to ${displayTitle(anchor, searchTitle)} (${anchor.id})`,
|
||||
);
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', false);
|
||||
return toResolution(anchor, searchTitle, season, 'anchor', false, exactMatchFor(anchor));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
assOverrideSignature,
|
||||
assToPlainText,
|
||||
collectAssOverrideCommands,
|
||||
extractAssOverrideBlocks,
|
||||
hasAssTemporalOverride,
|
||||
isAnimatedAssEffectKind,
|
||||
isAssTemporalCommand,
|
||||
normalizePlainSubtitleText,
|
||||
parseAssEffectField,
|
||||
} from './ass-text';
|
||||
|
||||
test('assToPlainText drops vector drawing runs', () => {
|
||||
assert.equal(
|
||||
assToPlainText(
|
||||
'{\\an5\\pos(730,1042)\\p1\\blur1}m 20 0 b 10 0 0 10 0 20 b 0 31 10 40 20 40 {\\p0}',
|
||||
),
|
||||
'',
|
||||
);
|
||||
});
|
||||
|
||||
test('assToPlainText keeps text around drawing runs on the same event', () => {
|
||||
assert.equal(
|
||||
assToPlainText('{\\p1}m 0 0 l 10 10{\\p0}本文{\\p1}m 5 5 l 6 6{\\p0}続き'),
|
||||
'本文続き',
|
||||
);
|
||||
});
|
||||
|
||||
test('assToPlainText leaves \\pos alone when no drawing mode is active', () => {
|
||||
assert.equal(assToPlainText('{\\pos(960,1068)\\bord3}位置指定'), '位置指定');
|
||||
});
|
||||
|
||||
test('assToPlainText does not read \\pos as a drawing tag', () => {
|
||||
assert.equal(assToPlainText('{\\p1\\pos(1,2)}m 0 0 l 5 5'), '');
|
||||
});
|
||||
|
||||
test('assToPlainText resolves line-break and space escapes', () => {
|
||||
assert.equal(assToPlainText('一行目\\N二行目'), '一行目\n二行目');
|
||||
assert.equal(assToPlainText('一行目\\n二行目'), '一行目\n二行目');
|
||||
assert.equal(assToPlainText('一行目\\N二行目', ' '), '一行目 二行目');
|
||||
assert.equal(assToPlainText('間\\h隔'), '間 隔');
|
||||
});
|
||||
|
||||
test('assToPlainText matches mpv on brace and backslash sequences', () => {
|
||||
// mpv has no `\{` / `\}` / `\\` escapes: the backslashes are literal text and the
|
||||
// braces still open and close an override block.
|
||||
assert.equal(assToPlainText('\\{注\\}'), '\\');
|
||||
assert.equal(assToPlainText('\\\\N'), '\\\n');
|
||||
});
|
||||
|
||||
test('assToPlainText renders an unclosed override block verbatim', () => {
|
||||
// mpv shows the stray brace; guessing where the block ended can eat a whole line.
|
||||
assert.equal(assToPlainText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||
});
|
||||
|
||||
test('assToPlainText is idempotent', () => {
|
||||
const samples = [
|
||||
'{\\an5\\p1}m 0 0 l 5 5{\\p0}本文',
|
||||
'\\{注\\}',
|
||||
'\\\\N',
|
||||
'本文{\\pos(1,2)',
|
||||
'一行目\\N二行目\\h終わり',
|
||||
];
|
||||
|
||||
for (const sample of samples) {
|
||||
const once = assToPlainText(sample);
|
||||
assert.equal(assToPlainText(once), once, sample);
|
||||
}
|
||||
});
|
||||
|
||||
test('assToPlainText normalizes CRLF before converting', () => {
|
||||
assert.equal(assToPlainText('一行目\r\n二行目'), '一行目\n二行目');
|
||||
});
|
||||
|
||||
test('normalizePlainSubtitleText settles whitespace without decoding ASS', () => {
|
||||
// A brace reaching this layer is literal text mpv chose to show, not markup.
|
||||
assert.equal(normalizePlainSubtitleText('本文{\\pos(1,2)'), '本文{\\pos(1,2)');
|
||||
assert.equal(normalizePlainSubtitleText('一行目\\N二行目'), '一行目\n二行目');
|
||||
assert.equal(
|
||||
normalizePlainSubtitleText('一行目\\N二行目', { collapseLineBreaks: true }),
|
||||
'一行目 二行目',
|
||||
);
|
||||
assert.equal(normalizePlainSubtitleText(' 余白 ', { trim: false }), ' 余白 ');
|
||||
});
|
||||
|
||||
test('normalizePlainSubtitleText is idempotent', () => {
|
||||
for (const sample of ['一行目\\N二行目', '間\\h隔', '本文{\\pos(1,2)', ' 余白 ']) {
|
||||
const once = normalizePlainSubtitleText(sample);
|
||||
assert.equal(normalizePlainSubtitleText(once), once, sample);
|
||||
}
|
||||
});
|
||||
|
||||
test('extractAssOverrideBlocks returns block contents', () => {
|
||||
assert.deepEqual(extractAssOverrideBlocks('{\\an8}上{\\fad(200,200)}下'), [
|
||||
'\\an8',
|
||||
'\\fad(200,200)',
|
||||
]);
|
||||
assert.deepEqual(extractAssOverrideBlocks('括弧なし'), []);
|
||||
});
|
||||
|
||||
test('collectAssOverrideCommands captures names and arguments from blocks only', () => {
|
||||
const commands = collectAssOverrideCommands('{\\pos(1,2)\\1c&HFFFFFF&\\kf30}歌詞');
|
||||
|
||||
assert.deepEqual(commands, [
|
||||
{ name: 'pos', args: '1,2', animated: false },
|
||||
{ name: '1c', args: '&HFFFFFF&', animated: false },
|
||||
{ name: 'kf', args: '30', animated: false },
|
||||
]);
|
||||
|
||||
// A `\pos(...)` sitting in visible text is not typesetting markup.
|
||||
assert.deepEqual(collectAssOverrideCommands('\\pos(730,1042) と書いてある'), []);
|
||||
});
|
||||
|
||||
test('collectAssOverrideCommands marks tags animated by a wrapping \\t', () => {
|
||||
const commands = collectAssOverrideCommands('{\\clip(0,0,10,10)\\t(0,500,\\frz30)}文字');
|
||||
|
||||
assert.deepEqual(
|
||||
commands.map((command) => [command.name, command.animated]),
|
||||
[
|
||||
['clip', false],
|
||||
['t', false],
|
||||
['frz', true],
|
||||
],
|
||||
);
|
||||
assert.equal(hasAssTemporalOverride(commands), true);
|
||||
});
|
||||
|
||||
test('collectAssOverrideCommands stops descending into deeply nested \\t tags', () => {
|
||||
// Nested far past the recursion cap. Uncapped, this recurses once per level, and a
|
||||
// pathological line (real files reach one or two levels) overflows the stack.
|
||||
const nesting = 32;
|
||||
const block = `{${'\\t(0,500,'.repeat(nesting)}\\frz30${')'.repeat(nesting)}}文字`;
|
||||
|
||||
const commands = collectAssOverrideCommands(block);
|
||||
|
||||
// The outer `\t` plus one per allowed recursion level, and nothing from below the cap.
|
||||
assert.equal(commands.length, 9);
|
||||
assert.deepEqual(new Set(commands.map((command) => command.name)), new Set(['t']));
|
||||
assert.equal(hasAssTemporalOverride(commands), true);
|
||||
});
|
||||
|
||||
test('hasAssTemporalOverride ignores static placement and shape tags', () => {
|
||||
assert.equal(
|
||||
hasAssTemporalOverride(collectAssOverrideCommands('{\\pos(1,2)\\clip(m 1 1)\\blur2}文字')),
|
||||
false,
|
||||
);
|
||||
assert.equal(hasAssTemporalOverride(collectAssOverrideCommands('{\\move(1,2,3,4)}文字')), true);
|
||||
});
|
||||
|
||||
test('isAssTemporalCommand covers only intrinsically animated tags', () => {
|
||||
for (const command of ['t', 'move', 'k', 'kf', 'ko', 'K']) {
|
||||
assert.equal(isAssTemporalCommand(command), true, command);
|
||||
}
|
||||
for (const command of ['clip', 'iclip', 'frz', 'fscx', 'blur', 'be', 'pos', 'fad']) {
|
||||
assert.equal(isAssTemporalCommand(command), false, command);
|
||||
}
|
||||
});
|
||||
|
||||
test('assOverrideSignature distinguishes events by their override values', () => {
|
||||
const first = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}歌詞'));
|
||||
const second = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 2 2)}歌詞'));
|
||||
const repeat = assOverrideSignature(collectAssOverrideCommands('{\\clip(m 1 1)}別の行'));
|
||||
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(first, repeat);
|
||||
});
|
||||
|
||||
test('parseAssEffectField classifies the event-level Effect column', () => {
|
||||
assert.equal(parseAssEffectField(''), 'none');
|
||||
assert.equal(parseAssEffectField(' '), 'none');
|
||||
assert.equal(parseAssEffectField('Banner;20;1;0'), 'banner');
|
||||
assert.equal(parseAssEffectField('Scroll up;0;0;30;10'), 'scroll');
|
||||
assert.equal(parseAssEffectField('Scroll down;0;0;30;10'), 'scroll');
|
||||
assert.equal(parseAssEffectField('Karaoke'), 'karaoke');
|
||||
assert.equal(parseAssEffectField('fx-template'), 'other');
|
||||
});
|
||||
|
||||
test('parseAssEffectField matches stock effect names exactly', () => {
|
||||
// Custom effect names that merely start with a stock name are not stock effects.
|
||||
assert.equal(parseAssEffectField('scrolling-credit'), 'other');
|
||||
assert.equal(parseAssEffectField('bannerfx;1'), 'other');
|
||||
assert.equal(parseAssEffectField('karaoke-template'), 'other');
|
||||
assert.equal(parseAssEffectField('Scroll'), 'other');
|
||||
});
|
||||
|
||||
test('isAnimatedAssEffectKind covers the stock animated effects only', () => {
|
||||
assert.equal(isAnimatedAssEffectKind('karaoke'), true);
|
||||
assert.equal(isAnimatedAssEffectKind('banner'), true);
|
||||
assert.equal(isAnimatedAssEffectKind('scroll'), true);
|
||||
// Typesetting groups put static template names in this column too.
|
||||
assert.equal(isAnimatedAssEffectKind('other'), false);
|
||||
assert.equal(isAnimatedAssEffectKind('none'), false);
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* ASS/SSA text handling, split into two deliberately distinct contracts:
|
||||
*
|
||||
* assToPlainText() raw ASS event text -> plain text. Ingestion only.
|
||||
* normalizePlainSubtitleText() already-decoded text -> display/lookup form.
|
||||
*
|
||||
* Subtitle text is decoded from ASS exactly once, at the point it enters the app: the
|
||||
* file cue parser does it for sidecar/embedded scripts, and mpv does it for live text
|
||||
* (`sub-text` is already run through mpv's own `ass_to_plaintext`). Everything
|
||||
* downstream -- renderer, timing tracker, tokenizer, tokenization cache keys -- gets
|
||||
* plain text and only normalizes whitespace, so no layer decodes the same string twice.
|
||||
*
|
||||
* assToPlainText mirrors mpv's `ass_to_plaintext` rather than inventing its own rules,
|
||||
* so a cue parsed from a file reads the same as the same line arriving live:
|
||||
* - `{...}` override blocks are markup
|
||||
* - `\pN ... \p0` runs are vector paths, not dialogue
|
||||
* - `\N`, `\n` and `\h` are the only escapes; `\{`, `\}` and `\\` are NOT escapes,
|
||||
* so `\{注\}` decodes to a lone backslash exactly as mpv renders it
|
||||
* - an unclosed `{` is rendered verbatim instead of swallowing the rest of the line
|
||||
* Because the decoder never emits an escape or a closed brace, running it twice is a
|
||||
* no-op -- but downstream code should still use normalizePlainSubtitleText.
|
||||
*/
|
||||
|
||||
/** What `\N` and `\n` become. */
|
||||
export type AssLineBreak = '\n' | ' ';
|
||||
|
||||
// `\p<n>` with n > 0 switches libass into vector-drawing mode: everything until the
|
||||
// next `\p0` is a path (`m 20 0 b 10 0 ...`), not dialogue. The negative lookahead keeps
|
||||
// `\pos(...)` from being read as a drawing tag.
|
||||
const ASS_DRAWING_SCALE_PATTERN = /\\p(?![a-zA-Z])(\d*)/g;
|
||||
|
||||
function readDrawingScale(block: string): number | null {
|
||||
ASS_DRAWING_SCALE_PATTERN.lastIndex = 0;
|
||||
let scale: number | null = null;
|
||||
let match: RegExpExecArray | null;
|
||||
// Drawing mode is whatever the last `\p` tag in this block set it to.
|
||||
while ((match = ASS_DRAWING_SCALE_PATTERN.exec(block)) !== null) {
|
||||
scale = match[1] ? Number(match[1]) : 0;
|
||||
}
|
||||
return scale;
|
||||
}
|
||||
|
||||
/** Resolve `\N`, `\n` and `\h`. The only text-level escapes libass recognises. */
|
||||
function resolveWhitespaceEscapes(text: string, lineBreak: AssLineBreak): string {
|
||||
return text.replace(/\\([Nnh])/g, (_match, escaped: string) =>
|
||||
escaped === 'h' ? ' ' : lineBreak,
|
||||
);
|
||||
}
|
||||
|
||||
/** Strip `{...}` override blocks and the drawing runs they enable. */
|
||||
function stripAssMarkup(raw: string): string {
|
||||
let out = '';
|
||||
let cursor = 0;
|
||||
let drawing = false;
|
||||
|
||||
while (cursor < raw.length) {
|
||||
if (raw[cursor] !== '{') {
|
||||
if (!drawing) {
|
||||
out += raw[cursor];
|
||||
}
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const close = raw.indexOf('}', cursor + 1);
|
||||
if (close === -1) {
|
||||
// mpv shows an unclosed `{` and everything after it. Guessing where the block was
|
||||
// meant to end can eat a whole line of dialogue.
|
||||
if (!drawing) {
|
||||
out += raw.slice(cursor);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
const scale = readDrawingScale(raw.slice(cursor, close + 1));
|
||||
if (scale !== null) {
|
||||
drawing = scale > 0;
|
||||
}
|
||||
cursor = close + 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a raw ASS/SSA event text field. Call this once, where the text enters the app;
|
||||
* downstream layers take the result as plain text.
|
||||
*/
|
||||
export function assToPlainText(text: string, lineBreak: AssLineBreak = '\n'): string {
|
||||
if (!text) return '';
|
||||
return resolveWhitespaceEscapes(stripAssMarkup(text.replace(/\r\n/g, '\n')), lineBreak);
|
||||
}
|
||||
|
||||
export interface NormalizePlainSubtitleTextOptions {
|
||||
/** Fold every line break into a single space. */
|
||||
collapseLineBreaks?: boolean;
|
||||
trim?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whitespace normalization for text that has already been decoded -- by mpv for live
|
||||
* subtitles, by the cue parser for files. Override blocks and drawing runs are none of
|
||||
* this function's business; a `{` that reaches here is literal text mpv chose to show.
|
||||
*
|
||||
* `\N`/`\n`/`\h` are still folded, because subtitle sources outside the ASS path (asbplayer
|
||||
* and other websocket clients) forward them raw and the display layer has to cope.
|
||||
*/
|
||||
export function normalizePlainSubtitleText(
|
||||
text: string,
|
||||
options: NormalizePlainSubtitleTextOptions = {},
|
||||
): string {
|
||||
if (!text) return '';
|
||||
const { collapseLineBreaks = false, trim = true } = options;
|
||||
|
||||
let normalized = resolveWhitespaceEscapes(
|
||||
text.replace(/\r\n/g, '\n'),
|
||||
collapseLineBreaks ? ' ' : '\n',
|
||||
);
|
||||
if (collapseLineBreaks) {
|
||||
normalized = normalized.replace(/\n/g, ' ').replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
return trim ? normalized.trim() : normalized;
|
||||
}
|
||||
|
||||
/** The contents of each `{...}` block, without the braces. */
|
||||
export function extractAssOverrideBlocks(text: string): string[] {
|
||||
const blocks: string[] = [];
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < text.length) {
|
||||
const open = text.indexOf('{', cursor);
|
||||
if (open === -1) {
|
||||
break;
|
||||
}
|
||||
const close = text.indexOf('}', open + 1);
|
||||
if (close === -1) {
|
||||
break;
|
||||
}
|
||||
blocks.push(text.slice(open + 1, close));
|
||||
cursor = close + 1;
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
export interface AssOverrideCommand {
|
||||
/** Tag name without the backslash, e.g. `pos`, `kf`, `1c`. */
|
||||
name: string;
|
||||
/** Everything the tag was given, e.g. `960,1068` for `\pos(960,1068)`. */
|
||||
args: string;
|
||||
/** Nested inside a `\t(...)` argument, so its value is animated over the event. */
|
||||
animated: boolean;
|
||||
}
|
||||
|
||||
const ASS_OVERRIDE_NAME_PATTERN = /[1-4]?[a-zA-Z]+/y;
|
||||
|
||||
function readCommandArgs(block: string, start: number): { args: string; next: number } {
|
||||
if (block[start] === '(') {
|
||||
let depth = 0;
|
||||
for (let i = start; i < block.length; i += 1) {
|
||||
if (block[i] === '(') depth += 1;
|
||||
else if (block[i] === ')') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
return { args: block.slice(start + 1, i), next: i + 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
return { args: block.slice(start + 1), next: block.length };
|
||||
}
|
||||
|
||||
const nextTag = block.indexOf('\\', start);
|
||||
const end = nextTag === -1 ? block.length : nextTag;
|
||||
return { args: block.slice(start, end), next: end };
|
||||
}
|
||||
|
||||
// `\t(...)` can wrap another `\t(...)`, and nothing in the format stops an author (or a
|
||||
// malformed file) from nesting them thousands deep. Real typesetting never goes past one
|
||||
// or two levels, so stop recursing well before the call stack is at risk.
|
||||
const MAX_ANIMATION_NESTING_DEPTH = 8;
|
||||
|
||||
function parseOverrideBlock(
|
||||
block: string,
|
||||
animated: boolean,
|
||||
into: AssOverrideCommand[],
|
||||
depth = 0,
|
||||
): void {
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < block.length) {
|
||||
if (block[cursor] !== '\\') {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
ASS_OVERRIDE_NAME_PATTERN.lastIndex = cursor + 1;
|
||||
const nameMatch = ASS_OVERRIDE_NAME_PATTERN.exec(block);
|
||||
if (!nameMatch) {
|
||||
cursor += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const name = nameMatch[0];
|
||||
const { args, next } = readCommandArgs(block, cursor + 1 + name.length);
|
||||
into.push({ name, args: args.trim(), animated });
|
||||
// `\t(0,500,\frz30)` animates whatever it wraps, so record the inner tags too.
|
||||
if (name === 't' && args.includes('\\') && depth < MAX_ANIMATION_NESTING_DEPTH) {
|
||||
parseOverrideBlock(args, true, into, depth + 1);
|
||||
}
|
||||
cursor = next;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Override commands with their arguments, in source order. Only `{...}` blocks are
|
||||
* inspected, so a `\pos(...)` sitting in visible text is never mistaken for markup.
|
||||
*/
|
||||
export function collectAssOverrideCommands(text: string): AssOverrideCommand[] {
|
||||
const commands: AssOverrideCommand[] = [];
|
||||
for (const block of extractAssOverrideBlocks(text)) {
|
||||
parseOverrideBlock(block, false, commands);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
// Tags that are animated by definition: `\t` interpolates, `\move` travels, and the
|
||||
// karaoke tags advance a highlight across the event's own duration. Everything else --
|
||||
// `\pos`, `\clip`, `\frz`, `\blur`, `\fad` -- is a static value for the event, so its
|
||||
// presence says nothing about whether neighbouring events form one animation.
|
||||
const ASS_TEMPORAL_COMMANDS = new Set(['t', 'move', 'k', 'kf', 'ko', 'K']);
|
||||
|
||||
export function isAssTemporalCommand(name: string): boolean {
|
||||
return ASS_TEMPORAL_COMMANDS.has(name);
|
||||
}
|
||||
|
||||
/** True when the event animates on its own, or animates a static tag through `\t(...)`. */
|
||||
export function hasAssTemporalOverride(commands: readonly AssOverrideCommand[]): boolean {
|
||||
return commands.some((command) => command.animated || isAssTemporalCommand(command.name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical form of an event's override values, for comparing consecutive events. Two
|
||||
* events with the same signature were typeset identically, so neither is a frame of an
|
||||
* animation the other belongs to.
|
||||
*/
|
||||
export function assOverrideSignature(commands: readonly AssOverrideCommand[]): string {
|
||||
return commands.map((command) => `${command.name}(${command.args})`).join('|');
|
||||
}
|
||||
|
||||
export type AssEffectKind = 'none' | 'banner' | 'scroll' | 'karaoke' | 'other';
|
||||
|
||||
// The stock effects, matched exactly. Typesetting groups put their own template names in
|
||||
// this column -- `scrolling-credit` is a static sign, not libass's `Scroll up` -- so a
|
||||
// prefix match would hand out animation evidence to arbitrary custom effects.
|
||||
const STOCK_ASS_EFFECTS = new Map<string, AssEffectKind>([
|
||||
['banner', 'banner'],
|
||||
['scroll up', 'scroll'],
|
||||
['scroll down', 'scroll'],
|
||||
['karaoke', 'karaoke'],
|
||||
]);
|
||||
|
||||
/**
|
||||
* The event-level `Effect` column. The stock values (`Banner;...`, `Scroll up;...`,
|
||||
* `Scroll down;...`, `Karaoke`) all animate; anything else is a custom name and lands in
|
||||
* `other`.
|
||||
*/
|
||||
export function parseAssEffectField(raw: string): AssEffectKind {
|
||||
const value = raw.trim().toLowerCase();
|
||||
if (!value) return 'none';
|
||||
|
||||
const name = value.split(';', 1)[0]!.trim();
|
||||
return STOCK_ASS_EFFECTS.get(name) ?? 'other';
|
||||
}
|
||||
|
||||
const ANIMATED_ASS_EFFECT_KINDS = new Set<AssEffectKind>(['banner', 'scroll', 'karaoke']);
|
||||
|
||||
export function isAnimatedAssEffectKind(kind: AssEffectKind): boolean {
|
||||
return ANIMATED_ASS_EFFECT_KINDS.has(kind);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user