Compare commits

..

6 Commits

Author SHA1 Message Date
09d8b52fbf docs(changelog): clarify windows setup streamlining 2026-04-03 22:39:45 -07:00
0edd566904 chore(release): prepare v0.11.0 2026-04-03 22:32:57 -07:00
6eb1b0f197 chore(config): update fresh-install defaults 2026-04-03 22:22:46 -07:00
e4137d9760 fix: stabilize failing test regressions across src and launcher lanes
- Fix log pruning cutoff math using BigInt `mtimeNs` to avoid Bun mtime precision loss
- Fix stats CLI lifetime rebuild timestamp units in tests and log output; add `formatLoggedNumber` guard
- Use `performance.now()` in subtitle sidebar auto-follow to isolate from test time injection
- Harden renderer global cleanup tests with descriptor save/restore instead of assuming globals absent
- Isolate `node:http` fallback in stats-server test with stub and assertion
- Fix AniSkip fallback title: cleaned basename beats generic parent dirs; episode-only filenames still prefer series directory
2026-04-03 22:04:52 -07:00
864f4124ae chore(deps): patch high severity audit findings 2026-04-03 21:53:34 -07:00
7514985feb [codex] Make Windows mpv shortcut self-contained (#40) 2026-04-03 21:35:18 -07:00
31 changed files with 372 additions and 89 deletions

View File

@@ -9,6 +9,9 @@
### Changed
- Setup: Made mpv plugin installation mandatory in the first-run setup flow, removed the skip path, and kept Finish disabled until the plugin is installed.
- Setup: Clarified that the mpv plugin requirement applies to setup on every platform, while the optional `SubMiner mpv` shortcut remains the recommended Windows playback entry point.
- Launcher: Streamlined Windows setup and config by making the `SubMiner mpv` shortcut self-contained and keeping `mpv.executablePath` as the simple fallback when `mpv.exe` is not on `PATH`.
- Overlay: Changed fresh-install default config to keep texthooker and stats from auto-opening browser tabs.
- Overlay: Changed fresh-install default config to enable AnkiConnect, Discord Rich Presence, subtitle-sidebar, and Yomitan-popup auto-pause by default, while disabling controller input by default.
### Fixed
- Main: Resolve the YouTube playback socket path lazily so startup honors CLI and config overrides.
@@ -36,6 +39,8 @@
- Launcher: Kept the first-run setup window from navigating away on unexpected URLs.
- Launcher: Made Windows mpv honor an explicitly configured executable path instead of silently falling back to PATH.
- Launcher: Hardened `--launch-mpv` parsing and Windows binary resolution so valueless flags do not swallow media targets and symlinked launcher installs do not short-circuit PATH lookup.
- Launcher: Fixed first-run setup blocking playback on macOS when the SubMiner mpv plugin was already installed at the canonical `~/.config/mpv` path.
- Launcher: Fixed setup gating so stale cancelled setup state no longer prevents playback when the canonical mpv plugin entrypoint already exists.
- Playback: Prevented stale async playlist-browser subtitle rearm callbacks from overriding newer subtitle selections during rapid file changes.
### Docs
@@ -46,6 +51,7 @@
### Internal
- Release: Retried AUR clone and push operations in the tagged release workflow.
- Release: Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.
- Release: Updated Electron to 39.8.6 and pinned patched transitive build dependencies to clear the reported high-severity audit findings.
## v0.10.0 (2026-03-29)

View File

@@ -0,0 +1,59 @@
---
id: TASK-274
title: Stabilize current failing test regressions
status: Done
assignee:
- codex
created_date: '2026-04-04 04:40'
updated_date: '2026-04-04 05:01'
labels: []
dependencies: []
documentation:
- docs/workflow/verification.md
- docs/architecture/README.md
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix the current src/test failures across stats CLI lifetime rebuild handling, immersion tracker lifetime rebuild idempotency, renderer test environment cleanup helpers, subtitle sidebar auto-follow behavior, and log retention pruning so the maintained test lanes pass again.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Stats CLI lifetime rebuild behavior passes the current regression coverage.
- [x] #2 Immersion tracker lifetime rebuild backfill remains idempotent under the existing runtime test.
- [x] #3 Renderer modal test helpers restore injected globals exactly to prior state.
- [x] #4 Log pruning removes files older than the configured retention window deterministically.
- [x] #5 Relevant targeted test files pass after the fixes.
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Reproduce the failing specs in isolation to separate deterministic regressions from suite-order pollution.
2. Fix source or test-helper logic for the three isolated failures: log retention cutoff, stats CLI lifetime rebuild timestamp handling, and subtitle sidebar initial jump behavior.
3. Harden renderer modal cleanup regressions so tests verify descriptor restoration without assuming global window/document start absent.
4. Re-run the targeted failing files, then the required verification gate for the touched areas and record results.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Targeted regressions fixed in log pruning, stats CLI lifetime logging/tests, subtitle sidebar auto-follow timing, and renderer global cleanup test isolation.
Verification: `bun test src/main/runtime/stats-cli-command.test.ts src/shared/log-files.test.ts src/renderer/modals/playlist-browser.test.ts src/renderer/modals/youtube-track-picker.test.ts src/renderer/modals/subtitle-sidebar.test.ts` passed.
Verification: `bun run test:src` still exits non-zero because of unrelated existing errors in `src/core/services/anilist/anilist-token-store.test.ts` (`Bun.serve is not a function`) plus one remaining non-task failure elsewhere; the originally reported regressions are green in the maintained lane.
User reported `test:full` still failing after the first regression pass. Reopened to clear the remaining `test:src` fail plus the existing unhandled test errors before handoff.
Verified final gate with `bun run test:launcher:unit:src` and `bun run test:full`; both pass after fixing the launcher AniSkip fallback title regression and the earlier src-lane regressions.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Stabilized the failing test regressions across source and launcher lanes. Fixed log pruning cutoff math under Bun BigInt mtimes, subtitle sidebar auto-follow timing, renderer global cleanup test isolation, stats CLI lifetime rebuild logging/tests, stats-server node:http fallback isolation, and launcher AniSkip fallback title resolution so basename titles beat generic parent directories while episode-only filenames still prefer the series directory. Verification passed with `bun test launcher/aniskip-metadata.test.ts`, `bun run test:launcher:unit:src`, and `bun run test:full`.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -0,0 +1,56 @@
---
id: TASK-275
title: Patch high-severity audit findings with minimal dependency changes
status: Done
assignee:
- codex
created_date: '2026-04-04 04:45'
updated_date: '2026-04-04 04:50'
labels:
- security
- dependencies
dependencies: []
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Update SubMiner's direct Electron runtime and vulnerable build-time transitive dependencies to patched versions using the smallest safe version moves. Keep electron-builder on the current pinned line unless verification shows a blocker. Verify that bun audit no longer reports the current high-severity findings and that the standard project gate still passes.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [x] #1 Electron is updated to a patched supported release on the current supported line with no broader dependency refresh
- [x] #2 Vulnerable transitive packages @xmldom/xmldom, lodash, and picomatch resolve to patched versions via targeted dependency changes
- [x] #3 `bun audit --audit-level high` no longer reports the currently listed high-severity findings
- [x] #4 The default handoff verification gate passes, or any failure is documented with the exact command and error output
- [x] #5 Any dependency or lockfile changes remain minimal and do not change the pinned electron-builder line unless required
<!-- AC:END -->
## Implementation Plan
<!-- SECTION:PLAN:BEGIN -->
1. Update package.json with the smallest set of dependency changes: bump electron from ^37.10.3 to 39.8.6 and add overrides for @xmldom/xmldom 0.8.12, lodash 4.18.0, and picomatch 4.0.4 while leaving electron-builder pinned at 26.8.2.
2. Refresh bun.lock with a lockfile-only install/update and confirm the resolved versions for electron, @xmldom/xmldom, lodash, and picomatch.
3. Run bun audit --audit-level high and verify the current high-severity findings are gone.
4. Run the default verification gate: bun run typecheck, bun run test:fast, bun run test:env, bun run build, bun run test:smoke:dist.
5. If any verification step fails, capture the exact failing command and error, assess whether it is caused by the dependency updates, and stop without broadening scope.
<!-- SECTION:PLAN:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Updated package.json to pin electron 39.8.6 and add overrides for @xmldom/xmldom 0.8.12, lodash 4.18.0, and picomatch 4.0.4 while keeping electron-builder pinned at 26.8.2.
Refreshed bun.lock with bun install and confirmed the patched versions resolved in the lockfile.
Verification passed: bun audit --audit-level high, bun run typecheck, bun run test:fast, bun run test:env, bun run build, bun run test:smoke:dist.
Added changelog fragment changes/patch-audit-dependencies.md for the security/dependency maintenance update. No internal docs or docs-site updates were needed because the change does not alter user-facing behavior, configuration, or workflows.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Cleared the reported high-severity audit findings with minimal dependency churn by pinning electron to 39.8.6 and overriding @xmldom/xmldom, lodash, and picomatch to patched versions. Kept electron-builder on 26.8.2. bun audit is clean and the full default handoff gate passed: typecheck, fast tests, env tests, build, and dist smoke tests.
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -18,7 +18,7 @@
"devDependencies": {
"@types/node": "^25.3.0",
"@types/ws": "^8.18.1",
"electron": "^37.10.3",
"electron": "39.8.6",
"electron-builder": "26.8.2",
"esbuild": "^0.25.12",
"prettier": "^3.8.1",
@@ -27,9 +27,12 @@
},
},
"overrides": {
"@xmldom/xmldom": "0.8.12",
"app-builder-lib": "26.8.2",
"electron-builder-squirrel-windows": "26.8.2",
"lodash": "4.18.0",
"minimatch": "10.2.3",
"picomatch": "4.0.4",
"tar": "7.5.11",
},
"packages": {
@@ -185,7 +188,7 @@
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
"abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="],
@@ -321,7 +324,7 @@
"ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="],
"electron": ["electron@37.10.3", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-3IjCGSjQmH50IbW2PFveaTzK+KwcFX9PEhE7KXb9v5IT8cLAiryAN7qezm/XzODhDRlLu0xKG1j8xWBtZ/bx/g=="],
"electron": ["electron@39.8.6", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^22.7.7", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-uWX6Jh5LmwL13VwOSKBjebI+ck+03GOwc8V2Sgbmr9pJVJ/cHfli/PkjXuRDr+hq+SLHQuT9mGHSIfScebApRA=="],
"electron-builder": ["electron-builder@26.8.2", "", { "dependencies": { "app-builder-lib": "26.8.2", "builder-util": "26.8.1", "builder-util-runtime": "9.5.1", "chalk": "^4.1.2", "ci-info": "^4.2.0", "dmg-builder": "26.8.2", "fs-extra": "^10.1.0", "lazy-val": "^1.0.5", "simple-update-notifier": "2.0.0", "yargs": "^17.6.2" }, "bin": { "electron-builder": "cli.js", "install-app-deps": "install-app-deps.js" } }, "sha512-ieiiXPdgH3qrG6lcvy2mtnI5iEmAopmLuVRMSJ5j40weU0tgpNx0OAk9J5X5nnO0j9+KIkxHzwFZVUDk1U3aGw=="],
@@ -479,7 +482,7 @@
"libsql": ["libsql@0.5.28", "", { "dependencies": { "@neon-rs/load": "^0.0.4", "detect-libc": "2.0.2" }, "optionalDependencies": { "@libsql/darwin-arm64": "0.5.28", "@libsql/darwin-x64": "0.5.28", "@libsql/linux-arm-gnueabihf": "0.5.28", "@libsql/linux-arm-musleabihf": "0.5.28", "@libsql/linux-arm64-gnu": "0.5.28", "@libsql/linux-arm64-musl": "0.5.28", "@libsql/linux-x64-gnu": "0.5.28", "@libsql/linux-x64-musl": "0.5.28", "@libsql/win32-x64-msvc": "0.5.28" }, "os": [ "linux", "win32", "darwin", ], "cpu": [ "arm", "x64", "arm64", ] }, "sha512-wKqx9FgtPcKHdPfR/Kfm0gejsnbuf8zV+ESPmltFvsq5uXwdeN9fsWn611DmqrdXj1e94NkARcMA2f1syiAqOg=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash": ["lodash@4.18.0", "", {}, "sha512-l1mfj2atMqndAHI3ls7XqPxEjV2J9ZkcNyHpoZA3r2T1LLwDB69jgkMWh71YKwhBbK0G2f4WSn05ahmQXVxupA=="],
"log-symbols": ["log-symbols@4.1.0", "", { "dependencies": { "chalk": "^4.1.0", "is-unicode-supported": "^0.1.0" } }, "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg=="],
@@ -569,7 +572,7 @@
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
"plist": ["plist@3.1.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.8", "base64-js": "^1.5.1", "xmlbuilder": "^15.1.1" } }, "sha512-uysumyrvkUX0rX/dEVqt8gC3sTBzd4zoWfLeS29nb53imdaXVvLINYXTI2GNqzaMuvacNx4uJQ8+b3zXR0pkgQ=="],

View File

@@ -1,5 +0,0 @@
type: fixed
area: launcher
Fixed first-run setup blocking playback on macOS when the SubMiner mpv plugin was already installed at the canonical `~/.config/mpv` path.
Fixed launcher setup gating so stale cancelled setup state no longer prevents playback when the canonical mpv plugin entrypoint already exists.

View File

@@ -18,7 +18,7 @@
// ==========================================
"texthooker": {
"launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false
"openBrowser": true // Open browser setting. Values: true | false
"openBrowser": false // Open browser setting. Values: true | false
}, // Configure texthooker startup launch and browser opening behavior.
// ==========================================
@@ -58,7 +58,7 @@
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
// ==========================================
"controller": {
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
"enabled": false, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
"preferredGamepadId": "", // Preferred controller id saved from the controller config modal.
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
@@ -225,7 +225,7 @@
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": false, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
@@ -290,7 +290,7 @@
// Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.
// ==========================================
"subtitleSidebar": {
"enabled": false, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
"enabled": true, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
@@ -330,7 +330,7 @@
// Most other AnkiConnect settings still require restart.
// ==========================================
"ankiConnect": {
"enabled": false, // Enable AnkiConnect integration. Values: true | false
"enabled": true, // Enable AnkiConnect integration. Values: true | false
"url": "http://127.0.0.1:8765", // Url setting.
"pollingRate": 3000, // Polling interval in milliseconds.
"proxy": {
@@ -506,7 +506,7 @@
// Uses official SubMiner Discord app assets for polished card visuals.
// ==========================================
"discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
@@ -553,6 +553,6 @@
"markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry.
"serverPort": 6969, // Port for the stats HTTP server.
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
"autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
"autoOpenBrowser": false // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
} // Local immersion stats dashboard served on localhost and available as an in-app overlay.
}

View File

@@ -252,7 +252,7 @@ See `config.example.jsonc` for detailed configuration options.
{
"texthooker": {
"launchAtStartup": true,
"openBrowser": true
"openBrowser": false
}
}
```
@@ -260,7 +260,7 @@ See `config.example.jsonc` for detailed configuration options.
| Option | Values | Description |
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------ |
| `launchAtStartup`| `true`, `false` | Start texthooker automatically with SubMiner startup (default: `true`) |
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `true`) |
| `openBrowser` | `true`, `false` | Open browser tab when texthooker starts (default: `false`) |
## Subtitle Display
@@ -307,7 +307,7 @@ See `config.example.jsonc` for detailed configuration options.
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`false` by default). |
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: semi-transparent dark) |
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
@@ -355,7 +355,7 @@ Configure the parsed-subtitle sidebar modal.
```json
{
"subtitleSidebar": {
"enabled": false,
"enabled": true,
"autoOpen": false,
"layout": "overlay",
"toggleKey": "Backslash",
@@ -369,7 +369,7 @@ Configure the parsed-subtitle sidebar modal.
| Option | Values | Description |
| --------------------------- | ---------------- | -------------------------------------------------------------------------------- |
| `enabled` | boolean | Enable subtitle sidebar support (`false` by default) |
| `enabled` | boolean | Enable subtitle sidebar support (`true` by default) |
| `autoOpen` | boolean | Open sidebar automatically on overlay startup (`false` by default) |
| `layout` | string | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space to mimic browser-like layout |
| `toggleKey` | string | `KeyboardEvent.code` used to open/close the sidebar (default: `"Backslash"`) |
@@ -848,7 +848,7 @@ This example is intentionally compact. The option table below documents availabl
| Option | Values | Description |
| --------------------------------------- | --------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `false`) |
| `enabled` | `true`, `false` | Enable AnkiConnect integration (default: `true`) |
| `url` | string (URL) | AnkiConnect API URL (default: `http://127.0.0.1:8765`) |
| `pollingRate` | number (ms) | How often to check for new cards in polling mode (default: `3000`; ignored for direct proxy `addNote`/`addNotes` updates) |
| `proxy.enabled` | `true`, `false` | Enable local AnkiConnect-compatible proxy for push-based auto-enrichment (default: `true`) |
@@ -1197,7 +1197,7 @@ Jellyfin remote auto-connect runs only when all three are `true`: `jellyfin.enab
### Discord Rich Presence
Discord Rich Presence is optional and disabled by default. When enabled, SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer.
Discord Rich Presence is enabled by default. SubMiner publishes a polished activity card that reflects current media title, playback state, and session timer unless you turn it off.
```json
{
@@ -1212,14 +1212,14 @@ Discord Rich Presence is optional and disabled by default. When enabled, SubMine
| Option | Values | Description |
| ------------------ | ------------------------------------------------- | ---------------------------------------------------------- |
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `false`) |
| `enabled` | `true`, `false` | Enable Discord Rich Presence updates (default: `true`) |
| `presenceStyle` | `"default"`, `"meme"`, `"japanese"`, `"minimal"` | Card text preset (default: `"default"`) |
| `updateIntervalMs` | number | Minimum interval between activity updates in milliseconds |
| `debounceMs` | number | Debounce window for bursty playback events in milliseconds |
Setup steps:
1. Set `discordPresence.enabled` to `true`.
1. Leave `discordPresence.enabled` as `true` or set it explicitly if you previously disabled it.
2. Optionally set `discordPresence.presenceStyle` to choose a card text preset.
3. Restart SubMiner.
@@ -1323,7 +1323,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
"toggleKey": "Backquote",
"serverPort": 6969,
"autoStartServer": true,
"autoOpenBrowser": true
"autoOpenBrowser": false
}
}
```
@@ -1333,7 +1333,7 @@ Configure the local stats UI served from SubMiner and the in-app stats overlay t
| `toggleKey` | Electron key code | Overlay-local key code used to toggle the stats overlay. Default `Backquote`. |
| `serverPort` | integer | Localhost port for the browser stats UI. Default `6969`. |
| `autoStartServer` | `true`, `false` | Start the local stats HTTP server automatically once immersion tracking is active. Default `true`. |
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `true`. |
| `autoOpenBrowser` | `true`, `false` | When `subminer stats` starts the server on demand, also open the dashboard in your default browser. Default `false`. |
Usage notes:

View File

@@ -72,7 +72,7 @@ Stats server config lives under `stats`:
"toggleKey": "Backquote",
"serverPort": 6969,
"autoStartServer": true,
"autoOpenBrowser": true
"autoOpenBrowser": false
}
}
```

View File

@@ -51,7 +51,7 @@ The visible overlay renders subtitles as tokenized hoverable word spans. Each wo
- Word-level hover targets for Yomitan lookup
- Auto pause/resume on subtitle hover (enabled by default via `subtitleStyle.autoPauseVideoOnHover`)
- Optional pause while the Yomitan popup is open (`subtitleStyle.autoPauseVideoOnYomitanPopup`)
- Auto pause/resume while the Yomitan popup is open (enabled by default via `subtitleStyle.autoPauseVideoOnYomitanPopup`)
- Right-click to pause/resume
- Right-click + drag to reposition subtitles
- Modal dialogs for Jimaku search, field grouping, subsync, and runtime options

View File

@@ -18,7 +18,7 @@
// ==========================================
"texthooker": {
"launchAtStartup": true, // Launch texthooker server automatically when SubMiner starts. Values: true | false
"openBrowser": true // Open browser setting. Values: true | false
"openBrowser": false // Open browser setting. Values: true | false
}, // Configure texthooker startup launch and browser opening behavior.
// ==========================================
@@ -58,7 +58,7 @@
// Override controller.buttonIndices when your pad reports non-standard raw button numbers.
// ==========================================
"controller": {
"enabled": true, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
"enabled": false, // Enable overlay controller support through the Chrome Gamepad API. Values: true | false
"preferredGamepadId": "", // Preferred controller id saved from the controller config modal.
"preferredGamepadLabel": "", // Preferred controller display label saved for diagnostics.
"smoothScroll": true, // Use smooth scrolling for controller-driven popup scroll input. Values: true | false
@@ -225,7 +225,7 @@
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
"autoPauseVideoOnYomitanPopup": false, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
@@ -290,7 +290,7 @@
// Hot-reload: subtitle sidebar changes apply live without restarting SubMiner.
// ==========================================
"subtitleSidebar": {
"enabled": false, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
"enabled": true, // Enable the subtitle sidebar feature for parsed subtitle sources. Values: true | false
"autoOpen": false, // Automatically open the subtitle sidebar once during overlay startup. Values: true | false
"layout": "overlay", // Render the subtitle sidebar as a floating overlay or reserve space inside mpv. Values: overlay | embedded
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
@@ -330,7 +330,7 @@
// Most other AnkiConnect settings still require restart.
// ==========================================
"ankiConnect": {
"enabled": false, // Enable AnkiConnect integration. Values: true | false
"enabled": true, // Enable AnkiConnect integration. Values: true | false
"url": "http://127.0.0.1:8765", // Url setting.
"pollingRate": 3000, // Polling interval in milliseconds.
"proxy": {
@@ -506,7 +506,7 @@
// Uses official SubMiner Discord app assets for polished card visuals.
// ==========================================
"discordPresence": {
"enabled": false, // Enable optional Discord Rich Presence updates. Values: true | false
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
@@ -553,6 +553,6 @@
"markWatchedKey": "KeyW", // Key code to mark the current video as watched and advance to the next playlist entry.
"serverPort": 6969, // Port for the stats HTTP server.
"autoStartServer": true, // Automatically start the stats server on launch. Values: true | false
"autoOpenBrowser": true // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
"autoOpenBrowser": false // Automatically open the stats dashboard in a browser when the server starts. Values: true | false
} // Local immersion stats dashboard served on localhost and available as an in-app overlay.
}

View File

@@ -2,7 +2,7 @@
The subtitle sidebar displays the full parsed cue list for the active subtitle file as a scrollable panel alongside mpv. It lets you review past and upcoming lines, click any cue to seek directly to that moment, and follow along without depending on the transient overlay subtitles.
The sidebar is opt-in and disabled by default. Enable it under `subtitleSidebar.enabled` in your config.
The sidebar is enabled by default. Set `subtitleSidebar.enabled` to `false` if you want to turn it off.
## How It Works
@@ -29,7 +29,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
```json
{
"subtitleSidebar": {
"enabled": false,
"enabled": true,
"autoOpen": false,
"layout": "overlay",
"toggleKey": "Backslash",
@@ -43,7 +43,7 @@ Enable and configure the sidebar under `subtitleSidebar` in your config file:
| Option | Type | Default | Description |
| --------------------------- | ------- | ------------ | -------------------------------------------------------------------------------------------------- |
| `enabled` | boolean | `false` | Enable subtitle sidebar support |
| `enabled` | boolean | `true` | Enable subtitle sidebar support |
| `autoOpen` | boolean | `false` | Open the sidebar automatically on overlay startup |
| `layout` | string | `"overlay"` | `"overlay"` floats over mpv; `"embedded"` reserves right-side player space |
| `toggleKey` | string | `"Backslash"` | `KeyboardEvent.code` for the toggle shortcut |

View File

@@ -297,7 +297,7 @@ See [Keyboard Shortcuts](/shortcuts) for the full reference, including mining sh
Useful overlay-local default keybinding: `Ctrl+Alt+P` opens the playlist browser for the current video's parent directory and the live mpv queue so you can append, reorder, remove, or jump between episodes without leaving playback.
Hovering over subtitle text pauses mpv by default; leaving resumes it. Disable with `subtitleStyle.autoPauseVideoOnHover: false`. To also pause while the Yomitan popup is open, set `subtitleStyle.autoPauseVideoOnYomitanPopup: true`.
Hovering over subtitle text pauses mpv by default; leaving resumes it. Yomitan popups also pause playback by default. Set `subtitleStyle.autoPauseVideoOnHover: false` or `subtitleStyle.autoPauseVideoOnYomitanPopup: false` to disable either behavior.
### Drag-and-Drop

View File

@@ -34,7 +34,7 @@ SubMiner's integration ports are configured in `config.jsonc`.
},
"texthooker": {
"launchAtStartup": true,
"openBrowser": true
"openBrowser": false
}
}
```

View File

@@ -125,6 +125,12 @@ function titleOverlapScore(expectedTitle: string, candidateTitle: string): numbe
if (!expected || !candidate) return 0;
if (candidate.includes(expected)) return 120;
if (
candidate.split(' ').length >= 2 &&
` ${expected} `.includes(` ${candidate} `)
) {
return 90;
}
const expectedTokens = tokenizeMatchWords(expectedTitle);
if (expectedTokens.length === 0) return 0;
@@ -339,6 +345,12 @@ function isSeasonDirectoryName(value: string): boolean {
return /^(?:season|s)[\s._-]*\d{1,2}$/i.test(value.trim());
}
function isEpisodeOnlyBaseName(value: string): boolean {
return /^(?:[Ss]\d{1,2}[Ee]\d{1,3}|[Ee][Pp]?[\s._-]*\d{1,3}|\d{1,3})(?:$|[\s._-])/.test(
value.trim(),
);
}
function inferTitleFromPath(mediaPath: string): string {
const directory = path.dirname(mediaPath);
const segments = directory.split(/[\\/]+/).filter((segment) => segment.length > 0);
@@ -445,8 +457,11 @@ export function inferAniSkipMetadataForFile(
}
const baseName = path.basename(mediaPath, path.extname(mediaPath));
const cleanedBaseName = cleanupTitle(baseName);
const pathTitle = inferTitleFromPath(mediaPath);
const fallbackTitle = pathTitle || cleanupTitle(baseName) || baseName;
const fallbackTitle = isEpisodeOnlyBaseName(baseName)
? pathTitle || cleanedBaseName || baseName
: cleanedBaseName || pathTitle || baseName;
return {
title: fallbackTitle,
season: detectSeasonFromNameOrDir(mediaPath),

View File

@@ -81,9 +81,12 @@
"build:win:unsigned": "bun run build && node scripts/build-win-unsigned.mjs"
},
"overrides": {
"@xmldom/xmldom": "0.8.12",
"app-builder-lib": "26.8.2",
"electron-builder-squirrel-windows": "26.8.2",
"lodash": "4.18.0",
"minimatch": "10.2.3",
"picomatch": "4.0.4",
"tar": "7.5.11"
},
"keywords": [
@@ -112,7 +115,7 @@
"devDependencies": {
"@types/node": "^25.3.0",
"@types/ws": "^8.18.1",
"electron": "^37.10.3",
"electron": "39.8.6",
"electron-builder": "26.8.2",
"esbuild": "^0.25.12",
"prettier": "^3.8.1",

View File

@@ -37,6 +37,9 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
assert.equal(config.ai.enabled, false);
assert.equal(config.ai.apiKeyCommand, '');
assert.equal(config.texthooker.openBrowser, false);
assert.equal(config.controller.enabled, false);
assert.equal(config.ankiConnect.enabled, true);
assert.deepEqual(config.ankiConnect.ai, {
enabled: false,
model: '',
@@ -47,12 +50,13 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
assert.equal(config.discordPresence.enabled, false);
assert.equal(config.discordPresence.enabled, true);
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
assert.equal(config.subtitleStyle.preserveLineBreaks, false);
assert.equal(config.subtitleStyle.autoPauseVideoOnHover, true);
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, false);
assert.equal(config.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
assert.equal(config.subtitleSidebar.enabled, true);
assert.equal(config.subtitleStyle.hoverTokenColor, '#f4dbd6');
assert.equal(config.subtitleStyle.hoverTokenBackgroundColor, 'rgba(54, 58, 79, 0.84)');
assert.equal(
@@ -96,6 +100,7 @@ test('loads defaults when config is missing', () => {
assert.equal(config.immersionTracking.lifetimeSummaries?.global, true);
assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true);
assert.equal(config.immersionTracking.lifetimeSummaries?.media, true);
assert.equal(config.stats.autoOpenBrowser, false);
});
test('throws actionable startup parse error for malformed config at construction time', () => {
@@ -2122,7 +2127,23 @@ test('template generator includes known keys', () => {
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
assert.match(
output,
/"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
/"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/,
);
assert.match(
output,
/"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,
);
assert.match(
output,
/"autoPauseVideoOnYomitanPopup": true,? \/\/ Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes\. Values: true \| false/,
);
assert.match(
output,
/"enabled": true,? \/\/ Enable the subtitle sidebar feature for parsed subtitle sources\. Values: true \| false/,
);
assert.match(
output,
/"enabled": true,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
);
assert.match(
output,
@@ -2136,6 +2157,14 @@ test('template generator includes known keys', () => {
output,
/"enabled": false,? \/\/ Enable shared OpenAI-compatible AI provider features\. Values: true \| false/,
);
assert.match(
output,
/"enabled": true,? \/\/ Enable optional Discord Rich Presence updates\. Values: true \| false/,
);
assert.match(
output,
/"autoOpenBrowser": false,? \/\/ Automatically open the stats dashboard in a browser when the server starts\. Values: true \| false/,
);
assert.match(
output,
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./,

View File

@@ -31,10 +31,10 @@ export const CORE_DEFAULT_CONFIG: Pick<
},
texthooker: {
launchAtStartup: true,
openBrowser: true,
openBrowser: false,
},
controller: {
enabled: true,
enabled: false,
preferredGamepadId: '',
preferredGamepadLabel: '',
smoothScroll: true,

View File

@@ -13,7 +13,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
| 'youtubeSubgen'
> = {
ankiConnect: {
enabled: false,
enabled: true,
url: 'http://127.0.0.1:8765',
pollingRate: 3000,
proxy: {
@@ -132,7 +132,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
transcodeVideoCodec: 'h264',
},
discordPresence: {
enabled: false,
enabled: true,
presenceStyle: 'default' as const,
updateIntervalMs: 3_000,
debounceMs: 750,

View File

@@ -6,6 +6,6 @@ export const STATS_DEFAULT_CONFIG: Pick<ResolvedConfig, 'stats'> = {
markWatchedKey: 'KeyW',
serverPort: 6969,
autoStartServer: true,
autoOpenBrowser: true,
autoOpenBrowser: false,
},
};

View File

@@ -5,7 +5,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
enableJlpt: false,
preserveLineBreaks: false,
autoPauseVideoOnHover: true,
autoPauseVideoOnYomitanPopup: false,
autoPauseVideoOnYomitanPopup: true,
hoverTokenColor: '#f4dbd6',
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
nameMatchEnabled: true,
@@ -58,7 +58,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
},
},
subtitleSidebar: {
enabled: false,
enabled: true,
autoOpen: false,
layout: 'overlay',
toggleKey: 'Backslash',

View File

@@ -55,7 +55,7 @@ test('discordPresence invalid values warn and keep defaults', () => {
applyIntegrationConfig(context);
assert.equal(context.resolved.discordPresence.enabled, false);
assert.equal(context.resolved.discordPresence.enabled, true);
assert.equal(context.resolved.discordPresence.updateIntervalMs, 3_000);
assert.equal(context.resolved.discordPresence.debounceMs, 750);

View File

@@ -71,7 +71,7 @@ test('subtitleSidebar falls back and warns on invalid values', () => {
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleSidebar.enabled, false);
assert.equal(context.resolved.subtitleSidebar.enabled, true);
assert.equal(context.resolved.subtitleSidebar.autoOpen, false);
assert.equal(context.resolved.subtitleSidebar.layout, 'overlay');
assert.equal(context.resolved.subtitleSidebar.maxWidth, 420);

View File

@@ -56,7 +56,7 @@ test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', (
applySubtitleDomainConfig(context);
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnYomitanPopup, false);
assert.equal(context.resolved.subtitleStyle.autoPauseVideoOnYomitanPopup, true);
assert.ok(
warnings.some(
(warning) =>

View File

@@ -1,6 +1,7 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import http from 'node:http';
import os from 'node:os';
import path from 'node:path';
import { createStatsApp, startStatsServer } from '../stats-server.js';
@@ -1172,7 +1173,23 @@ describe('stats server API routes', () => {
const bun = globalThis as typeof globalThis & BunRuntime;
const originalServe = bun.Bun.serve;
const originalCreateServer = http.createServer;
let listenedWith: { port: number; hostname: string } | null = null;
let closeCalls = 0;
bun.Bun.serve = undefined;
(
http as typeof http & {
createServer: typeof http.createServer;
}
).createServer = (() =>
({
listen: (port: number, hostname: string) => {
listenedWith = { port, hostname };
},
close: () => {
closeCalls += 1;
},
}) as unknown as ReturnType<typeof http.createServer>) as typeof http.createServer;
try {
const server = startStatsServer({
@@ -1181,9 +1198,16 @@ describe('stats server API routes', () => {
tracker: createMockTracker(),
});
assert.deepEqual(listenedWith, { port: 0, hostname: '127.0.0.1' });
server.close();
assert.equal(closeCalls, 1);
} finally {
bun.Bun.serve = originalServe;
(
http as typeof http & {
createServer: typeof http.createServer;
}
).createServer = originalCreateServer;
}
});
});

View File

@@ -236,7 +236,7 @@ test('stats cli command runs lifetime rebuild when cleanup lifetime mode is requ
getImmersionTracker: () => ({
rebuildLifetimeSummaries: async () => ({
appliedSessions: 4,
rebuiltAtMs: 1_710_000_000_000,
rebuiltAtMs: 1_710_000_000,
}),
}),
});
@@ -252,7 +252,7 @@ test('stats cli command runs lifetime rebuild when cleanup lifetime mode is requ
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000',
]);
assert.deepEqual(responses, [
{
@@ -285,6 +285,7 @@ async function waitForPendingAnimeMetadata(
test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempotent', async () => {
const dbPath = makeDbPath();
const previousNowMs = globalThis.__subminerTestNowMs;
let tracker:
| import('../../core/services/immersion-tracker-service').ImmersionTrackerService
| null = null;
@@ -298,19 +299,23 @@ test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempo
const { Database } = await import('../../core/services/immersion-tracker/sqlite');
try {
globalThis.__subminerTestNowMs = 1_700_000_000;
tracker = new ImmersionTrackerService({ dbPath });
tracker.handleMediaChange('/tmp/Frieren S01E01.mkv', 'Episode 1');
await waitForPendingAnimeMetadata(tracker);
tracker.recordCardsMined(2);
tracker.recordSubtitleLine('first line', 0, 1);
globalThis.__subminerTestNowMs = 1_700_001_000;
tracker.destroy();
tracker = null;
globalThis.__subminerTestNowMs = 1_700_002_000;
tracker2 = new ImmersionTrackerService({ dbPath });
tracker2.handleMediaChange('/tmp/Frieren S01E02.mkv', 'Episode 2');
await waitForPendingAnimeMetadata(tracker2);
tracker2.recordCardsMined(1);
tracker2.recordSubtitleLine('second line', 0, 1);
globalThis.__subminerTestNowMs = 1_700_003_000;
tracker2.destroy();
tracker2 = null;
@@ -357,8 +362,10 @@ test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempo
`);
beforeDb.close();
globalThis.__subminerTestNowMs = 1_700_004_000;
tracker3 = new ImmersionTrackerService({ dbPath });
const firstRebuild = await tracker3.rebuildLifetimeSummaries();
globalThis.__subminerTestNowMs = 1_700_005_000;
const secondRebuild = await tracker3.rebuildLifetimeSummaries();
const rebuiltDb = new Database(dbPath);
@@ -405,6 +412,7 @@ test('tracker rebuildLifetimeSummaries backfills retained sessions and is idempo
assert.equal(secondRebuild.appliedSessions, firstRebuild.appliedSessions);
assert.ok(secondRebuild.rebuiltAtMs >= firstRebuild.rebuiltAtMs);
} finally {
globalThis.__subminerTestNowMs = previousNowMs;
tracker?.destroy();
tracker2?.destroy();
tracker3?.destroy();
@@ -417,7 +425,7 @@ test('stats cli command runs lifetime rebuild when requested', async () => {
getImmersionTracker: () => ({
rebuildLifetimeSummaries: async () => ({
appliedSessions: 4,
rebuiltAtMs: 1_710_000_000_000,
rebuiltAtMs: 1_710_000_000,
}),
}),
});
@@ -433,7 +441,7 @@ test('stats cli command runs lifetime rebuild when requested', async () => {
assert.deepEqual(calls, [
'ensureImmersionTrackerStarted',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000000',
'info:Stats lifetime rebuild complete: appliedSessions=4 rebuiltAtMs=1710000000',
]);
assert.deepEqual(responses, [
{

View File

@@ -32,6 +32,10 @@ type BackgroundStatsStopResult = {
stale: boolean;
};
function formatLoggedNumber(value: number): string {
return Number.isFinite(value) ? value.toString() : String(value);
}
export function writeStatsCliCommandResponse(
responsePath: string,
payload: StatsCliCommandResponse,
@@ -143,7 +147,7 @@ export function createRunStatsCliCommandHandler(deps: {
}
const result = await tracker.rebuildLifetimeSummaries();
deps.logInfo(
`Stats lifetime rebuild complete: appliedSessions=${result.appliedSessions} rebuiltAtMs=${result.rebuiltAtMs}`,
`Stats lifetime rebuild complete: appliedSessions=${formatLoggedNumber(result.appliedSessions)} rebuiltAtMs=${formatLoggedNumber(result.rebuiltAtMs)}`,
);
writeResponseSafe(args.statsResponsePath, { ok: true });
return;

View File

@@ -302,22 +302,28 @@ function setupPlaylistBrowserModalTest(options?: {
}
test('playlist browser test cleanup must delete injected globals that were originally absent', () => {
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
const env = setupPlaylistBrowserModalTest();
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
try {
Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'document');
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
const env = setupPlaylistBrowserModalTest();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true);
} finally {
env.restore();
}
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
} finally {
restoreGlobalDescriptor('window', previousWindowDescriptor);
restoreGlobalDescriptor('document', previousDocumentDescriptor);
}
});
test('playlist browser modal opens with playlist-focused current item selection', async () => {

View File

@@ -74,6 +74,18 @@ function createListStub() {
};
}
test.afterEach(() => {
if (Object.prototype.hasOwnProperty.call(globalThis, 'window') && globalThis.window === undefined) {
Reflect.deleteProperty(globalThis, 'window');
}
if (
Object.prototype.hasOwnProperty.call(globalThis, 'document') &&
globalThis.document === undefined
) {
Reflect.deleteProperty(globalThis, 'document');
}
});
test('findActiveSubtitleCueIndex prefers timing match before text fallback', () => {
const cues = [
{ startTime: 1, endTime: 2, text: 'same' },

View File

@@ -8,6 +8,14 @@ const CLICK_SEEK_OFFSET_SEC = 0.08;
const SNAPSHOT_POLL_INTERVAL_MS = 80;
const EMBEDDED_SIDEBAR_MIN_WIDTH_PX = 240;
const EMBEDDED_SIDEBAR_MAX_RATIO = 0.45;
function nowForUiTiming(): number {
if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
return performance.now();
}
return Date.now();
}
function subtitleCueListsEqual(a: SubtitleCue[], b: SubtitleCue[]): boolean {
if (a.length !== b.length) {
return false;
@@ -294,7 +302,7 @@ export function createSubtitleSidebarModal(
!ctx.state.subtitleSidebarAutoScroll ||
ctx.state.subtitleSidebarActiveCueIndex < 0 ||
(!force && ctx.state.subtitleSidebarActiveCueIndex === previousActiveCueIndex) ||
Date.now() < ctx.state.subtitleSidebarManualScrollUntilMs
nowForUiTiming() < ctx.state.subtitleSidebarManualScrollUntilMs
) {
return;
}
@@ -547,7 +555,7 @@ export function createSubtitleSidebarModal(
seekToCue(cue);
});
ctx.dom.subtitleSidebarList.addEventListener('wheel', () => {
ctx.state.subtitleSidebarManualScrollUntilMs = Date.now() + MANUAL_SCROLL_HOLD_MS;
ctx.state.subtitleSidebarManualScrollUntilMs = nowForUiTiming() + MANUAL_SCROLL_HOLD_MS;
});
ctx.dom.subtitleSidebarContent.addEventListener('mouseenter', async () => {
subtitleSidebarHovered = true;

View File

@@ -92,6 +92,17 @@ function restoreGlobalProp<K extends keyof typeof globalThis>(
Reflect.deleteProperty(globalThis, key);
}
function restoreGlobalDescriptor<K extends keyof typeof globalThis>(
key: K,
descriptor: PropertyDescriptor | undefined,
) {
if (descriptor) {
Object.defineProperty(globalThis, key, descriptor);
return;
}
Reflect.deleteProperty(globalThis, key);
}
function setupYoutubePickerTestEnv(options?: {
windowValue?: YoutubePickerTestWindow;
customEventValue?: unknown;
@@ -153,20 +164,30 @@ function setupYoutubePickerTestEnv(options?: {
}
test('youtube picker test env restore deletes injected globals that were originally absent', () => {
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
const previousWindowDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousDocumentDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'document');
const env = setupYoutubePickerTestEnv();
try {
Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'document');
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true);
const env = setupYoutubePickerTestEnv();
env.restore();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), true);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
env.restore();
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'window'), false);
assert.equal(Object.prototype.hasOwnProperty.call(globalThis, 'document'), false);
assert.equal(typeof globalThis.window, 'undefined');
assert.equal(typeof globalThis.document, 'undefined');
} finally {
restoreGlobalDescriptor('window', previousWindowDescriptor);
restoreGlobalDescriptor('document', previousDocumentDescriptor);
}
});
test('youtube track picker close restores focus and mouse-ignore state', () => {

View File

@@ -9,6 +9,36 @@ export const DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024;
const TRUNCATED_MARKER = '[truncated older log content]\n';
const prunedDirectories = new Set<string>();
const NS_PER_MS = 1_000_000n;
const MS_PER_DAY = 86_400_000n;
function floorDiv(left: number, right: number): number {
return Math.floor(left / right);
}
function daysFromCivil(year: number, month: number, day: number): bigint {
const adjustedYear = year - (month <= 2 ? 1 : 0);
const era = floorDiv(adjustedYear >= 0 ? adjustedYear : adjustedYear - 399, 400);
const yearOfEra = adjustedYear - era * 400;
const monthIndex = month + (month > 2 ? -3 : 9);
const dayOfYear = floorDiv(153 * monthIndex + 2, 5) + day - 1;
const dayOfEra =
yearOfEra * 365 + floorDiv(yearOfEra, 4) - floorDiv(yearOfEra, 100) + dayOfYear;
return BigInt(era * 146097 + dayOfEra - 719468);
}
function dateToEpochMs(date: Date): bigint {
const dayCount = daysFromCivil(
date.getUTCFullYear(),
date.getUTCMonth() + 1,
date.getUTCDate(),
);
const timeOfDayMs = BigInt(
((date.getUTCHours() * 60 + date.getUTCMinutes()) * 60 + date.getUTCSeconds()) * 1000 +
date.getUTCMilliseconds(),
);
return dayCount * MS_PER_DAY + timeOfDayMs;
}
export function resolveLogBaseDir(options?: {
platform?: NodeJS.Platform;
@@ -52,16 +82,20 @@ export function pruneLogFiles(
return;
}
const cutoffMs = (options?.now ?? new Date()).getTime() - retentionDays * 24 * 60 * 60 * 1000;
const cutoffDate = new Date(options?.now ?? new Date());
cutoffDate.setUTCDate(cutoffDate.getUTCDate() - retentionDays);
const cutoffNs = dateToEpochMs(cutoffDate) * NS_PER_MS;
for (const entry of entries) {
const candidate = path.join(logsDir, entry);
let stats: fs.Stats;
let stats: fs.BigIntStats;
try {
stats = fs.statSync(candidate);
stats = fs.statSync(candidate, { bigint: true });
} catch {
continue;
}
if (!stats.isFile() || !entry.endsWith('.log') || stats.mtimeMs >= cutoffMs) continue;
if (!stats.isFile() || !entry.endsWith('.log') || stats.mtimeNs >= cutoffNs) {
continue;
}
try {
fs.rmSync(candidate, { force: true });
} catch {