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:
2026-03-14 22:14:46 -07:00
parent 26fb5b4162
commit 950263bd66
20 changed files with 456 additions and 60 deletions

View File

@@ -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
View File

@@ -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

View File

@@ -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 -->

View File

@@ -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:

View File

@@ -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

View File

@@ -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=="],

View File

@@ -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.
} }

View File

@@ -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\)\./,
);
});

View 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);
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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');

View File

@@ -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;
} }

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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;

View File

@@ -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",

View File

@@ -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 {

View File

@@ -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({