diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3c5fdcd..a76e3fe 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,13 +27,16 @@ jobs: path: | ~/.bun/install/cache node_modules + stats/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: | ${{ runner.os }}-bun- - name: Install dependencies - run: bun install --frozen-lockfile + run: | + bun install --frozen-lockfile + cd stats && bun install --frozen-lockfile - name: Lint changelog fragments run: bun run changelog:lint @@ -49,6 +52,9 @@ jobs: - name: Verify generated config examples run: bun run verify:config-example + - name: Internal docs knowledge-base checks + run: bun run test:docs:kb + - name: Test suite (source) run: bun run test:fast diff --git a/.gitignore b/.gitignore index ef9bd4b..8da312e 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ tests/* !.agents/skills/subminer-scrum-master/SKILL.md favicon.png .claude/* +!stats/public/favicon.png diff --git a/AGENTS.md b/AGENTS.md index 88cfd70..a6112b9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,17 +1,29 @@ # 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 -- 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`. -- Install deps: `make deps` or `bun install` plus `(cd vendor/texthooker-ui && bun install --frozen-lockfile)`. -- Fast dev loop: `make dev-watch`. -- Full local run: `bun run dev`. -- Verbose Electron debug: `electron . --start --dev --log-level debug`. +- Init workspace: `git submodule update --init --recursive` +- Install deps: `make deps` or `bun install` plus `(cd vendor/texthooker-ui && bun install --frozen-lockfile)` +- Fast dev loop: `make dev-watch` +- Full local run: `bun run dev` +- Verbose Electron debug: `electron . --start --dev --log-level debug` ## 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: `bun run typecheck` `bun run test:fast` @@ -21,59 +33,37 @@ - If `docs-site/` changed, also run: `bun run docs:test` `bun run docs:build` -- Formatting: prefer `make pretty` and `bun run format:check:src`; use `bun run format` only intentionally. -- Keep verification observable; capture failing command + exact error in notes/handoff. +- Prefer `make pretty` and `bun run format:check:src` ## Change-Specific Checks -- Config/schema/defaults changes: run `bun run test:config`; if config template/defaults changed, run `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. -- Runtime-compat or compiled/dist-sensitive changes: run `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. +- Config/schema/defaults: `bun run test:config`; if template/defaults changed, `bun run generate:config-example` +- Launcher/plugin: `bun run test:launcher` or `bun run test:env` +- Runtime-compat / dist-sensitive: `bun run test:runtime:compat` +- Docs-only: `bun run docs:test`, then `bun run docs:build` -## Generated / Sensitive Files +## Sensitive Files -- Launcher source of truth: `launcher/*.ts`. -- Generated launcher artifact: `dist/launcher/subminer`; never hand-edit it. -- Repo-root `./subminer` is stale artifact path; do not revive/use it. -- `bun run build` rebuilds bundled Yomitan from `vendor/subminer-yomitan`; check submodules before debugging build failures. -- Avoid changing packaging/signing identifiers (`build.appId`, mac entitlements, signing-related settings) unless task explicitly requires it. +- Launcher source of truth: `launcher/*.ts` +- Generated launcher artifact: `dist/launcher/subminer`; never hand-edit it +- Repo-root `./subminer` is stale; do not revive it +- `bun run build` rebuilds bundled Yomitan from `vendor/subminer-yomitan` +- 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/). -- Update docs for new/breaking behavior; no ship with stale docs. -- Make sure [`docs-site/changelog.md`](./docs-site/changelog.md) is updated on each release. +- User-visible PRs need one fragment in `changes/*.md` +- 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 api repos/:owner/:repo/pulls//comments --paginate` -## PR Feedback +## Runtime Notes -- Active PR: `gh pr view --json number,title,url --jq '"PR #\\(.number): \\(.title)\\n\\(.url)"'`. -- PR comments: `gh pr view …` + `gh api …/comments --paginate`. -- Replies: cite fix + file/line; resolve threads only after fix lands. - -## Changelog - -- User-visible PRs: add one fragment in `changes/*.md`. -- Fragment format: - `type: added|changed|fixed|docs|internal` - `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. +- 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 +- Swift: use workspace helper/daemon; validate `swift build` + tests diff --git a/Makefile b/Makefile index f968142..b9d34f6 100644 --- a/Makefile +++ b/Makefile @@ -69,7 +69,7 @@ help: " generate-config Generate ~/.config/SubMiner/config.jsonc from centralized defaults" \ "" \ "Other targets:" \ - " deps Install JS dependencies (root + texthooker-ui)" \ + " deps Install JS dependencies (root + stats + texthooker-ui)" \ " uninstall-linux Remove Linux install artifacts" \ " uninstall-macos Remove macOS install artifacts" \ " uninstall-windows Remove Windows mpv plugin artifacts" \ @@ -104,6 +104,7 @@ print-dirs: deps: @$(MAKE) --no-print-directory ensure-bun @bun install + @cd stats && bun install --frozen-lockfile @cd vendor/texthooker-ui && bun install --frozen-lockfile ensure-bun: diff --git a/README.md b/README.md index d3a54b5..9507cb6 100644 --- a/README.md +++ b/README.md @@ -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 - **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 +- **Immersion stats** — Optional dashboard for watch time, sessions, trends, vocabulary, and mining throughput - **Subtitle tools** — Download from Jimaku, sync with alass/ffsubsync - **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 @@ -118,7 +119,7 @@ Windows builds use native window tracking and do not require the Linux composito ## 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 diff --git a/bun.lock b/bun.lock index f09ad53..cc04bb0 100644 --- a/bun.lock +++ b/bun.lock @@ -5,9 +5,11 @@ "": { "name": "subminer", "dependencies": { + "@hono/node-server": "^1.19.11", "axios": "^1.13.5", "commander": "^14.0.3", "discord-rpc": "^4.0.1", + "hono": "^4.12.7", "jsonc-parser": "^3.3.1", "libsql": "^0.5.22", "ws": "^8.19.0", @@ -96,6 +98,8 @@ "@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/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=="], + "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=="], "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], diff --git a/config.example.jsonc b/config.example.jsonc index ec1000d..007e5d2 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -503,5 +503,16 @@ "monthlyRollupsDays": 1825, // Monthly rollup retention window in days. "vacuumIntervalDays": 7 // Minimum days between VACUUM runs. } // 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. } diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 7d5598d..2d126cb 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -7,6 +7,7 @@ import { runConfigCommand } from './config-command.js'; import { runDictionaryCommand } from './dictionary-command.js'; import { runDoctorCommand } from './doctor-command.js'; import { runMpvPreAppCommand } from './mpv-command.js'; +import { runStatsCommand } from './stats-command.js'; class ExitSignal extends Error { code: number; @@ -128,3 +129,98 @@ test('dictionary command throws if app handoff unexpectedly returns', () => { /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\)\./, + ); +}); diff --git a/launcher/commands/stats-command.ts b/launcher/commands/stats-command.ts new file mode 100644 index 0000000..7bd7397 --- /dev/null +++ b/launcher/commands/stats-command.ts @@ -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; + waitForStatsResponse: (responsePath: string) => Promise; + 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 { + 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); + } +} diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 44a34b9..66b2358 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -122,6 +122,9 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): jellyfinPlay: false, jellyfinDiscovery: false, dictionary: false, + stats: false, + statsCleanup: false, + statsCleanupVocab: false, doctor: false, configPath: false, configShow: false, @@ -188,6 +191,9 @@ export function applyRootOptionsToArgs( export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void { 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) { parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget); } @@ -256,6 +262,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations if (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.texthookerLogLevel) diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index 126d8ef..b4191c3 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -40,6 +40,10 @@ export interface CliInvocations { dictionaryTriggered: boolean; dictionaryTarget: string | null; dictionaryLogLevel: string | null; + statsTriggered: boolean; + statsCleanup: boolean; + statsCleanupVocab: boolean; + statsLogLevel: string | null; doctorTriggered: boolean; doctorLogLevel: string | null; texthookerTriggered: boolean; @@ -87,6 +91,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n 'mpv', 'dictionary', 'dict', + 'stats', 'texthooker', 'app', 'bin', @@ -137,6 +142,10 @@ export function parseCliPrograms( let dictionaryTriggered = false; let dictionaryTarget: 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 texthookerLogLevel: string | null = null; let doctorTriggered = false; @@ -241,6 +250,21 @@ export function parseCliPrograms( 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 ', 'Log level') + .action((action: string | undefined, options: Record) => { + statsTriggered = true; + if ((action || '').toLowerCase() === 'cleanup') { + statsCleanup = true; + statsCleanupVocab = options.vocab === true; + } + statsLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; + }); + commandProgram .command('doctor') .description('Run dependency and environment checks') @@ -319,6 +343,10 @@ export function parseCliPrograms( dictionaryTriggered, dictionaryTarget, dictionaryLogLevel, + statsTriggered, + statsCleanup, + statsCleanupVocab, + statsLogLevel, doctorTriggered, doctorLogLevel, texthookerTriggered, diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 236ba40..7beda39 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -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', () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); diff --git a/launcher/main.ts b/launcher/main.ts index af5aa56..7c15f07 100644 --- a/launcher/main.ts +++ b/launcher/main.ts @@ -14,6 +14,7 @@ import { runConfigCommand } from './commands/config-command.js'; import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js'; import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js'; import { runDictionaryCommand } from './commands/dictionary-command.js'; +import { runStatsCommand } from './commands/stats-command.js'; import { runJellyfinCommand } from './commands/jellyfin-command.js'; import { runPlaybackCommand } from './commands/playback-command.js'; @@ -95,6 +96,10 @@ async function main(): Promise { return; } + if (await runStatsCommand(appContext)) { + return; + } + if (await runJellyfinCommand(appContext)) { return; } diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 67877c8..5f56efb 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -133,6 +133,7 @@ function makeArgs(overrides: Partial = {}): Args { jellyfinPlay: false, jellyfinDiscovery: false, dictionary: false, + stats: false, doctor: false, configPath: false, configShow: false, diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 5beee3b..aad413c 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -756,6 +756,37 @@ export function runAppCommandCaptureOutput( }; } +export function runAppCommandAttached( + appPath: string, + appArgs: string[], + logLevel: LogLevel, + label: string, +): Promise { + 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( appPath: string, appArgs: string[], @@ -786,10 +817,24 @@ export function runAppCommandWithInheritLogged( export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void { const startArgs = ['--start']; 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; } - 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, { stdio: 'ignore', detached: true, diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index 8fb156e..799fadd 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -58,3 +58,26 @@ test('parseArgs maps dictionary command and log-level override', () => { assert.equal(parsed.dictionaryTarget, process.cwd()); 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); +}); diff --git a/launcher/types.ts b/launcher/types.ts index 743ed73..a580e83 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -111,6 +111,9 @@ export interface Args { jellyfinPlay: boolean; jellyfinDiscovery: boolean; dictionary: boolean; + stats: boolean; + statsCleanup?: boolean; + statsCleanupVocab?: boolean; dictionaryTarget?: string; doctor: boolean; configPath: boolean; diff --git a/package.json b/package.json index ccea6a1..2dc66e3 100644 --- a/package.json +++ b/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", "build:yomitan": "bun scripts/build-yomitan.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", "changelog:build": "bun run scripts/build-changelog.ts build", "changelog:check": "bun run scripts/build-changelog.ts check", @@ -28,6 +30,7 @@ "docs:build": "bun run --cwd docs-site docs:build", "docs:preview": "bun run --cwd docs-site docs:preview", "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: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", @@ -54,7 +57,7 @@ "test:launcher": "bun run test:launcher:src", "test:core": "bun run test:core: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", "verify:config-example": "bun run src/verify-config-example.ts", "start": "bun run build && electron . --start", @@ -81,9 +84,11 @@ "author": "", "license": "GPL-3.0-or-later", "dependencies": { + "@hono/node-server": "^1.19.11", "axios": "^1.13.5", "commander": "^14.0.3", "discord-rpc": "^4.0.1", + "hono": "^4.12.7", "jsonc-parser": "^3.3.1", "libsql": "^0.5.22", "ws": "^8.19.0" @@ -147,6 +152,7 @@ }, "files": [ "dist/**/*", + "stats/dist/**/*", "vendor/texthooker-ui/docs/**/*", "vendor/texthooker-ui/package.json", "package.json", diff --git a/plugin/subminer/messages.lua b/plugin/subminer/messages.lua index ca93e23..44c5ade 100644 --- a/plugin/subminer/messages.lua +++ b/plugin/subminer/messages.lua @@ -44,6 +44,9 @@ function M.create(ctx) mp.register_script_message(hover.HOVER_MESSAGE_NAME_LEGACY, function(payload_json) hover.handle_hover_message(payload_json) end) + mp.register_script_message("subminer-stats-toggle", function() + mp.osd_message("Stats: press ` (backtick) in overlay", 3) + end) end return { diff --git a/plugin/subminer/ui.lua b/plugin/subminer/ui.lua index 949cbb0..f4ff0e4 100644 --- a/plugin/subminer/ui.lua +++ b/plugin/subminer/ui.lua @@ -32,6 +32,7 @@ function M.create(ctx) "Open options", "Restart overlay", "Check status", + "Stats", } local actions = { @@ -53,6 +54,9 @@ function M.create(ctx) function() process.check_status() end, + function() + mp.commandv("script-message", "subminer-stats-toggle") + end, } input.select({