mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
feat(stats): add launcher stats command and build integration
- Launcher stats subcommand with cleanup mode - Stats frontend build integrated into Makefile - CI workflow updated for stats package - Config example updated with stats section - mpv plugin menu entry for stats toggle
This commit is contained in:
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
@@ -27,13 +27,16 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
~/.bun/install/cache
|
~/.bun/install/cache
|
||||||
node_modules
|
node_modules
|
||||||
|
stats/node_modules
|
||||||
vendor/subminer-yomitan/node_modules
|
vendor/subminer-yomitan/node_modules
|
||||||
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
|
key: ${{ runner.os }}-bun-${{ hashFiles('bun.lock', 'stats/bun.lock', 'vendor/subminer-yomitan/package-lock.json') }}
|
||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-bun-
|
${{ runner.os }}-bun-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: |
|
||||||
|
bun install --frozen-lockfile
|
||||||
|
cd stats && bun install --frozen-lockfile
|
||||||
|
|
||||||
- name: Lint changelog fragments
|
- name: Lint changelog fragments
|
||||||
run: bun run changelog:lint
|
run: bun run changelog:lint
|
||||||
@@ -49,6 +52,9 @@ jobs:
|
|||||||
- name: Verify generated config examples
|
- name: Verify generated config examples
|
||||||
run: bun run verify:config-example
|
run: bun run verify:config-example
|
||||||
|
|
||||||
|
- name: Internal docs knowledge-base checks
|
||||||
|
run: bun run test:docs:kb
|
||||||
|
|
||||||
- name: Test suite (source)
|
- name: Test suite (source)
|
||||||
run: bun run test:fast
|
run: bun run test:fast
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -52,3 +52,4 @@ tests/*
|
|||||||
!.agents/skills/subminer-scrum-master/SKILL.md
|
!.agents/skills/subminer-scrum-master/SKILL.md
|
||||||
favicon.png
|
favicon.png
|
||||||
.claude/*
|
.claude/*
|
||||||
|
!stats/public/favicon.png
|
||||||
|
|||||||
92
AGENTS.md
92
AGENTS.md
@@ -1,17 +1,29 @@
|
|||||||
# AGENTS.MD
|
# AGENTS.MD
|
||||||
|
|
||||||
|
## Internal Docs
|
||||||
|
|
||||||
|
Start here, then leave this file.
|
||||||
|
|
||||||
|
- Internal system of record: [`docs/README.md`](./docs/README.md)
|
||||||
|
- Architecture map: [`docs/architecture/README.md`](./docs/architecture/README.md)
|
||||||
|
- Workflow map: [`docs/workflow/README.md`](./docs/workflow/README.md)
|
||||||
|
- Verification lanes: [`docs/workflow/verification.md`](./docs/workflow/verification.md)
|
||||||
|
- Knowledge-base rules: [`docs/knowledge-base/README.md`](./docs/knowledge-base/README.md)
|
||||||
|
- Release guide: [`docs/RELEASING.md`](./docs/RELEASING.md)
|
||||||
|
|
||||||
|
`docs-site/` is user-facing. Do not treat it as the canonical internal source of truth.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
- Read [`docs-site/development.md`](./docs-site/development.md) and [`docs-site/architecture.md`](./docs-site/architecture.md) before substantial changes; follow them unless task requires deviation.
|
- Init workspace: `git submodule update --init --recursive`
|
||||||
- Init workspace: `git submodule update --init --recursive`.
|
- Install deps: `make deps` or `bun install` plus `(cd vendor/texthooker-ui && bun install --frozen-lockfile)`
|
||||||
- Install deps: `make deps` or `bun install` plus `(cd vendor/texthooker-ui && bun install --frozen-lockfile)`.
|
- Fast dev loop: `make dev-watch`
|
||||||
- Fast dev loop: `make dev-watch`.
|
- Full local run: `bun run dev`
|
||||||
- Full local run: `bun run dev`.
|
- Verbose Electron debug: `electron . --start --dev --log-level debug`
|
||||||
- Verbose Electron debug: `electron . --start --dev --log-level debug`.
|
|
||||||
|
|
||||||
## Build / Test
|
## Build / Test
|
||||||
|
|
||||||
- Use repo package manager/runtime only: Bun (`packageManager: bun@1.3.5`).
|
- Runtime/package manager: Bun (`packageManager: bun@1.3.5`)
|
||||||
- Default handoff gate:
|
- Default handoff gate:
|
||||||
`bun run typecheck`
|
`bun run typecheck`
|
||||||
`bun run test:fast`
|
`bun run test:fast`
|
||||||
@@ -21,59 +33,37 @@
|
|||||||
- If `docs-site/` changed, also run:
|
- If `docs-site/` changed, also run:
|
||||||
`bun run docs:test`
|
`bun run docs:test`
|
||||||
`bun run docs:build`
|
`bun run docs:build`
|
||||||
- Formatting: prefer `make pretty` and `bun run format:check:src`; use `bun run format` only intentionally.
|
- Prefer `make pretty` and `bun run format:check:src`
|
||||||
- Keep verification observable; capture failing command + exact error in notes/handoff.
|
|
||||||
|
|
||||||
## Change-Specific Checks
|
## Change-Specific Checks
|
||||||
|
|
||||||
- Config/schema/defaults changes: run `bun run test:config`; if config template/defaults changed, run `bun run generate:config-example`.
|
- Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example`
|
||||||
- Launcher/plugin changes: run `bun run test:launcher` or `bun run test:env`; use `bun run test:launcher:smoke:src` for focused launcher e2e checks.
|
- Launcher/plugin: `bun run test:launcher` or `bun run test:env`
|
||||||
- Runtime-compat or compiled/dist-sensitive changes: run `bun run test:runtime:compat`.
|
- Runtime-compat / dist-sensitive: `bun run test:runtime:compat`
|
||||||
- Docs-only changes: at least `bun run docs:test` if docs behavior/assertions changed; `bun run docs:build` before handoff.
|
- Docs-only: `bun run docs:test`, then `bun run docs:build`
|
||||||
|
|
||||||
## Generated / Sensitive Files
|
## Sensitive Files
|
||||||
|
|
||||||
- Launcher source of truth: `launcher/*.ts`.
|
- Launcher source of truth: `launcher/*.ts`
|
||||||
- Generated launcher artifact: `dist/launcher/subminer`; never hand-edit it.
|
- Generated launcher artifact: `dist/launcher/subminer`; never hand-edit it
|
||||||
- Repo-root `./subminer` is stale artifact path; do not revive/use it.
|
- Repo-root `./subminer` is stale; do not revive it
|
||||||
- `bun run build` rebuilds bundled Yomitan from `vendor/subminer-yomitan`; check submodules before debugging build failures.
|
- `bun run build` rebuilds bundled Yomitan from `vendor/subminer-yomitan`
|
||||||
- Avoid changing packaging/signing identifiers (`build.appId`, mac entitlements, signing-related settings) unless task explicitly requires it.
|
- Do not change signing/packaging identifiers unless the task explicitly requires it
|
||||||
|
|
||||||
## Docs
|
## Release / PR Notes
|
||||||
|
|
||||||
- Docs site lives in-repo under [`docs-site/`](./docs-site/).
|
- User-visible PRs need one fragment in `changes/*.md`
|
||||||
- Update docs for new/breaking behavior; no ship with stale docs.
|
- CI enforces `bun run changelog:lint` and `bun run changelog:pr-check`
|
||||||
- Make sure [`docs-site/changelog.md`](./docs-site/changelog.md) is updated on each release.
|
- PR review helpers:
|
||||||
|
- `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'`
|
||||||
|
- `gh api repos/:owner/:repo/pulls/<num>/comments --paginate`
|
||||||
|
|
||||||
## PR Feedback
|
## Runtime Notes
|
||||||
|
|
||||||
- Active PR: `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'`.
|
- Use Codex background for long jobs; tmux only when persistence/interaction is required
|
||||||
- PR comments: `gh pr view …` + `gh api …/comments --paginate`.
|
- CI red: `gh run list/view`, rerun, fix, repeat until green
|
||||||
- Replies: cite fix + file/line; resolve threads only after fix lands.
|
- TypeScript: keep files small; follow existing patterns
|
||||||
|
- Swift: use workspace helper/daemon; validate `swift build` + tests
|
||||||
## Changelog
|
|
||||||
|
|
||||||
- User-visible PRs: add one fragment in `changes/*.md`.
|
|
||||||
- Fragment format:
|
|
||||||
`type: added|changed|fixed|docs|internal`
|
|
||||||
`area: <short-area>`
|
|
||||||
blank line
|
|
||||||
`- bullet`
|
|
||||||
- `changes/README.md`: instructions only; generator ignores it.
|
|
||||||
- No release-note entry wanted: use PR label `skip-changelog`.
|
|
||||||
- CI runs `bun run changelog:lint` + `bun run changelog:pr-check` on PRs.
|
|
||||||
- Release prep: `bun run changelog:build`, review `CHANGELOG.md` + `release/release-notes.md`, commit generated changelog + fragment deletions, then tag.
|
|
||||||
- Release CI expects committed changelog entry already present; do not rely on tag job to invent notes.
|
|
||||||
|
|
||||||
## Flow & Runtime
|
|
||||||
|
|
||||||
- Use Codex background for long jobs; tmux only for interactive/persistent (debugger/server).
|
|
||||||
- CI red: `gh run list/view`, rerun, fix, push, repeat til green.
|
|
||||||
|
|
||||||
## Language/Stack Notes
|
|
||||||
|
|
||||||
- Swift: use workspace helper/daemon; validate `swift build` + tests; keep concurrency attrs right.
|
|
||||||
- TypeScript: use repo PM; keep files small; follow existing patterns.
|
|
||||||
|
|
||||||
<!-- BACKLOG.MD MCP GUIDELINES START -->
|
<!-- BACKLOG.MD MCP GUIDELINES START -->
|
||||||
|
|
||||||
|
|||||||
3
Makefile
3
Makefile
@@ -69,7 +69,7 @@ help:
|
|||||||
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
" generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \
|
||||||
"" \
|
"" \
|
||||||
"Other targets:" \
|
"Other targets:" \
|
||||||
" deps Install JS dependencies (root + texthooker-ui)" \
|
" deps Install JS dependencies (root + stats + texthooker-ui)" \
|
||||||
" uninstall-linux Remove Linux install artifacts" \
|
" uninstall-linux Remove Linux install artifacts" \
|
||||||
" uninstall-macos Remove macOS install artifacts" \
|
" uninstall-macos Remove macOS install artifacts" \
|
||||||
" uninstall-windows Remove Windows mpv plugin artifacts" \
|
" uninstall-windows Remove Windows mpv plugin artifacts" \
|
||||||
@@ -104,6 +104,7 @@ print-dirs:
|
|||||||
deps:
|
deps:
|
||||||
@$(MAKE) --no-print-directory ensure-bun
|
@$(MAKE) --no-print-directory ensure-bun
|
||||||
@bun install
|
@bun install
|
||||||
|
@cd stats && bun install --frozen-lockfile
|
||||||
@cd vendor/texthooker-ui && bun install --frozen-lockfile
|
@cd vendor/texthooker-ui && bun install --frozen-lockfile
|
||||||
|
|
||||||
ensure-bun:
|
ensure-bun:
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ SubMiner is an Electron overlay that sits on top of mpv. It turns your video pla
|
|||||||
- **Look up words as you watch** — Yomitan dictionary popups on hover or keyboard-driven token-by-token navigation
|
- **Look up words as you watch** — Yomitan dictionary popups on hover or keyboard-driven token-by-token navigation
|
||||||
- **One-key Anki mining** — Creates cards with sentence, audio, screenshot, and translation; optional local AnkiConnect proxy auto-enriches Yomitan cards instantly
|
- **One-key Anki mining** — Creates cards with sentence, audio, screenshot, and translation; optional local AnkiConnect proxy auto-enriches Yomitan cards instantly
|
||||||
- **Reading annotations** — N+1 targeting, frequency-dictionary highlighting, JLPT underlining, and character name dictionary for anime/manga proper nouns
|
- **Reading annotations** — N+1 targeting, frequency-dictionary highlighting, JLPT underlining, and character name dictionary for anime/manga proper nouns
|
||||||
|
- **Immersion stats** — Optional dashboard for watch time, sessions, trends, vocabulary, and mining throughput
|
||||||
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
- **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync
|
||||||
- **Jellyfin & AniList integration** — Remote playback, cast device mode, and automatic episode progress tracking
|
- **Jellyfin & AniList integration** — Remote playback, cast device mode, and automatic episode progress tracking
|
||||||
- **Texthooker & API** — Built-in texthooker page and annotated websocket feed for external clients
|
- **Texthooker & API** — Built-in texthooker page and annotated websocket feed for external clients
|
||||||
@@ -118,7 +119,7 @@ Windows builds use native window tracking and do not require the Linux composito
|
|||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
For full guides on configuration, Anki, Jellyfin, and more, see [docs.subminer.moe](https://docs.subminer.moe). The VitePress source for that site lives in [`docs-site/`](./docs-site/).
|
For full guides on configuration, Anki, Jellyfin, immersion tracking/stats, and more, see [docs.subminer.moe](https://docs.subminer.moe). The VitePress source for that site lives in [`docs-site/`](./docs-site/).
|
||||||
|
|
||||||
## Acknowledgments
|
## Acknowledgments
|
||||||
|
|
||||||
|
|||||||
6
bun.lock
6
bun.lock
@@ -5,9 +5,11 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.19.11",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
|
"hono": "^4.12.7",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"libsql": "^0.5.22",
|
"libsql": "^0.5.22",
|
||||||
"ws": "^8.19.0",
|
"ws": "^8.19.0",
|
||||||
@@ -96,6 +98,8 @@
|
|||||||
|
|
||||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
|
||||||
|
|
||||||
|
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
|
||||||
|
|
||||||
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
|
"@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="],
|
||||||
|
|
||||||
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "7.1.2" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "7.1.2" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
|
||||||
@@ -396,6 +400,8 @@
|
|||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
||||||
|
|
||||||
|
"hono": ["hono@4.12.7", "", {}, "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw=="],
|
||||||
|
|
||||||
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
"hosted-git-info": ["hosted-git-info@4.1.0", "", { "dependencies": { "lru-cache": "6.0.0" } }, "sha512-kyCuEOWjJqZuDbRHzL8V93NzQhwIB71oFWSyzVo+KPZI+pnQPPxucdkrOZvkLRnrf5URsQM+IJ09Dw29cRALIA=="],
|
||||||
|
|
||||||
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
"http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="],
|
||||||
|
|||||||
@@ -503,5 +503,16 @@
|
|||||||
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
"monthlyRollupsDays": 1825, // Monthly rollup retention window in days.
|
||||||
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
"vacuumIntervalDays": 7 // Minimum days between VACUUM runs.
|
||||||
} // Retention setting.
|
} // Retention setting.
|
||||||
} // Enable/disable immersion tracking.
|
}, // Enable/disable immersion tracking.
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// Stats Dashboard
|
||||||
|
// Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
||||||
|
// Uses the immersion tracking database for overview, trends, sessions, and vocabulary views.
|
||||||
|
// ==========================================
|
||||||
|
"stats": {
|
||||||
|
"toggleKey": "Backquote", // Key code to toggle the stats overlay.
|
||||||
|
"serverPort": 5175, // Port for the stats HTTP server.
|
||||||
|
"autoStartServer": true // Automatically start the stats server on launch. Values: true | false
|
||||||
|
} // Local immersion stats dashboard served on localhost and available as an in-app overlay.
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { runConfigCommand } from './config-command.js';
|
|||||||
import { runDictionaryCommand } from './dictionary-command.js';
|
import { runDictionaryCommand } from './dictionary-command.js';
|
||||||
import { runDoctorCommand } from './doctor-command.js';
|
import { runDoctorCommand } from './doctor-command.js';
|
||||||
import { runMpvPreAppCommand } from './mpv-command.js';
|
import { runMpvPreAppCommand } from './mpv-command.js';
|
||||||
|
import { runStatsCommand } from './stats-command.js';
|
||||||
|
|
||||||
class ExitSignal extends Error {
|
class ExitSignal extends Error {
|
||||||
code: number;
|
code: number;
|
||||||
@@ -128,3 +129,98 @@ test('dictionary command throws if app handoff unexpectedly returns', () => {
|
|||||||
/unexpectedly returned/,
|
/unexpectedly returned/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stats command launches attached app command with response path', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args.stats = true;
|
||||||
|
context.args.logLevel = 'debug';
|
||||||
|
const forwarded: string[][] = [];
|
||||||
|
|
||||||
|
const handled = await runStatsCommand(context, {
|
||||||
|
createTempDir: () => '/tmp/subminer-stats-test',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
runAppCommandAttached: async (_appPath, appArgs) => {
|
||||||
|
forwarded.push(appArgs);
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
waitForStatsResponse: async () => ({ ok: true, url: 'http://127.0.0.1:5175' }),
|
||||||
|
removeDir: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(forwarded, [
|
||||||
|
['--stats', '--stats-response-path', '/tmp/subminer-stats-test/response.json', '--log-level', 'debug'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stats cleanup command forwards cleanup vocab flags to the app', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args.stats = true;
|
||||||
|
context.args.statsCleanup = true;
|
||||||
|
context.args.statsCleanupVocab = true;
|
||||||
|
const forwarded: string[][] = [];
|
||||||
|
|
||||||
|
const handled = await runStatsCommand(context, {
|
||||||
|
createTempDir: () => '/tmp/subminer-stats-test',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
runAppCommandAttached: async (_appPath, appArgs) => {
|
||||||
|
forwarded.push(appArgs);
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
waitForStatsResponse: async () => ({ ok: true }),
|
||||||
|
removeDir: () => {},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(forwarded, [
|
||||||
|
[
|
||||||
|
'--stats',
|
||||||
|
'--stats-response-path',
|
||||||
|
'/tmp/subminer-stats-test/response.json',
|
||||||
|
'--stats-cleanup',
|
||||||
|
'--stats-cleanup-vocab',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stats command throws when stats response reports an error', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args.stats = true;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
await runStatsCommand(context, {
|
||||||
|
createTempDir: () => '/tmp/subminer-stats-test',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
runAppCommandAttached: async () => 0,
|
||||||
|
waitForStatsResponse: async () => ({
|
||||||
|
ok: false,
|
||||||
|
error: 'Immersion tracking is disabled in config.',
|
||||||
|
}),
|
||||||
|
removeDir: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/Immersion tracking is disabled in config\./,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stats command fails if attached app exits before startup response', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args.stats = true;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
await runStatsCommand(context, {
|
||||||
|
createTempDir: () => '/tmp/subminer-stats-test',
|
||||||
|
joinPath: (...parts) => parts.join('/'),
|
||||||
|
runAppCommandAttached: async () => 2,
|
||||||
|
waitForStatsResponse: async () => {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||||
|
return { ok: true, url: 'http://127.0.0.1:5175' };
|
||||||
|
},
|
||||||
|
removeDir: () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/Stats app exited before startup response \(status 2\)\./,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
108
launcher/commands/stats-command.ts
Normal file
108
launcher/commands/stats-command.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { runAppCommandAttached } from '../mpv.js';
|
||||||
|
import { sleep } from '../util.js';
|
||||||
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
|
type StatsCommandResponse = {
|
||||||
|
ok: boolean;
|
||||||
|
url?: string;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatsCommandDeps = {
|
||||||
|
createTempDir: (prefix: string) => string;
|
||||||
|
joinPath: (...parts: string[]) => string;
|
||||||
|
runAppCommandAttached: (
|
||||||
|
appPath: string,
|
||||||
|
appArgs: string[],
|
||||||
|
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||||
|
label: string,
|
||||||
|
) => Promise<number>;
|
||||||
|
waitForStatsResponse: (responsePath: string) => Promise<StatsCommandResponse>;
|
||||||
|
removeDir: (targetPath: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultDeps: StatsCommandDeps = {
|
||||||
|
createTempDir: (prefix) => fs.mkdtempSync(path.join(os.tmpdir(), prefix)),
|
||||||
|
joinPath: (...parts) => path.join(...parts),
|
||||||
|
runAppCommandAttached: (appPath, appArgs, logLevel, label) =>
|
||||||
|
runAppCommandAttached(appPath, appArgs, logLevel, label),
|
||||||
|
waitForStatsResponse: async (responsePath) => {
|
||||||
|
const deadline = Date.now() + 8000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(responsePath)) {
|
||||||
|
return JSON.parse(fs.readFileSync(responsePath, 'utf8')) as StatsCommandResponse;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// retry until timeout
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: 'Timed out waiting for stats dashboard startup response.',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
removeDir: (targetPath) => {
|
||||||
|
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runStatsCommand(
|
||||||
|
context: LauncherCommandContext,
|
||||||
|
deps: StatsCommandDeps = defaultDeps,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { args, appPath } = context;
|
||||||
|
if (!args.stats || !appPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempDir = deps.createTempDir('subminer-stats-');
|
||||||
|
const responsePath = deps.joinPath(tempDir, 'response.json');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const forwarded = ['--stats', '--stats-response-path', responsePath];
|
||||||
|
if (args.statsCleanup) {
|
||||||
|
forwarded.push('--stats-cleanup');
|
||||||
|
}
|
||||||
|
if (args.statsCleanupVocab) {
|
||||||
|
forwarded.push('--stats-cleanup-vocab');
|
||||||
|
}
|
||||||
|
if (args.logLevel !== 'info') {
|
||||||
|
forwarded.push('--log-level', args.logLevel);
|
||||||
|
}
|
||||||
|
const attachedExitPromise = deps.runAppCommandAttached(
|
||||||
|
appPath,
|
||||||
|
forwarded,
|
||||||
|
args.logLevel,
|
||||||
|
'stats',
|
||||||
|
);
|
||||||
|
const startupResult = await Promise.race([
|
||||||
|
deps.waitForStatsResponse(responsePath).then((response) => ({ kind: 'response' as const, response })),
|
||||||
|
attachedExitPromise.then((status) => ({ kind: 'exit' as const, status })),
|
||||||
|
]);
|
||||||
|
if (startupResult.kind === 'exit') {
|
||||||
|
if (startupResult.status !== 0) {
|
||||||
|
throw new Error(`Stats app exited before startup response (status ${startupResult.status}).`);
|
||||||
|
}
|
||||||
|
const response = await deps.waitForStatsResponse(responsePath);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(response.error || 'Stats dashboard failed to start.');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!startupResult.response.ok) {
|
||||||
|
throw new Error(startupResult.response.error || 'Stats dashboard failed to start.');
|
||||||
|
}
|
||||||
|
const exitStatus = await attachedExitPromise;
|
||||||
|
if (exitStatus !== 0) {
|
||||||
|
throw new Error(`Stats app exited with status ${exitStatus}.`);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
deps.removeDir(tempDir);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,6 +122,9 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
|||||||
jellyfinPlay: false,
|
jellyfinPlay: false,
|
||||||
jellyfinDiscovery: false,
|
jellyfinDiscovery: false,
|
||||||
dictionary: false,
|
dictionary: false,
|
||||||
|
stats: false,
|
||||||
|
statsCleanup: false,
|
||||||
|
statsCleanupVocab: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
@@ -188,6 +191,9 @@ export function applyRootOptionsToArgs(
|
|||||||
|
|
||||||
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||||
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
||||||
|
if (invocations.statsTriggered) parsed.stats = true;
|
||||||
|
if (invocations.statsCleanup) parsed.statsCleanup = true;
|
||||||
|
if (invocations.statsCleanupVocab) parsed.statsCleanupVocab = true;
|
||||||
if (invocations.dictionaryTarget) {
|
if (invocations.dictionaryTarget) {
|
||||||
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
||||||
}
|
}
|
||||||
@@ -256,6 +262,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
if (invocations.dictionaryLogLevel) {
|
if (invocations.dictionaryLogLevel) {
|
||||||
parsed.logLevel = parseLogLevel(invocations.dictionaryLogLevel);
|
parsed.logLevel = parseLogLevel(invocations.dictionaryLogLevel);
|
||||||
}
|
}
|
||||||
|
if (invocations.statsLogLevel) {
|
||||||
|
parsed.logLevel = parseLogLevel(invocations.statsLogLevel);
|
||||||
|
}
|
||||||
|
|
||||||
if (invocations.doctorLogLevel) parsed.logLevel = parseLogLevel(invocations.doctorLogLevel);
|
if (invocations.doctorLogLevel) parsed.logLevel = parseLogLevel(invocations.doctorLogLevel);
|
||||||
if (invocations.texthookerLogLevel)
|
if (invocations.texthookerLogLevel)
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export interface CliInvocations {
|
|||||||
dictionaryTriggered: boolean;
|
dictionaryTriggered: boolean;
|
||||||
dictionaryTarget: string | null;
|
dictionaryTarget: string | null;
|
||||||
dictionaryLogLevel: string | null;
|
dictionaryLogLevel: string | null;
|
||||||
|
statsTriggered: boolean;
|
||||||
|
statsCleanup: boolean;
|
||||||
|
statsCleanupVocab: boolean;
|
||||||
|
statsLogLevel: string | null;
|
||||||
doctorTriggered: boolean;
|
doctorTriggered: boolean;
|
||||||
doctorLogLevel: string | null;
|
doctorLogLevel: string | null;
|
||||||
texthookerTriggered: boolean;
|
texthookerTriggered: boolean;
|
||||||
@@ -87,6 +91,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
|||||||
'mpv',
|
'mpv',
|
||||||
'dictionary',
|
'dictionary',
|
||||||
'dict',
|
'dict',
|
||||||
|
'stats',
|
||||||
'texthooker',
|
'texthooker',
|
||||||
'app',
|
'app',
|
||||||
'bin',
|
'bin',
|
||||||
@@ -137,6 +142,10 @@ export function parseCliPrograms(
|
|||||||
let dictionaryTriggered = false;
|
let dictionaryTriggered = false;
|
||||||
let dictionaryTarget: string | null = null;
|
let dictionaryTarget: string | null = null;
|
||||||
let dictionaryLogLevel: string | null = null;
|
let dictionaryLogLevel: string | null = null;
|
||||||
|
let statsTriggered = false;
|
||||||
|
let statsCleanup = false;
|
||||||
|
let statsCleanupVocab = false;
|
||||||
|
let statsLogLevel: string | null = null;
|
||||||
let doctorLogLevel: string | null = null;
|
let doctorLogLevel: string | null = null;
|
||||||
let texthookerLogLevel: string | null = null;
|
let texthookerLogLevel: string | null = null;
|
||||||
let doctorTriggered = false;
|
let doctorTriggered = false;
|
||||||
@@ -241,6 +250,21 @@ export function parseCliPrograms(
|
|||||||
dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('stats')
|
||||||
|
.description('Launch the local immersion stats dashboard')
|
||||||
|
.argument('[action]', 'cleanup')
|
||||||
|
.option('-v, --vocab', 'Clean vocabulary rows in the stats database')
|
||||||
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||||
|
statsTriggered = true;
|
||||||
|
if ((action || '').toLowerCase() === 'cleanup') {
|
||||||
|
statsCleanup = true;
|
||||||
|
statsCleanupVocab = options.vocab === true;
|
||||||
|
}
|
||||||
|
statsLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||||
|
});
|
||||||
|
|
||||||
commandProgram
|
commandProgram
|
||||||
.command('doctor')
|
.command('doctor')
|
||||||
.description('Run dependency and environment checks')
|
.description('Run dependency and environment checks')
|
||||||
@@ -319,6 +343,10 @@ export function parseCliPrograms(
|
|||||||
dictionaryTriggered,
|
dictionaryTriggered,
|
||||||
dictionaryTarget,
|
dictionaryTarget,
|
||||||
dictionaryLogLevel,
|
dictionaryLogLevel,
|
||||||
|
statsTriggered,
|
||||||
|
statsCleanup,
|
||||||
|
statsCleanupVocab,
|
||||||
|
statsLogLevel,
|
||||||
doctorTriggered,
|
doctorTriggered,
|
||||||
doctorLogLevel,
|
doctorLogLevel,
|
||||||
texthookerTriggered,
|
texthookerTriggered,
|
||||||
|
|||||||
@@ -335,6 +335,55 @@ test('dictionary command forwards --dictionary and --dictionary-target to app co
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('stats command launches attached app flow and waits for response file', { timeout: 15000 }, () => {
|
||||||
|
withTempDir((root) => {
|
||||||
|
const homeDir = path.join(root, 'home');
|
||||||
|
const xdgConfigHome = path.join(root, 'xdg');
|
||||||
|
const appPath = path.join(root, 'fake-subminer.sh');
|
||||||
|
const capturePath = path.join(root, 'captured-args.txt');
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
`#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
response_path=""
|
||||||
|
prev=""
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$prev" = "--stats-response-path" ]; then
|
||||||
|
response_path="$arg"
|
||||||
|
prev=""
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
--stats-response-path=*)
|
||||||
|
response_path="\${arg#--stats-response-path=}"
|
||||||
|
;;
|
||||||
|
--stats-response-path)
|
||||||
|
prev="--stats-response-path"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
if [ -n "$SUBMINER_TEST_STATS_CAPTURE" ]; then
|
||||||
|
printf '%s\\n' "$@" > "$SUBMINER_TEST_STATS_CAPTURE"
|
||||||
|
fi
|
||||||
|
mkdir -p "$(dirname "$response_path")"
|
||||||
|
printf '%s' '{"ok":true,"url":"http://127.0.0.1:5175"}' > "$response_path"
|
||||||
|
exit 0
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...makeTestEnv(homeDir, xdgConfigHome),
|
||||||
|
SUBMINER_APPIMAGE_PATH: appPath,
|
||||||
|
SUBMINER_TEST_STATS_CAPTURE: capturePath,
|
||||||
|
};
|
||||||
|
const result = runLauncher(['stats', '--log-level', 'debug'], env);
|
||||||
|
|
||||||
|
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
||||||
|
assert.match(fs.readFileSync(capturePath, 'utf8'), /^--stats\n--stats-response-path\n.+\n--log-level\ndebug\n$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { runConfigCommand } from './commands/config-command.js';
|
|||||||
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
|
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
|
||||||
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
|
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
|
||||||
import { runDictionaryCommand } from './commands/dictionary-command.js';
|
import { runDictionaryCommand } from './commands/dictionary-command.js';
|
||||||
|
import { runStatsCommand } from './commands/stats-command.js';
|
||||||
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||||
|
|
||||||
@@ -95,6 +96,10 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (await runStatsCommand(appContext)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (await runJellyfinCommand(appContext)) {
|
if (await runJellyfinCommand(appContext)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
jellyfinPlay: false,
|
jellyfinPlay: false,
|
||||||
jellyfinDiscovery: false,
|
jellyfinDiscovery: false,
|
||||||
dictionary: false,
|
dictionary: false,
|
||||||
|
stats: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
configShow: false,
|
configShow: false,
|
||||||
|
|||||||
@@ -756,6 +756,37 @@ export function runAppCommandCaptureOutput(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function runAppCommandAttached(
|
||||||
|
appPath: string,
|
||||||
|
appArgs: string[],
|
||||||
|
logLevel: LogLevel,
|
||||||
|
label: string,
|
||||||
|
): Promise<number> {
|
||||||
|
if (maybeCaptureAppArgs(appArgs)) {
|
||||||
|
return Promise.resolve(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||||
|
log(
|
||||||
|
'debug',
|
||||||
|
logLevel,
|
||||||
|
`${label}: launching attached app with args: ${[target.command, ...target.args].join(' ')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const proc = spawn(target.command, target.args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
env: buildAppEnv(),
|
||||||
|
});
|
||||||
|
proc.once('error', (error) => {
|
||||||
|
reject(error);
|
||||||
|
});
|
||||||
|
proc.once('exit', (code) => {
|
||||||
|
resolve(code ?? 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function runAppCommandWithInheritLogged(
|
export function runAppCommandWithInheritLogged(
|
||||||
appPath: string,
|
appPath: string,
|
||||||
appArgs: string[],
|
appArgs: string[],
|
||||||
@@ -786,10 +817,24 @@ export function runAppCommandWithInheritLogged(
|
|||||||
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
|
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
|
||||||
const startArgs = ['--start'];
|
const startArgs = ['--start'];
|
||||||
if (logLevel !== 'info') startArgs.push('--log-level', logLevel);
|
if (logLevel !== 'info') startArgs.push('--log-level', logLevel);
|
||||||
if (maybeCaptureAppArgs(startArgs)) {
|
launchAppCommandDetached(appPath, startArgs, logLevel, 'start');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function launchAppCommandDetached(
|
||||||
|
appPath: string,
|
||||||
|
appArgs: string[],
|
||||||
|
logLevel: LogLevel,
|
||||||
|
label: string,
|
||||||
|
): void {
|
||||||
|
if (maybeCaptureAppArgs(appArgs)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const target = resolveAppSpawnTarget(appPath, startArgs);
|
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||||
|
log(
|
||||||
|
'debug',
|
||||||
|
logLevel,
|
||||||
|
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
|
||||||
|
);
|
||||||
const proc = spawn(target.command, target.args, {
|
const proc = spawn(target.command, target.args, {
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
detached: true,
|
detached: true,
|
||||||
|
|||||||
@@ -58,3 +58,26 @@ test('parseArgs maps dictionary command and log-level override', () => {
|
|||||||
assert.equal(parsed.dictionaryTarget, process.cwd());
|
assert.equal(parsed.dictionaryTarget, process.cwd());
|
||||||
assert.equal(parsed.logLevel, 'debug');
|
assert.equal(parsed.logLevel, 'debug');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs maps stats command and log-level override', () => {
|
||||||
|
const parsed = parseArgs(['stats', '--log-level', 'debug'], 'subminer', {});
|
||||||
|
|
||||||
|
assert.equal(parsed.stats, true);
|
||||||
|
assert.equal(parsed.logLevel, 'debug');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseArgs maps stats cleanup to vocab mode by default', () => {
|
||||||
|
const parsed = parseArgs(['stats', 'cleanup'], 'subminer', {});
|
||||||
|
|
||||||
|
assert.equal(parsed.stats, true);
|
||||||
|
assert.equal(parsed.statsCleanup, true);
|
||||||
|
assert.equal(parsed.statsCleanupVocab, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseArgs maps explicit stats cleanup vocab flag', () => {
|
||||||
|
const parsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {});
|
||||||
|
|
||||||
|
assert.equal(parsed.stats, true);
|
||||||
|
assert.equal(parsed.statsCleanup, true);
|
||||||
|
assert.equal(parsed.statsCleanupVocab, true);
|
||||||
|
});
|
||||||
|
|||||||
@@ -111,6 +111,9 @@ export interface Args {
|
|||||||
jellyfinPlay: boolean;
|
jellyfinPlay: boolean;
|
||||||
jellyfinDiscovery: boolean;
|
jellyfinDiscovery: boolean;
|
||||||
dictionary: boolean;
|
dictionary: boolean;
|
||||||
|
stats: boolean;
|
||||||
|
statsCleanup?: boolean;
|
||||||
|
statsCleanupVocab?: boolean;
|
||||||
dictionaryTarget?: string;
|
dictionaryTarget?: string;
|
||||||
doctor: boolean;
|
doctor: boolean;
|
||||||
configPath: boolean;
|
configPath: boolean;
|
||||||
|
|||||||
10
package.json
10
package.json
@@ -13,7 +13,9 @@
|
|||||||
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && electron dist/scripts/test-yomitan-parser.js",
|
"test-yomitan-parser:electron": "bun run build:yomitan && bun build scripts/test-yomitan-parser.ts --format=cjs --target=node --outfile dist/scripts/test-yomitan-parser.js --external electron && electron dist/scripts/test-yomitan-parser.js",
|
||||||
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
"build:yomitan": "bun scripts/build-yomitan.mjs",
|
||||||
"build:assets": "bun scripts/prepare-build-assets.mjs",
|
"build:assets": "bun scripts/prepare-build-assets.mjs",
|
||||||
"build": "bun run build:yomitan && tsc -p tsconfig.json && bun run build:renderer && bun run build:assets",
|
"build:stats": "cd stats && bun run build",
|
||||||
|
"dev:stats": "cd stats && bun run dev",
|
||||||
|
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:assets",
|
||||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||||
"changelog:build": "bun run scripts/build-changelog.ts build",
|
"changelog:build": "bun run scripts/build-changelog.ts build",
|
||||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||||
@@ -28,6 +30,7 @@
|
|||||||
"docs:build": "bun run --cwd docs-site docs:build",
|
"docs:build": "bun run --cwd docs-site docs:build",
|
||||||
"docs:preview": "bun run --cwd docs-site docs:preview",
|
"docs:preview": "bun run --cwd docs-site docs:preview",
|
||||||
"docs:test": "bun run --cwd docs-site test",
|
"docs:test": "bun run --cwd docs-site test",
|
||||||
|
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
||||||
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts",
|
"test:config:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/subtitle-style.test.ts src/config/resolve/jellyfin.test.ts src/config/definitions/domain-registry.test.ts src/generate-config-example.test.ts src/verify-config-example.test.ts",
|
||||||
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js",
|
"test:config:dist": "bun test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js dist/generate-config-example.test.js dist/verify-config-example.test.js",
|
||||||
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
"test:config:smoke:dist": "bun test dist/config/path-resolution.test.js",
|
||||||
@@ -54,7 +57,7 @@
|
|||||||
"test:launcher": "bun run test:launcher:src",
|
"test:launcher": "bun run test:launcher:src",
|
||||||
"test:core": "bun run test:core:src",
|
"test:core": "bun run test:core:src",
|
||||||
"test:subtitle": "bun run test:subtitle:src",
|
"test:subtitle": "bun run test:subtitle:src",
|
||||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts scripts/update-aur-package.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration/anki-connect-proxy.test.ts src/release-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/mkv-to-readme-video.test.ts scripts/update-aur-package.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||||
"start": "bun run build && electron . --start",
|
"start": "bun run build && electron . --start",
|
||||||
@@ -81,9 +84,11 @@
|
|||||||
"author": "",
|
"author": "",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.19.11",
|
||||||
"axios": "^1.13.5",
|
"axios": "^1.13.5",
|
||||||
"commander": "^14.0.3",
|
"commander": "^14.0.3",
|
||||||
"discord-rpc": "^4.0.1",
|
"discord-rpc": "^4.0.1",
|
||||||
|
"hono": "^4.12.7",
|
||||||
"jsonc-parser": "^3.3.1",
|
"jsonc-parser": "^3.3.1",
|
||||||
"libsql": "^0.5.22",
|
"libsql": "^0.5.22",
|
||||||
"ws": "^8.19.0"
|
"ws": "^8.19.0"
|
||||||
@@ -147,6 +152,7 @@
|
|||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"dist/**/*",
|
"dist/**/*",
|
||||||
|
"stats/dist/**/*",
|
||||||
"vendor/texthooker-ui/docs/**/*",
|
"vendor/texthooker-ui/docs/**/*",
|
||||||
"vendor/texthooker-ui/package.json",
|
"vendor/texthooker-ui/package.json",
|
||||||
"package.json",
|
"package.json",
|
||||||
|
|||||||
@@ -44,6 +44,9 @@ function M.create(ctx)
|
|||||||
mp.register_script_message(hover.HOVER_MESSAGE_NAME_LEGACY, function(payload_json)
|
mp.register_script_message(hover.HOVER_MESSAGE_NAME_LEGACY, function(payload_json)
|
||||||
hover.handle_hover_message(payload_json)
|
hover.handle_hover_message(payload_json)
|
||||||
end)
|
end)
|
||||||
|
mp.register_script_message("subminer-stats-toggle", function()
|
||||||
|
mp.osd_message("Stats: press ` (backtick) in overlay", 3)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ function M.create(ctx)
|
|||||||
"Open options",
|
"Open options",
|
||||||
"Restart overlay",
|
"Restart overlay",
|
||||||
"Check status",
|
"Check status",
|
||||||
|
"Stats",
|
||||||
}
|
}
|
||||||
|
|
||||||
local actions = {
|
local actions = {
|
||||||
@@ -53,6 +54,9 @@ function M.create(ctx)
|
|||||||
function()
|
function()
|
||||||
process.check_status()
|
process.check_status()
|
||||||
end,
|
end,
|
||||||
|
function()
|
||||||
|
mp.commandv("script-message", "subminer-stats-toggle")
|
||||||
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
input.select({
|
input.select({
|
||||||
|
|||||||
Reference in New Issue
Block a user