Compare commits

...

24 Commits

Author SHA1 Message Date
6b111eae4a test: fix setup gate timeout regression 2026-04-03 17:57:11 -07:00
65ed5e4633 fix: guard setup cancel ignore against null reads 2026-04-03 17:43:06 -07:00
57a7e5247d fix: unblock playback when mpv plugin is already installed 2026-04-03 17:31:42 -07:00
e7734b76a7 Cancel stale lookup scan fallback timer when closing popup
The setTimeout fallback in triggerLookupForSelectedWord was not being
tracked or cancelled, causing it to fire after test globals were
restored and hit an undefined document reference.
2026-04-03 15:32:06 -07:00
51b19ebe48 Include build:launcher in main build script
The launcher source lives in launcher/*.ts and the generated artifact
at dist/launcher/subminer must stay in sync. Adding build:launcher to
the build pipeline ensures launcher changes are validated and packaged.
2026-04-03 14:27:00 -07:00
d065aca7dd Retrigger CI with skip-changelog label 2026-04-03 14:16:11 -07:00
1445f0e535 Release prep v0.11.0
Bump version to 0.11.0, build changelog from 15 fragments,
update docs-site changelog, and regenerate config example.
2026-04-03 14:09:30 -07:00
c31e55398d Run prettier across source files 2026-04-03 14:04:07 -07:00
acb490fa10 Fix track-probe fake yt-dlp shell script for CI
- Use #!/bin/sh instead of #!/usr/bin/env sh (avoids PATH dependency)
- Set minimal PATH inside script for base64/cat commands
- Fix base64 content encoding: use stdoutBody directly instead of
  JSON.stringify(stdoutBody) which double-encoded the output
- Use unique heredoc delimiter to avoid conflicts with content
2026-04-03 13:33:40 -07:00
4dd754f826 Remove external command deps from fake yt-dlp shell script
Replace sed with shell parameter expansion, dirname with ${var%/*},
cat heredoc with printf, and use /bin/mkdir absolute path. This
ensures the script works when PATH is empty (SUBMINER_YTDLP_BIN test).
2026-04-03 13:27:55 -07:00
db03713a9a Set minimal PATH inside fake yt-dlp shell script
The SUBMINER_YTDLP_BIN test empties PATH to verify the env var
fallback, but the shell script needs sed/mkdir/dirname. Hardcode
a minimal PATH at the top of the script so internal commands work.
2026-04-03 13:26:19 -07:00
0f03547c83 fix latest review feedback 2026-04-03 13:23:15 -07:00
76fa8b779d Fix CI failures in youtube track-download tests
- Use #!/bin/sh instead of #!/usr/bin/env sh so the fake yt-dlp
  shell script works when PATH is emptied by the SUBMINER_YTDLP_BIN test
- Remove trailing blank line in heredoc rolling-auto output to match
  the bun script's output (avoids extra \n that breaks normalization assertion)
2026-04-03 13:22:43 -07:00
4b2861bd71 Update icon paths and improve Windows yt-dlp command resolution 2026-04-03 13:13:11 -07:00
3958204737 add proper square Windows icon with multiple sizes
The source PNG (1536x1024) was non-square, causing the icon to appear
squished on Windows. Center-crop to 1024x1024 and generate a multi-size
.ico (16, 32, 48, 64, 128, 256) for correct display across taskbar,
title bar, alt-tab, and explorer.
2026-04-03 13:10:18 -07:00
b3645a78a5 fix yt-dlp helper review feedback 2026-04-03 13:09:38 -07:00
2d01dc097b fix windows launcher review feedback 2026-04-03 13:04:01 -07:00
ea22fda16b Normalize mpv args only when safe on Windows 2026-04-03 12:54:16 -07:00
0498cfa07d fix yt-dlp test harness 2026-04-03 12:53:58 -07:00
3c7995afb7 fix launcher runtime feedback 2026-04-03 12:42:56 -07:00
3203713fd5 Fix YouTube test yt-dlp fakes for non-Windows CI 2026-04-03 12:28:50 -07:00
a7a50358e9 Fix launcher backend parsing and yt-dlp overrides 2026-04-03 12:18:32 -07:00
d2201833f0 Add Windows mpv executable path override 2026-04-03 11:47:26 -07:00
c664f9f605 Address CodeRabbit follow-ups for PR 40 2026-04-03 11:37:12 -07:00
116 changed files with 1891 additions and 672 deletions

View File

@@ -1,11 +1,51 @@
# Changelog
## Unreleased
## v0.11.0 (2026-04-03)
### Added
- Overlay: Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
- Overlay: Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback.
### 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.
### Fixed
- AniList: Stopped post-watch tracking from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
- Main: Resolve the YouTube playback socket path lazily so startup honors CLI and config overrides.
- Main: Add regression coverage for the lazy socket-path lookup during Windows mpv startup.
- Main: Keep integrated `--start --texthooker` launches on the full app-ready startup path so the texthooker page and websocket servers start together during normal playback startup.
- Main: Stop the mpv/plugin auto-start flow from spawning a separate standalone texthooker helper during normal `subminer <video>` launches.
- Overlay: Keep tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately without requiring a subtitle hover cycle first.
- Overlay: Add regression coverage for the macOS visible-overlay passthrough default.
- Anilist: Stop AniList post-watch from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
- Anilist: Add regression coverage for the retry-queue plus live-update duplicate path.
- Overlay: Fixed Kiku duplicate grouping to reuse duplicate note IDs from both generic sentence-card creation and Yomitan popup mining instead of running extra duplicate scans after add.
- Overlay: Fixed the Yomitan popup mining flow to add cards in the background while keeping the stock popup progress feedback, then pause playback and close the lookup popup before the Kiku merge modal opens.
- Overlay: Fixed configured subtitle-jump keybindings so backward and forward subtitle seeks keep playback paused when invoked from a paused state.
- Launcher: Fixed the Windows `SubMiner mpv` shortcut and `SubMiner.exe --launch-mpv` flow to launch mpv with SubMiner's required default args directly instead of requiring an `mpv.conf` profile named `subminer`.
- Launcher: Clarified the Windows install and usage docs so the shortcut path is documented as self-contained, while the optional `subminer` mpv profile remains available for manual mpv launches.
- Launcher: Hardened the first-run setup blocker copy and stale custom-scheme handling so setup messages stay aligned with config, plugin, and dictionary readiness.
- Launcher: Fixed the Windows `SubMiner mpv` shortcut idle launch so loading a video after opening the shortcut keeps mpv in the expected SubMiner-managed session, auto-starts the overlay, and re-arms subtitle auto-selection for the newly opened file.
- Launcher: Removed the redundant `.` subtitle search path from the Windows shortcut launch args and deduped repeated subtitle source tracks in the manual sync picker so duplicate external subtitle entries no longer appear from the shortcut path.
- Playback: Fixed managed local playback so duplicate startup-ready retries no longer unpause media after a later manual pause on the same file.
- Playback: Fixed managed local subtitle auto-selection so local files reuse configured primary/secondary subtitle language priorities instead of staying on mpv's initial `sid=auto` guess.
- Playback: Fixed managed local subtitle auto-selection so local files reuse configured primary and secondary subtitle language priorities instead of staying on mpv's initial `sid=auto` guess.
- Launcher: Added a blank-by-default `mpv.executablePath` override for Windows playback so users can point SubMiner at `mpv.exe` when it is not on `PATH`.
- Launcher: Kept the Windows shortcut and `--launch-mpv` flow simple by preserving PATH auto-discovery as the default and exposing the override in first-run setup.
- Launcher: Added `windows` as a recognized launcher backend option and auto-detection target on Windows.
- Launcher: Honored `SUBMINER_YTDLP_BIN` consistently across YouTube playback URL resolution, track probing, subtitle downloads, and metadata probing.
- 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.
- Playback: Prevented stale async playlist-browser subtitle rearm callbacks from overriding newer subtitle selections during rapid file changes.
### Docs
- Docs Site: Added a dedicated Subtitle Sidebar guide and linked it from the homepage and configuration docs.
- Docs Site: Linked Jimaku integration from the homepage to its dedicated docs page.
- Docs Site: Refreshed docs-site theme tokens and hover/selection styling for the updated pages.
### 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.
## v0.10.0 (2026-03-29)

BIN
assets/SubMiner-square.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

BIN
assets/SubMiner.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@@ -0,0 +1,53 @@
---
id: TASK-273
title: >-
Fix first-run setup false positive when canonical mpv plugin is already
installed
status: Done
assignee:
- Kyle Yasuda
created_date: '2026-04-03 23:26'
updated_date: '2026-04-04 00:31'
labels:
- bug
- macos
- first-run-setup
dependencies: []
priority: high
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Investigate and fix launcher/app first-run setup gating so playback does not block when the SubMiner mpv plugin is already installed at the canonical mpv config path on macOS. Align mpv path resolution with the actual install location, keep plugin detection scoped to the canonical plugin entrypoint, and make launcher setup gating resilient to stale cancelled setup state.
<!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
- [ ] #1 `resolveDefaultMpvInstallPaths` resolves the canonical macOS mpv config path used by existing installs.
- [ ] #2 Playback launcher bypasses first-run setup when the canonical `scripts/subminer/main.lua` plugin entrypoint already exists, even if `setup-state.json` is stale.
- [ ] #3 Regression tests cover canonical plugin detection and launcher handling of stale cancelled setup state.
<!-- AC:END -->
## Implementation Notes
<!-- SECTION:NOTES:BEGIN -->
Root cause ended up split across path resolution and launcher gating. No automated test command was executed in this pass by request.
<!-- SECTION:NOTES:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Updated macOS mpv install path resolution to use the canonical `~/.config/mpv` location so first-run plugin detection matches the actual installed plugin path.
Restricted plugin detection to the canonical `scripts/subminer/main.lua` entrypoint instead of config presence or legacy loader files.
Updated the launcher setup gate to bypass stale `setup-state.json` when the mpv plugin is already installed, and to ignore an initially stale `cancelled` state after spawning setup.
Added regression coverage for canonical macOS detection and launcher setup-gate bypass behavior. No automated test command was executed in this pass by request.
<!-- SECTION:FINAL_SUMMARY:END -->
## Definition of Done
<!-- DOD:BEGIN -->
- [ ] #1 Manual verification with scenario: existing plugin installed in custom mpv config path does not open first-run setup.
<!-- DOD:END -->

View File

@@ -1,6 +0,0 @@
type: docs
area: docs-site
- Added a dedicated Subtitle Sidebar guide and linked it from the homepage and configuration docs.
- Linked Jimaku integration from the homepage to its dedicated docs page.
- Refreshed docs-site theme tokens and hover/selection styling for the updated pages.

View File

@@ -1,5 +0,0 @@
type: fixed
area: main
- Resolve the YouTube playback socket path lazily so startup honors CLI and config overrides.
- Add regression coverage for the lazy socket-path lookup during Windows mpv startup.

View File

@@ -1,5 +0,0 @@
type: internal
area: release
- Retried AUR clone and push operations in the tagged release workflow.
- Kept GitHub Releases green when AUR publish flakes and needs manual follow-up.

View File

@@ -1,5 +0,0 @@
type: fixed
area: main
- Keep integrated `--start --texthooker` launches on the full app-ready startup path so the texthooker page and websocket servers start together during normal playback startup.
- Stop the mpv/plugin auto-start flow from spawning a separate standalone texthooker helper during normal `subminer <video>` launches.

View File

@@ -1,5 +0,0 @@
type: added
area: overlay
- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback.
- Added the default `Ctrl+Alt+P` keybinding to open the playlist browser and manage queue order without leaving playback.

View File

@@ -1,5 +0,0 @@
type: fixed
area: overlay
- Keep tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately without requiring a subtitle hover cycle first.
- Add regression coverage for the macOS visible-overlay passthrough default.

View File

@@ -1,5 +0,0 @@
type: fixed
area: anilist
- Stop AniList post-watch from sending a second progress update when the current episode was already satisfied by a ready retry item in the same watch-completion pass.
- Add regression coverage for the retry-queue plus live-update duplicate path.

View File

@@ -1,6 +0,0 @@
type: fixed
area: overlay
- Fixed Kiku duplicate grouping to reuse duplicate note IDs from both generic sentence-card creation and Yomitan popup mining instead of running extra duplicate scans after add.
- Fixed the Yomitan popup mining flow to add cards in the background while keeping the stock popup progress feedback, then pause playback and close the lookup popup before the Kiku merge modal opens.
- Fixed configured subtitle-jump keybindings so backward and forward subtitle seeks keep playback paused when invoked from a paused state.

View File

@@ -1,6 +0,0 @@
type: fixed
area: launcher
- Fixed the Windows `SubMiner mpv` shortcut and `SubMiner.exe --launch-mpv` flow to launch mpv with SubMiner's required default args directly instead of requiring an `mpv.conf` profile named `subminer`.
- Clarified the Windows install and usage docs so the shortcut path is documented as self-contained, while the optional `subminer` mpv profile remains available for manual mpv launches.
- Hardened the first-run setup blocker copy and stale custom-scheme handling so setup messages stay aligned with config, plugin, and dictionary readiness.

View File

@@ -1,5 +0,0 @@
type: fixed
area: launcher
- Fixed the Windows `SubMiner mpv` shortcut idle launch so loading a video after opening the shortcut keeps mpv in the expected SubMiner-managed session, auto-starts the overlay, and re-arms subtitle auto-selection for the newly opened file.
- Removed the redundant `.` subtitle search path from the Windows shortcut launch args and deduped repeated subtitle source tracks in the manual sync picker so duplicate external subtitle entries no longer appear from the shortcut path.

View File

@@ -1,5 +0,0 @@
type: changed
area: 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.
- 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.

View File

@@ -1,5 +0,0 @@
type: fixed
area: playback
- Fixed managed local playback so duplicate startup-ready retries no longer unpause media after a later manual pause on the same file.
- Fixed managed local subtitle auto-selection so local files reuse configured primary and secondary subtitle language priorities instead of staying on mpv's initial `sid=auto` guess.

View File

@@ -0,0 +1,5 @@
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

@@ -458,6 +458,15 @@
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
}, // Optional external Yomitan profile integration.
// ==========================================
// MPV Launcher
// Optional mpv.exe override for Windows playback entry points.
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
// ==========================================
"mpv": {
"executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
}, // Optional mpv.exe override for Windows playback entry points.
// ==========================================
// Jellyfin
// Optional Jellyfin integration for auth, browsing, and playback launch.

View File

@@ -74,7 +74,7 @@ src/
handlers/ # Keyboard/mouse interaction modules
modals/ # Jimaku/Kiku/subsync/runtime-options/session-help modals
positioning/ # Subtitle position controller (drag-to-reposition)
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS)
window-trackers/ # Backend-specific tracker implementations (Hyprland, Sway, X11, macOS, Windows)
jimaku/ # Jimaku API integration helpers
subsync/ # Subtitle sync (alass/ffsubsync) helpers
subtitle/ # Subtitle processing utilities

View File

@@ -1,5 +1,21 @@
# Changelog
## v0.11.0 (2026-04-03)
- Added a playlist browser overlay modal for browsing sibling video files and the live mpv queue during playback, with a default `Ctrl+Alt+P` keybinding.
- Made mpv plugin installation mandatory in first-run setup (removed skip path); Finish stays disabled until the plugin is installed.
- Fixed the Windows `SubMiner mpv` shortcut to launch mpv with required default args directly instead of requiring an `mpv.conf` profile named `subminer`.
- Fixed the Windows mpv idle launch so loading a video after opening the shortcut keeps mpv in the SubMiner-managed session and auto-starts the overlay.
- Added a blank-by-default `mpv.executablePath` config override for Windows playback when mpv is not on `PATH`, exposed in first-run setup.
- Fixed Kiku duplicate grouping to reuse duplicate note IDs from both sentence-card creation and Yomitan popup mining, with background card addition and proper merge-modal sequencing.
- Fixed configured subtitle-jump keybindings to keep playback paused when invoked from a paused state.
- Fixed managed local subtitle auto-selection to reuse configured language priorities instead of staying on mpv's initial `sid=auto` guess.
- Kept tracked macOS visible overlays click-through by default so subtitle sidebar passthrough works immediately.
- Stopped AniList post-watch from sending duplicate progress updates when already satisfied by a retry item.
- Kept integrated `--start --texthooker` launches on the full app-ready startup path.
- Honored `SUBMINER_YTDLP_BIN` consistently across all YouTube flows (playback URL resolution, track probing, subtitle downloads, metadata probing).
- Added `windows` as a recognized launcher backend option and auto-detection target.
- Added a dedicated Subtitle Sidebar guide to the docs site with links from homepage and configuration docs.
## v0.10.0 (2026-03-29)
- Fixed stats startup so the immersion tracker can run when `Bun.serve` is unavailable.
- Added a Node `http` fallback for Electron/runtime paths that do not expose Bun, so stats keeps working there too.

View File

@@ -23,7 +23,7 @@
**macOS** — macOS 10.13 or later. Accessibility permission required for window tracking.
**Windows** — Windows 10 or later. Install `mpv` and keep it available on `PATH`; SubMiner's packaged build handles window tracking directly.
**Windows** — Windows 10 or later. Install `mpv`; keep it on `PATH` for auto-discovery or set `mpv.executablePath` in config if `mpv.exe` lives elsewhere. SubMiner's packaged build handles window tracking directly.
### Optional Tools
@@ -172,6 +172,7 @@ Install `mpv` separately and ensure `mpv.exe` is on `PATH`. `ffmpeg` is still re
### Windows Usage Notes
- Launch `SubMiner.exe` once to let the first-run setup flow seed `%APPDATA%\\SubMiner\\config.jsonc`, require mpv plugin installation, and open bundled Yomitan settings. The optional `SubMiner mpv` Start Menu/Desktop shortcut can also be created during setup, and on Windows it is the recommended way to launch mpv playback with SubMiner defaults.
- If `mpv.exe` is not on `PATH`, set `mpv.executablePath` in `config.jsonc` or use the first-run setup field to point at the executable. Leave it blank to keep PATH auto-discovery.
- `SubMiner.exe --launch-mpv` and the optional `SubMiner mpv` shortcut pass SubMiner's default mpv socket/subtitle args directly and do not require an `mpv.conf` profile named `subminer`.
- First-run mpv plugin installs pin `binary_path` to the current `SubMiner.exe` automatically. Manual plugin configs can leave `binary_path` empty unless SubMiner is installed in a non-standard location.
- Windows plugin installs rewrite `socket_path` to `\\.\pipe\subminer-socket`; do not keep `/tmp/subminer-socket` on Windows.

View File

@@ -98,7 +98,7 @@ Use `subminer <subcommand> -h` for command-specific help.
| `-T, --no-texthooker` | Disable texthooker server |
| `-p, --profile` | mpv profile name (default: `subminer`) |
| `-a, --args` | Pass additional mpv arguments as a quoted string |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`) |
| `-b, --backend` | Force window backend (`hyprland`, `sway`, `x11`, `macos`, `windows`) |
| `--log-level` | Logger verbosity (`debug`, `info`, `warn`, `error`) |
| `--dev`, `--debug` | Enable app dev-mode (not tied to log level) |

View File

@@ -79,7 +79,7 @@ texthooker_enabled=yes
# Port for the texthooker server.
texthooker_port=5174
# Window manager backend: auto, hyprland, sway, x11, macos.
# Window manager backend: auto, hyprland, sway, x11, macos, windows.
backend=auto
# Start the overlay automatically when a file is loaded.

View File

@@ -458,6 +458,15 @@
"externalProfilePath": "" // Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay
}, // Optional external Yomitan profile integration.
// ==========================================
// MPV Launcher
// Optional mpv.exe override for Windows playback entry points.
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
// ==========================================
"mpv": {
"executablePath": "" // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
}, // Optional mpv.exe override for Windows playback entry points.
// ==========================================
// Jellyfin
// Optional Jellyfin integration for auth, browsing, and playback launch.

View File

@@ -133,7 +133,7 @@ You can use it three ways:
& "C:\Program Files\SubMiner\SubMiner.exe" --launch-mpv "C:\Videos\episode 01.mkv"
```
This flow requires `mpv.exe` to be on `PATH`. If it is installed elsewhere, set `SUBMINER_MPV_PATH` to the full `mpv.exe` path before launching. On Windows, `--launch-mpv` does not require an `mpv.conf` profile named `subminer`.
This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blank to auto-discover from `PATH`, or set it to the full `mpv.exe` path if mpv is installed elsewhere. `SUBMINER_MPV_PATH` is still honored as a fallback. On Windows, `--launch-mpv` does not require an `mpv.conf` profile named `subminer`.
### Launcher Subcommands
@@ -164,6 +164,7 @@ Setup flow:
- Yomitan shortcut: open bundled Yomitan settings directly from the setup window
- dictionary check: ensure at least one bundled Yomitan dictionary is available, unless an external Yomitan profile is configured
- Windows: optionally create or remove `SubMiner mpv` Start Menu/Desktop shortcuts (`SubMiner.exe --launch-mpv`)
- Windows: optionally set `mpv.executablePath` if `mpv.exe` is not on `PATH`
- refresh: re-check plugin + dictionary state without restarting
- `Finish setup` stays disabled until the config, plugin, and dictionary gates are satisfied
- finish action writes setup completion state and suppresses future auto-open prompts

View File

@@ -21,7 +21,9 @@ import {
getDefaultConfigDir,
getSetupStatePath,
readSetupState,
resolveDefaultMpvInstallPaths,
} from '../../src/shared/setup-state.js';
import { detectInstalledFirstRunPlugin } from '../../src/main/runtime/first-run-setup-plugin.js';
import { hasLauncherExternalYomitanProfileConfig } from '../config.js';
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
@@ -105,6 +107,14 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
const ready = await ensureLauncherSetupReady({
readSetupState: () => readSetupState(statePath),
isExternalYomitanConfigured: () => hasLauncherExternalYomitanProfileConfig(),
isPluginInstalled: () => {
const installPaths = resolveDefaultMpvInstallPaths(
process.platform,
os.homedir(),
process.env.XDG_CONFIG_HOME,
);
return detectInstalledFirstRunPlugin(installPaths);
},
launchSetupApp: () => {
const setupArgs = ['--background', '--setup'];
if (args.logLevel) {

View File

@@ -49,10 +49,17 @@ function parseLogLevel(value: string): LogLevel {
}
function parseBackend(value: string): Backend {
if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') {
if (
value === 'auto' ||
value === 'hyprland' ||
value === 'sway' ||
value === 'x11' ||
value === 'macos' ||
value === 'windows'
) {
return value as Backend;
}
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
fail(`Invalid backend: ${value} (must be auto, hyprland, sway, x11, macos, or windows)`);
}
function parseDictionaryTarget(value: string): string {

View File

@@ -17,20 +17,20 @@ test('resolveTopLevelCommand respects the app alias after root options', () => {
});
test('parseCliPrograms keeps root options and target when no command is present', () => {
const result = parseCliPrograms(['--backend', 'x11', '/tmp/movie.mkv'], 'subminer');
const result = parseCliPrograms(['--backend', 'windows', '/tmp/movie.mkv'], 'subminer');
assert.equal(result.options.backend, 'x11');
assert.equal(result.options.backend, 'windows');
assert.equal(result.rootTarget, '/tmp/movie.mkv');
assert.equal(result.invocations.appInvocation, null);
});
test('parseCliPrograms routes app alias arguments through passthrough mode', () => {
const result = parseCliPrograms(
['--backend', 'macos', 'bin', '--anilist', '--log-level', 'debug'],
['--backend', 'windows', 'bin', '--anilist', '--log-level', 'debug'],
'subminer',
);
assert.equal(result.options.backend, 'macos');
assert.equal(result.options.backend, 'windows');
assert.deepEqual(result.invocations.appInvocation, {
appArgs: ['--anilist', '--log-level', 'debug'],
});

View File

@@ -43,7 +43,10 @@ export interface CliInvocations {
function applyRootOptions(program: Command): void {
program
.option('-b, --backend <backend>', 'Display backend')
.option(
'-b, --backend <backend>',
'Display backend (auto, hyprland, sway, x11, macos, windows)',
)
.option('-d, --directory <dir>', 'Directory to browse')
.option('-a, --args <args>', 'Pass arguments to MPV')
.option('-r, --recursive', 'Search directories recursively')

View File

@@ -8,6 +8,7 @@ import { EventEmitter } from 'node:events';
import type { Args } from './types';
import {
cleanupPlaybackSession,
detectBackend,
findAppBinary,
launchAppCommandDetached,
launchTexthookerOnly,
@@ -56,6 +57,22 @@ function createTempSocketPath(): { dir: string; socketPath: string } {
return { dir, socketPath: path.join(dir, 'mpv.sock') };
}
function withPlatform<T>(platform: NodeJS.Platform, callback: () => T): T {
const originalDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
try {
return callback();
} finally {
if (originalDescriptor) {
Object.defineProperty(process, 'platform', originalDescriptor);
}
}
}
test('mpv module exposes only canonical socket readiness helper', () => {
assert.equal('waitForSocket' in mpvModule, false);
});
@@ -102,6 +119,12 @@ test('parseMpvArgString preserves empty quoted tokens', () => {
]);
});
test('detectBackend resolves windows on win32 auto mode', () => {
withPlatform('win32', () => {
assert.equal(detectBackend('auto'), 'windows');
});
});
test('launchTexthookerOnly exits non-zero when app binary cannot be spawned', () => {
const error = withProcessExitIntercept(() => {
launchTexthookerOnly('/definitely-missing-subminer-binary', makeArgs());
@@ -434,7 +457,9 @@ function withFindAppBinaryPlatformSandbox(
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
withFindAppBinaryEnvSandbox(() => run(platform === 'win32' ? (path.win32 as typeof path) : path));
withFindAppBinaryEnvSandbox(() =>
run(platform === 'win32' ? (path.win32 as typeof path) : path),
);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
@@ -460,72 +485,138 @@ function withAccessSyncStub(
}
}
test('findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists', { concurrency: false }, () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
const originalHomedir = os.homedir;
function withRealpathSyncStub(resolvePath: (filePath: string) => string, run: () => void): void {
const originalRealpathSync = fs.realpathSync;
try {
os.homedir = () => baseDir;
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
makeExecutable(appImage);
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
const result = findAppBinary('/some/other/path/subminer', pathModule);
assert.equal(result, appImage);
});
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fs as any).realpathSync = (filePath: string): string => resolvePath(filePath);
run();
} finally {
os.homedir = originalHomedir;
fs.rmSync(baseDir, { recursive: true, force: true });
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(fs as any).realpathSync = originalRealpathSync;
}
});
}
test('findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist', { concurrency: false }, () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
const originalHomedir = os.homedir;
try {
os.homedir = () => baseDir;
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
withAccessSyncStub(
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
() => {
const result = findAppBinary('/some/other/path/subminer', pathModule);
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
},
);
});
} finally {
os.homedir = originalHomedir;
fs.rmSync(baseDir, { recursive: true, force: true });
}
});
test(
'findAppBinary resolves ~/.local/bin/SubMiner.AppImage when it exists',
{ concurrency: false },
() => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
const originalHomedir = os.homedir;
try {
os.homedir = () => baseDir;
const appImage = path.join(baseDir, '.local/bin/SubMiner.AppImage');
makeExecutable(appImage);
test('findAppBinary finds subminer on PATH when AppImage candidates do not exist', { concurrency: false }, () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
const originalHomedir = os.homedir;
const originalPath = process.env.PATH;
try {
os.homedir = () => baseDir;
// No AppImage candidates in empty home dir; place subminer wrapper on PATH
const binDir = path.join(baseDir, 'bin');
const wrapperPath = path.join(binDir, 'subminer');
makeExecutable(wrapperPath);
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
const result = findAppBinary('/some/other/path/subminer', pathModule);
assert.equal(result, appImage);
});
} finally {
os.homedir = originalHomedir;
fs.rmSync(baseDir, { recursive: true, force: true });
}
},
);
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
withAccessSyncStub(
(filePath) => filePath === wrapperPath,
() => {
// selfPath must differ from wrapperPath so the self-check does not exclude it
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'), pathModule);
assert.equal(result, wrapperPath);
},
);
});
} finally {
os.homedir = originalHomedir;
process.env.PATH = originalPath;
fs.rmSync(baseDir, { recursive: true, force: true });
}
});
test(
'findAppBinary resolves /opt/SubMiner/SubMiner.AppImage when ~/.local/bin candidate does not exist',
{ concurrency: false },
() => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-home-'));
const originalHomedir = os.homedir;
try {
os.homedir = () => baseDir;
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
withAccessSyncStub(
(filePath) => filePath === '/opt/SubMiner/SubMiner.AppImage',
() => {
const result = findAppBinary('/some/other/path/subminer', pathModule);
assert.equal(result, '/opt/SubMiner/SubMiner.AppImage');
},
);
});
} finally {
os.homedir = originalHomedir;
fs.rmSync(baseDir, { recursive: true, force: true });
}
},
);
test(
'findAppBinary finds subminer on PATH when AppImage candidates do not exist',
{ concurrency: false },
() => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-path-'));
const originalHomedir = os.homedir;
const originalPath = process.env.PATH;
try {
os.homedir = () => baseDir;
// No AppImage candidates in empty home dir; place subminer wrapper on PATH
const binDir = path.join(baseDir, 'bin');
const wrapperPath = path.join(binDir, 'subminer');
makeExecutable(wrapperPath);
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
withAccessSyncStub(
(filePath) => filePath === wrapperPath,
() => {
// selfPath must differ from wrapperPath so the self-check does not exclude it
const result = findAppBinary(path.join(baseDir, 'launcher', 'subminer'), pathModule);
assert.equal(result, wrapperPath);
},
);
});
} finally {
os.homedir = originalHomedir;
process.env.PATH = originalPath;
fs.rmSync(baseDir, { recursive: true, force: true });
}
},
);
test(
'findAppBinary excludes PATH matches that canonicalize to the launcher path',
{ concurrency: false },
() => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-realpath-'));
const originalHomedir = os.homedir;
const originalPath = process.env.PATH;
try {
os.homedir = () => baseDir;
const binDir = path.join(baseDir, 'bin');
const wrapperPath = path.join(binDir, 'subminer');
const canonicalPath = path.join(baseDir, 'launch', 'subminer');
makeExecutable(wrapperPath);
process.env.PATH = `${binDir}${path.delimiter}${originalPath ?? ''}`;
withFindAppBinaryPlatformSandbox('linux', (pathModule) => {
withAccessSyncStub(
(filePath) => filePath === wrapperPath,
() => {
withRealpathSyncStub(
(filePath) => {
if (filePath === canonicalPath || filePath === wrapperPath) {
return canonicalPath;
}
return filePath;
},
() => {
const result = findAppBinary(canonicalPath, pathModule);
assert.equal(result, null);
},
);
},
);
});
} finally {
os.homedir = originalHomedir;
process.env.PATH = originalPath;
fs.rmSync(baseDir, { recursive: true, force: true });
}
},
);
test('findAppBinary resolves Windows install paths when present', { concurrency: false }, () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-'));
@@ -547,7 +638,10 @@ test('findAppBinary resolves Windows install paths when present', { concurrency:
withAccessSyncStub(
(filePath) => filePath === appExe,
() => {
const result = findAppBinary(pathModule.join(baseDir, 'launcher', 'SubMiner.exe'), pathModule);
const result = findAppBinary(
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
pathModule,
);
assert.equal(result, appExe);
},
);
@@ -578,7 +672,10 @@ test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: fa
withAccessSyncStub(
(filePath) => filePath === wrapperPath,
() => {
const result = findAppBinary(pathModule.join(baseDir, 'launcher', 'SubMiner.exe'), pathModule);
const result = findAppBinary(
pathModule.join(baseDir, 'launcher', 'SubMiner.exe'),
pathModule,
);
assert.equal(result, wrapperPath);
},
);
@@ -590,34 +687,41 @@ test('findAppBinary resolves SubMiner.exe on PATH on Windows', { concurrency: fa
}
});
test('findAppBinary resolves a Windows install directory to SubMiner.exe', { concurrency: false }, () => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-dir-'));
const originalHomedir = os.homedir;
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
try {
os.homedir = () => baseDir;
const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner');
const appExe = path.win32.join(installDir, 'SubMiner.exe');
process.env.SUBMINER_BINARY_PATH = installDir;
fs.mkdirSync(installDir, { recursive: true });
fs.writeFileSync(appExe, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appExe, 0o755);
const originalPlatform = process.platform;
test(
'findAppBinary resolves a Windows install directory to SubMiner.exe',
{ concurrency: false },
() => {
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-win-dir-'));
const originalHomedir = os.homedir;
const originalSubminerBinaryPath = process.env.SUBMINER_BINARY_PATH;
try {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
const result = findAppBinary(path.win32.join(baseDir, 'launcher', 'SubMiner.exe'), path.win32);
assert.equal(result, appExe);
os.homedir = () => baseDir;
const installDir = path.win32.join(baseDir, 'Programs', 'SubMiner');
const appExe = path.win32.join(installDir, 'SubMiner.exe');
process.env.SUBMINER_BINARY_PATH = installDir;
fs.mkdirSync(installDir, { recursive: true });
fs.writeFileSync(appExe, '#!/bin/sh\nexit 0\n');
fs.chmodSync(appExe, 0o755);
const originalPlatform = process.platform;
try {
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
const result = findAppBinary(
path.win32.join(baseDir, 'launcher', 'SubMiner.exe'),
path.win32,
);
assert.equal(result, appExe);
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
}
} finally {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true });
os.homedir = originalHomedir;
if (originalSubminerBinaryPath === undefined) {
delete process.env.SUBMINER_BINARY_PATH;
} else {
process.env.SUBMINER_BINARY_PATH = originalSubminerBinaryPath;
}
fs.rmSync(baseDir, { recursive: true, force: true });
}
} finally {
os.homedir = originalHomedir;
if (originalSubminerBinaryPath === undefined) {
delete process.env.SUBMINER_BINARY_PATH;
} else {
process.env.SUBMINER_BINARY_PATH = originalSubminerBinaryPath;
}
fs.rmSync(baseDir, { recursive: true, force: true });
}
});
},
);

View File

@@ -18,6 +18,7 @@ import {
uniqueNormalizedLangCodes,
sleep,
normalizeLangCode,
realpathMaybe,
} from './util.js';
export const state = {
@@ -226,6 +227,7 @@ export function makeTempDir(prefix: string): string {
export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
if (backend !== 'auto') return backend;
if (process.platform === 'win32') return 'windows';
if (process.platform === 'darwin') return 'macos';
const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase();
const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || '').toLowerCase();
@@ -362,8 +364,12 @@ export function findAppBinary(selfPath: string, pathModule: PathModule = path):
} else if (process.platform === 'darwin') {
candidates.push('/Applications/SubMiner.app/Contents/MacOS/SubMiner');
candidates.push('/Applications/SubMiner.app/Contents/MacOS/subminer');
candidates.push(pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'));
candidates.push(pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'));
candidates.push(
pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'),
);
candidates.push(
pathModule.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'),
);
} else {
candidates.push(pathModule.join(os.homedir(), '.local/bin/SubMiner.AppImage'));
candidates.push('/opt/SubMiner/SubMiner.AppImage');
@@ -380,8 +386,8 @@ export function findAppBinary(selfPath: string, pathModule: PathModule = path):
);
if (fromPath) {
const resolvedSelf = pathModule.resolve(selfPath);
const resolvedCandidate = pathModule.resolve(fromPath);
const resolvedSelf = realpathMaybe(selfPath);
const resolvedCandidate = realpathMaybe(fromPath);
if (resolvedSelf !== resolvedCandidate) return fromPath;
}
@@ -703,7 +709,9 @@ export async function startMpv(
mpvArgs.push(`--input-ipc-server=${socketPath}`);
mpvArgs.push(target);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
normalizeWindowsShellArgs: false,
});
state.mpvProc = spawn(mpvTarget.command, mpvTarget.args, { stdio: 'inherit' });
}
@@ -1145,7 +1153,9 @@ export function launchMpvIdleDetached(
);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(`--input-ipc-server=${socketPath}`);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
normalizeWindowsShellArgs: false,
});
const proc = spawn(mpvTarget.command, mpvTarget.args, {
stdio: 'ignore',
detached: true,

View File

@@ -116,6 +116,36 @@ test('ensureLauncherSetupReady bypasses setup gate when external yomitan is conf
assert.deepEqual(calls, []);
});
test('ensureLauncherSetupReady bypasses setup gate when plugin is already installed', async () => {
const calls: string[] = [];
const ready = await ensureLauncherSetupReady({
readSetupState: () => ({
version: 3,
status: 'cancelled',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
}),
isPluginInstalled: () => true,
launchSetupApp: () => {
calls.push('launch');
},
sleep: async () => undefined,
now: () => 0,
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(ready, true);
assert.deepEqual(calls, []);
});
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
const result = await ensureLauncherSetupReady({
readSetupState: () => ({
@@ -132,10 +162,73 @@ test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
}),
launchSetupApp: () => undefined,
sleep: async () => undefined,
now: () => 0,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(result, false);
});
test('ensureLauncherSetupReady ignores stale cancelled state after launching setup app', async () => {
let reads = 0;
const result = await ensureLauncherSetupReady({
readSetupState: () => {
reads += 1;
if (reads <= 2) {
return {
version: 3,
status: 'cancelled',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
};
}
if (reads === 3) {
return {
version: 3,
status: 'in_progress',
completedAt: null,
completionSource: null,
yomitanSetupMode: null,
lastSeenYomitanDictionaryCount: 0,
pluginInstallStatus: 'unknown',
pluginInstallPathSummary: null,
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
};
}
return {
version: 3,
status: 'completed',
completedAt: '2026-03-07T00:00:00.000Z',
completionSource: 'legacy_auto_detected',
yomitanSetupMode: 'internal',
lastSeenYomitanDictionaryCount: 1,
pluginInstallStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
windowsMpvShortcutPreferences: { startMenuEnabled: true, desktopEnabled: true },
windowsMpvShortcutLastStatus: 'unknown',
};
},
launchSetupApp: () => undefined,
sleep: async () => undefined,
now: (() => {
let value = 0;
return () => (value += 100);
})(),
timeoutMs: 5_000,
pollIntervalMs: 100,
});
assert.equal(result, true);
});

View File

@@ -6,15 +6,24 @@ export async function waitForSetupCompletion(deps: {
now: () => number;
timeoutMs: number;
pollIntervalMs: number;
ignoreInitialCancelledState?: boolean;
}): Promise<'completed' | 'cancelled' | 'timeout'> {
const deadline = deps.now() + deps.timeoutMs;
let ignoringCancelled = deps.ignoreInitialCancelledState === true;
while (deps.now() <= deadline) {
const state = deps.readSetupState();
if (isSetupCompleted(state)) {
return 'completed';
}
if (ignoringCancelled && state != null && state.status !== 'cancelled') {
ignoringCancelled = false;
}
if (state?.status === 'cancelled') {
if (ignoringCancelled) {
await deps.sleep(deps.pollIntervalMs);
continue;
}
return 'cancelled';
}
await deps.sleep(deps.pollIntervalMs);
@@ -26,6 +35,7 @@ export async function waitForSetupCompletion(deps: {
export async function ensureLauncherSetupReady(deps: {
readSetupState: () => SetupState | null;
isExternalYomitanConfigured?: () => boolean;
isPluginInstalled?: () => boolean;
launchSetupApp: () => void;
sleep: (ms: number) => Promise<void>;
now: () => number;
@@ -35,11 +45,18 @@ export async function ensureLauncherSetupReady(deps: {
if (deps.isExternalYomitanConfigured?.()) {
return true;
}
if (isSetupCompleted(deps.readSetupState())) {
if (deps.isPluginInstalled?.()) {
return true;
}
const initialState = deps.readSetupState();
if (isSetupCompleted(initialState)) {
return true;
}
deps.launchSetupApp();
const result = await waitForSetupCompletion(deps);
const result = await waitForSetupCompletion({
...deps,
ignoreInitialCancelledState: initialState?.status === 'cancelled',
});
return result === 'completed';
}

View File

@@ -79,7 +79,7 @@ function createSmokeCase(name: string): SmokeCase {
writeExecutable(
fakeMpvPath,
`#!/usr/bin/env node
`#!/usr/bin/env bun
const fs = require('node:fs');
const net = require('node:net');
const path = require('node:path');
@@ -118,7 +118,7 @@ process.on('SIGTERM', closeAndExit);
writeExecutable(
fakeAppPath,
`#!/usr/bin/env node
`#!/usr/bin/env bun
const fs = require('node:fs');
const logPath = ${JSON.stringify(fakeAppLogPath)};

View File

@@ -68,7 +68,7 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
] as const;
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type Backend = 'auto' | 'hyprland' | 'x11' | 'macos';
export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
export interface LauncherAiConfig {

View File

@@ -244,13 +244,19 @@ export function inferWhisperLanguage(langCodes: string[], fallback: string): str
return fallback;
}
export interface CommandInvocationOptions {
normalizeWindowsShellArgs?: boolean;
}
export function resolveCommandInvocation(
executable: string,
args: string[],
options: CommandInvocationOptions = {},
): { command: string; args: string[] } {
if (process.platform !== 'win32') {
return { command: executable, args };
}
const { normalizeWindowsShellArgs = true } = options;
const resolvedExecutable = resolveExecutablePath(executable) ?? executable;
const extension = path.extname(resolvedExecutable).toLowerCase();
@@ -267,7 +273,9 @@ export function resolveCommandInvocation(
command: bashTarget.command,
args: [
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
...args.map((arg) =>
normalizeWindowsShellArgs ? normalizeWindowsShellArg(arg, bashTarget.flavor) : arg,
),
],
};
}
@@ -280,7 +288,9 @@ export function resolveCommandInvocation(
command: bashTarget.command,
args: [
normalizeWindowsShellArg(resolvedExecutable, bashTarget.flavor),
...args.map((arg) => normalizeWindowsShellArg(arg, bashTarget.flavor)),
...args.map((arg) =>
normalizeWindowsShellArgs ? normalizeWindowsShellArg(arg, bashTarget.flavor) : arg,
),
],
};
}

View File

@@ -1,6 +1,6 @@
{
"name": "subminer",
"version": "0.10.0",
"version": "0.11.0",
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
"packageManager": "bun@1.3.5",
"main": "dist/main-entry.js",
@@ -16,7 +16,7 @@
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --outfile=dist/launcher/subminer",
"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": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && 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",
@@ -38,8 +38,8 @@
"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:src": "bun test src/config/config.test.ts src/config/path-resolution.test.ts src/config/resolve/anki-connect.test.ts src/config/resolve/integrations.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/integrations.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:plugin:src": "lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-binary-windows.lua",
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
@@ -123,7 +123,7 @@
"productName": "SubMiner",
"executableName": "SubMiner",
"artifactName": "SubMiner-${version}.${ext}",
"icon": "assets/SubMiner.png",
"icon": "assets/SubMiner-square.png",
"directories": {
"output": "release"
},
@@ -142,7 +142,7 @@
"zip"
],
"category": "public.app-category.video",
"icon": "assets/SubMiner.png",
"icon": "assets/SubMiner-square.png",
"hardenedRuntime": true,
"entitlements": "build/entitlements.mac.plist",
"entitlementsInherit": "build/entitlements.mac.plist",
@@ -158,7 +158,7 @@
"nsis",
"zip"
],
"icon": "assets/SubMiner.png"
"icon": "assets/SubMiner.ico"
},
"nsis": {
"oneClick": false,

View File

@@ -18,7 +18,7 @@ texthooker_enabled=yes
# Texthooker WebSocket port
texthooker_port=5174
# Window manager backend: auto, hyprland, sway, x11
# Window manager backend: auto, hyprland, sway, x11, macos, windows
# "auto" detects based on environment variables
backend=auto

View File

@@ -38,7 +38,11 @@ const lanes: Record<string, LaneConfig> = {
},
};
function collectFiles(rootDir: string, includeSuffixes: string[], excludeSet: Set<string>): string[] {
function collectFiles(
rootDir: string,
includeSuffixes: string[],
excludeSet: Set<string>,
): string[] {
const out: string[] = [];
const visit = (currentDir: string) => {
for (const entry of readdirSync(currentDir, { withFileTypes: true })) {
@@ -145,7 +149,12 @@ function parseLcovReport(report: string): LcovRecord[] {
}
if (line.startsWith('BRDA:')) {
const [lineNumber, block, branch, hits] = line.slice(5).split(',');
if (lineNumber === undefined || block === undefined || branch === undefined || hits === undefined) {
if (
lineNumber === undefined ||
block === undefined ||
branch === undefined ||
hits === undefined
) {
continue;
}
ensureCurrent().branches.set(`${lineNumber}:${block}:${branch}`, {
@@ -224,7 +233,9 @@ export function mergeLcovReports(reports: string[]): string {
chunks.push(`FNDA:${record.functionHits.get(name) ?? 0},${name}`);
}
chunks.push(`FNF:${functions.length}`);
chunks.push(`FNH:${functions.filter(([name]) => (record.functionHits.get(name) ?? 0) > 0).length}`);
chunks.push(
`FNH:${functions.filter(([name]) => (record.functionHits.get(name) ?? 0) > 0).length}`,
);
const branches = [...record.branches.values()].sort((a, b) =>
a.line === b.line
@@ -298,7 +309,9 @@ function runCoverageLane(): number {
}
writeFileSync(join(coverageDir, 'lcov.info'), mergeLcovReports(reports), 'utf8');
process.stdout.write(`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`);
process.stdout.write(
`Merged LCOV written to ${relative(repoRoot, join(coverageDir, 'lcov.info'))}\n`,
);
return 0;
} finally {
rmSync(shardRoot, { recursive: true, force: true });

View File

@@ -369,7 +369,8 @@ export class AnkiIntegration {
trackLastAddedDuplicateNoteIds: (noteId, duplicateNoteIds) => {
this.trackedDuplicateNoteIds.set(noteId, [...duplicateNoteIds]);
},
findDuplicateNoteIds: (expression, noteInfo) => this.findDuplicateNoteIds(expression, noteInfo),
findDuplicateNoteIds: (expression, noteInfo) =>
this.findDuplicateNoteIds(expression, noteInfo),
recordCardsMinedCallback: (count, noteIds) => {
this.recordCardsMinedSafely(count, noteIds, 'card creation');
},
@@ -1082,10 +1083,7 @@ export class AnkiIntegration {
});
}
private async findDuplicateNoteIds(
expression: string,
noteInfo: NoteInfo,
): Promise<number[]> {
private async findDuplicateNoteIds(expression: string, noteInfo: NoteInfo): Promise<number[]> {
return findDuplicateNoteIdsForAnkiIntegration(expression, -1, noteInfo, {
findNotes: async (query, options) => (await this.client.findNotes(query, options)) as unknown,
notesInfo: async (noteIds) => (await this.client.notesInfo(noteIds)) as unknown,

View File

@@ -162,7 +162,8 @@ export class AnkiConnectProxyServer {
}
try {
const forwardedBody = req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
const forwardedBody =
req.method === 'POST' ? this.getForwardRequestBody(rawBody, requestJson) : rawBody;
const targetUrl = new URL(req.url || '/', upstreamUrl).toString();
const contentType =
typeof req.headers['content-type'] === 'string'
@@ -272,7 +273,9 @@ export class AnkiConnectProxyServer {
private sanitizeRequestJson(requestJson: Record<string, unknown>): Record<string, unknown> {
const action =
typeof requestJson.action === 'string' ? requestJson.action : String(requestJson.action ?? '');
typeof requestJson.action === 'string'
? requestJson.action
: String(requestJson.action ?? '');
if (action !== 'addNote') {
return requestJson;
}
@@ -301,9 +304,13 @@ export class AnkiConnectProxyServer {
const rawNoteIds = Array.isArray(params?.subminerDuplicateNoteIds)
? params.subminerDuplicateNoteIds
: [];
return [...new Set(rawNoteIds.filter((entry): entry is number => {
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
}))].sort((left, right) => left - right);
return [
...new Set(
rawNoteIds.filter((entry): entry is number => {
return typeof entry === 'number' && Number.isInteger(entry) && entry > 0;
}),
),
].sort((left, right) => left - right);
}
private requestIncludesAddAction(action: string, requestJson: Record<string, unknown>): boolean {

View File

@@ -113,10 +113,7 @@ interface CardCreationDeps {
setUpdateInProgress: (value: boolean) => void;
trackLastAddedNoteId?: (noteId: number) => void;
trackLastAddedDuplicateNoteIds?: (noteId: number, duplicateNoteIds: number[]) => void;
findDuplicateNoteIds?: (
expression: string,
noteInfo: CardCreationNoteInfo,
) => Promise<number[]>;
findDuplicateNoteIds?: (expression: string, noteInfo: CardCreationNoteInfo) => Promise<number[]>;
recordCardsMinedCallback?: (count: number, noteIds?: number[]) => void;
}
@@ -573,10 +570,7 @@ export class CardCreationService {
await this.deps.findDuplicateNoteIds(pendingExpressionText, pendingNoteInfo),
);
} catch (error) {
log.warn(
'Failed to capture pre-add duplicate note ids:',
(error as Error).message,
);
log.warn('Failed to capture pre-add duplicate note ids:', (error as Error).message);
}
}
@@ -728,9 +722,7 @@ export class CardCreationService {
private createPendingNoteInfo(fields: Record<string, string>): CardCreationNoteInfo {
return {
noteId: -1,
fields: Object.fromEntries(
Object.entries(fields).map(([name, value]) => [name, { value }]),
),
fields: Object.fromEntries(Object.entries(fields).map(([name, value]) => [name, { value }])),
};
}

View File

@@ -307,21 +307,27 @@ test('findDuplicateNoteIds returns no matches when maxMatches is zero', async ()
};
let notesInfoCalls = 0;
const duplicateIds = await findDuplicateNoteIds('貴様', 100, currentNote, {
findNotes: async () => [200],
notesInfo: async (noteIds) => {
notesInfoCalls += 1;
return noteIds.map((noteId) => ({
noteId,
fields: {
Expression: { value: '貴様' },
},
}));
const duplicateIds = await findDuplicateNoteIds(
'貴様',
100,
currentNote,
{
findNotes: async () => [200],
notesInfo: async (noteIds) => {
notesInfoCalls += 1;
return noteIds.map((noteId) => ({
noteId,
fields: {
Expression: { value: '貴様' },
},
}));
},
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
},
getDeck: () => 'Japanese::Mining',
resolveFieldName: (noteInfo, preferredName) => createFieldResolver(noteInfo, preferredName),
logWarn: () => {},
}, 0);
0,
);
assert.deepEqual(duplicateIds, []);
assert.equal(notesInfoCalls, 0);

View File

@@ -24,13 +24,7 @@ export async function findDuplicateNote(
noteInfo: NoteInfo,
deps: DuplicateDetectionDeps,
): Promise<number | null> {
const duplicateNoteIds = await findDuplicateNoteIds(
expression,
excludeNoteId,
noteInfo,
deps,
1,
);
const duplicateNoteIds = await findDuplicateNoteIds(expression, excludeNoteId, noteInfo, deps, 1);
return duplicateNoteIds[0] ?? null;
}

View File

@@ -35,7 +35,7 @@ const {
startupWarmups,
auto_start_overlay,
} = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
INTEGRATIONS_DEFAULT_CONFIG;
const { subtitleStyle, subtitleSidebar } = SUBTITLE_DEFAULT_CONFIG;
const { immersionTracking } = IMMERSION_DEFAULT_CONFIG;
@@ -60,6 +60,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
auto_start_overlay,
jimaku,
anilist,
mpv,
yomitan,
jellyfin,
discordPresence,

View File

@@ -5,6 +5,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
| 'ankiConnect'
| 'jimaku'
| 'anilist'
| 'mpv'
| 'yomitan'
| 'jellyfin'
| 'discordPresence'
@@ -90,6 +91,9 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
languagePreference: 'ja',
maxEntryResults: 10,
},
mpv: {
executablePath: '',
},
anilist: {
enabled: false,
accessToken: '',

View File

@@ -28,6 +28,7 @@ test('config option registry includes critical paths and has unique entries', ()
'ankiConnect.enabled',
'anilist.characterDictionary.enabled',
'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath',
'yomitan.externalProfilePath',
'immersionTracking.enabled',
]) {
@@ -48,6 +49,7 @@ test('config template sections include expected domains and unique keys', () =>
'subtitleStyle',
'ankiConnect',
'yomitan',
'mpv',
'immersionTracking',
];

View File

@@ -238,6 +238,13 @@ export function buildIntegrationConfigOptionRegistry(
description:
'Optional external Yomitan Electron profile path to use in read-only mode for shared dictionaries/settings. Example: ~/.config/gsm_overlay',
},
{
path: 'mpv.executablePath',
kind: 'string',
defaultValue: defaultConfig.mpv.executablePath,
description:
'Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',

View File

@@ -131,7 +131,9 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
},
{
title: 'YouTube Playback Settings',
description: ['Defaults for managed subtitle language preferences and YouTube subtitle loading.'],
description: [
'Defaults for managed subtitle language preferences and YouTube subtitle loading.',
],
key: 'youtube',
},
{
@@ -153,6 +155,14 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
],
key: 'yomitan',
},
{
title: 'MPV Launcher',
description: [
'Optional mpv.exe override for Windows playback entry points.',
'Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.',
],
key: 'mpv',
},
{
title: 'Jellyfin',
description: [

View File

@@ -0,0 +1,31 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { resolveConfig } from '../resolve';
test('resolveConfig trims configured mpv executable path', () => {
const { resolved, warnings } = resolveConfig({
mpv: {
executablePath: ' C:\\Program Files\\mpv\\mpv.exe ',
},
});
assert.equal(resolved.mpv.executablePath, 'C:\\Program Files\\mpv\\mpv.exe');
assert.deepEqual(warnings, []);
});
test('resolveConfig warns for invalid mpv executable path type', () => {
const { resolved, warnings } = resolveConfig({
mpv: {
executablePath: 42 as never,
},
});
assert.equal(resolved.mpv.executablePath, '');
assert.equal(warnings.length, 1);
assert.deepEqual(warnings[0], {
path: 'mpv.executablePath',
value: 42,
fallback: '',
message: 'Expected string.',
});
});

View File

@@ -228,6 +228,22 @@ export function applyIntegrationConfig(context: ResolveContext): void {
warn('yomitan', src.yomitan, resolved.yomitan, 'Expected object.');
}
if (isObject(src.mpv)) {
const executablePath = asString(src.mpv.executablePath);
if (executablePath !== undefined) {
resolved.mpv.executablePath = executablePath.trim();
} else if (src.mpv.executablePath !== undefined) {
warn(
'mpv.executablePath',
src.mpv.executablePath,
resolved.mpv.executablePath,
'Expected string.',
);
}
} else if (src.mpv !== undefined) {
warn('mpv', src.mpv, resolved.mpv, 'Expected object.');
}
if (isObject(src.jellyfin)) {
const enabled = asBoolean(src.jellyfin.enabled);
if (enabled !== undefined) {

View File

@@ -83,7 +83,9 @@ const PRESENCE_STYLES: Record<DiscordPresenceStylePreset, PresenceStyleDefinitio
},
};
function resolvePresenceStyle(preset: DiscordPresenceStylePreset | undefined): PresenceStyleDefinition {
function resolvePresenceStyle(
preset: DiscordPresenceStylePreset | undefined,
): PresenceStyleDefinition {
return PRESENCE_STYLES[preset ?? 'default'] ?? PRESENCE_STYLES.default;
}
@@ -130,9 +132,7 @@ export function buildDiscordPresenceActivity(
const status = buildStatus(snapshot);
const title = sanitizeText(snapshot.mediaTitle, basename(snapshot.mediaPath) || 'Unknown media');
const details =
snapshot.connected && snapshot.mediaPath
? trimField(title)
: style.fallbackDetails;
snapshot.connected && snapshot.mediaPath ? trimField(title) : style.fallbackDetails;
const timeline = `${formatClock(snapshot.currentTimeSec)} / ${formatClock(snapshot.mediaDurationSec)}`;
const state =
snapshot.connected && snapshot.mediaPath
@@ -157,10 +157,7 @@ export function buildDiscordPresenceActivity(
if (style.smallImageText.trim().length > 0) {
activity.smallImageText = trimField(style.smallImageText.trim());
}
if (
style.buttonLabel.trim().length > 0 &&
/^https?:\/\//.test(style.buttonUrl.trim())
) {
if (style.buttonLabel.trim().length > 0 && /^https?:\/\//.test(style.buttonUrl.trim())) {
activity.buttons = [
{
label: trimField(style.buttonLabel.trim(), 32),

View File

@@ -380,42 +380,22 @@ export class ImmersionTrackerService {
};
};
const eventsRetention = daysToRetentionWindow(
retention.eventsDays,
7,
3650,
);
const telemetryRetention = daysToRetentionWindow(
retention.telemetryDays,
30,
3650,
);
const sessionsRetention = daysToRetentionWindow(
retention.sessionsDays,
30,
3650,
);
const eventsRetention = daysToRetentionWindow(retention.eventsDays, 7, 3650);
const telemetryRetention = daysToRetentionWindow(retention.telemetryDays, 30, 3650);
const sessionsRetention = daysToRetentionWindow(retention.sessionsDays, 30, 3650);
this.eventsRetentionMs = eventsRetention.ms;
this.eventsRetentionDays = eventsRetention.days;
this.telemetryRetentionMs = telemetryRetention.ms;
this.telemetryRetentionDays = telemetryRetention.days;
this.sessionsRetentionMs = sessionsRetention.ms;
this.sessionsRetentionDays = sessionsRetention.days;
this.dailyRollupRetentionMs = daysToRetentionWindow(
retention.dailyRollupsDays,
365,
36500,
).ms;
this.dailyRollupRetentionMs = daysToRetentionWindow(retention.dailyRollupsDays, 365, 36500).ms;
this.monthlyRollupRetentionMs = daysToRetentionWindow(
retention.monthlyRollupsDays,
5 * 365,
36500,
).ms;
this.vacuumIntervalMs = daysToRetentionWindow(
retention.vacuumIntervalDays,
7,
3650,
).ms;
this.vacuumIntervalMs = daysToRetentionWindow(retention.vacuumIntervalDays, 7, 3650).ms;
this.db = new Database(this.dbPath);
applyPragmas(this.db);
ensureSchema(this.db);

View File

@@ -975,79 +975,79 @@ test('getTrendsDashboard month grouping spans every touched calendar month and k
);
}
const insertDailyRollup = db.prepare(
`
const insertDailyRollup = db.prepare(
`
INSERT INTO imm_daily_rollups (
rollup_day, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
const insertMonthlyRollup = db.prepare(
`
);
const insertMonthlyRollup = db.prepare(
`
INSERT INTO imm_monthly_rollups (
rollup_month, video_id, total_sessions, total_active_min, total_lines_seen,
total_tokens_seen, total_cards, CREATED_DATE, LAST_UPDATE_DATE
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
);
insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
);
insertDailyRollup.run(20500, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertDailyRollup.run(20513, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
insertMonthlyRollup.run(202602, febVideoId, 1, 30, 4, 100, 2, febStartedAtMs, febStartedAtMs);
insertMonthlyRollup.run(202603, marVideoId, 1, 30, 4, 120, 4, marStartedAtMs, marStartedAtMs);
db.prepare(
`
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
'二月',
'二月',
'にがつ',
'noun',
'名詞',
'',
'',
(BigInt(febStartedAtMs) / 1000n).toString(),
(BigInt(febStartedAtMs) / 1000n).toString(),
1,
);
db.prepare(
`
).run(
'二月',
'二月',
'にがつ',
'noun',
'名詞',
'',
'',
(BigInt(febStartedAtMs) / 1000n).toString(),
(BigInt(febStartedAtMs) / 1000n).toString(),
1,
);
db.prepare(
`
INSERT INTO imm_words (
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
'三月',
'三月',
'さんがつ',
'noun',
'名詞',
'',
'',
(BigInt(marStartedAtMs) / 1000n).toString(),
(BigInt(marStartedAtMs) / 1000n).toString(),
1,
);
).run(
'三月',
'三月',
'さんがつ',
'noun',
'名詞',
'',
'',
(BigInt(marStartedAtMs) / 1000n).toString(),
(BigInt(marStartedAtMs) / 1000n).toString(),
1,
);
const dashboard = getTrendsDashboard(db, '30d', 'month');
const dashboard = getTrendsDashboard(db, '30d', 'month');
assert.equal(dashboard.activity.watchTime.length, 2);
assert.deepEqual(
dashboard.progress.newWords.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.deepEqual(
dashboard.progress.episodes.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.deepEqual(
dashboard.progress.lookups.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.equal(dashboard.activity.watchTime.length, 2);
assert.deepEqual(
dashboard.progress.newWords.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.deepEqual(
dashboard.progress.episodes.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
assert.deepEqual(
dashboard.progress.lookups.map((point) => point.label),
dashboard.activity.watchTime.map((point) => point.label),
);
} finally {
db.close();
cleanupDbPath(dbPath);
@@ -1230,18 +1230,7 @@ test('getQueryHints counts new words by distinct headword first-seen time', () =
headword, word, reading, part_of_speech, pos1, pos2, pos3, first_seen, last_seen, frequency
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
).run(
'猫',
'猫',
'ねこ',
'noun',
'名詞',
'',
'',
String(twoDaysAgo),
String(twoDaysAgo),
1,
);
).run('猫', '猫', 'ねこ', 'noun', '名詞', '', '', String(twoDaysAgo), String(twoDaysAgo), 1);
const hints = getQueryHints(db);
assert.equal(hints.newWordsToday, 1);

View File

@@ -82,12 +82,9 @@ function hasRetainedPriorSession(
LIMIT 1
`,
)
.get(
videoId,
toDbTimestamp(startedAtMs),
toDbTimestamp(startedAtMs),
currentSessionId,
) as { found: number } | null;
.get(videoId, toDbTimestamp(startedAtMs), toDbTimestamp(startedAtMs), currentSessionId) as {
found: number;
} | null;
return Boolean(row);
}
@@ -150,7 +147,7 @@ function resetLifetimeSummaries(db: DatabaseSync, nowMs: number): void {
LAST_UPDATE_DATE = ?
WHERE global_id = 1
`,
).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
).run(toDbTimestamp(nowMs), toDbTimestamp(nowMs));
}
function rebuildLifetimeSummariesInternal(

View File

@@ -126,9 +126,9 @@ test('pruneRawRetention skips disabled retention windows', () => {
const remainingTelemetry = db
.prepare('SELECT COUNT(*) AS count FROM imm_session_telemetry')
.get() as { count: number };
const remainingSessions = db
.prepare('SELECT COUNT(*) AS count FROM imm_sessions')
.get() as { count: number };
const remainingSessions = db.prepare('SELECT COUNT(*) AS count FROM imm_sessions').get() as {
count: number;
};
assert.equal(result.deletedSessionEvents, 0);
assert.equal(result.deletedTelemetryRows, 0);

View File

@@ -56,10 +56,7 @@ export function pruneRawRetention(
sessionsRetentionDays?: number;
},
): RawRetentionResult {
const resolveCutoff = (
retentionMs: number,
retentionDays: number | undefined,
): string => {
const resolveCutoff = (retentionMs: number, retentionDays: number | undefined): string => {
if (retentionDays !== undefined) {
return subtractDbTimestamp(currentMs, BigInt(retentionDays) * 86_400_000n);
}
@@ -68,9 +65,11 @@ export function pruneRawRetention(
const deletedSessionEvents = Number.isFinite(policy.eventsRetentionMs)
? (
db.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`).run(
resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays),
) as { changes: number }
db
.prepare(`DELETE FROM imm_session_events WHERE ts_ms < ?`)
.run(resolveCutoff(policy.eventsRetentionMs, policy.eventsRetentionDays)) as {
changes: number;
}
).changes
: 0;
const deletedTelemetryRows = Number.isFinite(policy.telemetryRetentionMs)

View File

@@ -150,9 +150,11 @@ export function getSessionEvents(
ORDER BY ts_ms ASC
LIMIT ?
`);
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<SessionEventRow & {
tsMs: number | string;
}>;
const rows = stmt.all(sessionId, ...eventTypes, limit) as Array<
SessionEventRow & {
tsMs: number | string;
}
>;
return rows.map((row) => ({
...row,
tsMs: fromDbTimestamp(row.tsMs) ?? 0,

View File

@@ -355,9 +355,7 @@ export function upsertCoverArt(
const fetchedAtMs = toDbTimestamp(nowMs());
const coverBlob = normalizeCoverBlobBytes(art.coverBlob);
const computedCoverBlobHash =
coverBlob && coverBlob.length > 0
? createHash('sha256').update(coverBlob).digest('hex')
: null;
coverBlob && coverBlob.length > 0 ? createHash('sha256').update(coverBlob).digest('hex') : null;
let coverBlobHash = computedCoverBlobHash ?? sharedCoverBlobHash ?? null;
if (!coverBlobHash && (!coverBlob || coverBlob.length === 0)) {
coverBlobHash = existing?.coverBlobHash ?? null;

View File

@@ -39,10 +39,12 @@ export function getSessionSummaries(db: DatabaseSync, limit = 50): SessionSummar
ORDER BY s.started_at_ms DESC
LIMIT ?
`);
const rows = prepared.all(limit) as Array<SessionSummaryQueryRow & {
startedAtMs: number | string;
endedAtMs: number | string | null;
}>;
const rows = prepared.all(limit) as Array<
SessionSummaryQueryRow & {
startedAtMs: number | string;
endedAtMs: number | string | null;
}
>;
return rows.map((row) => ({
...row,
startedAtMs: fromDbTimestamp(row.startedAtMs) ?? 0,
@@ -69,19 +71,21 @@ export function getSessionTimeline(
`;
if (limit === undefined) {
const rows = db.prepare(select).all(sessionId) as Array<SessionTimelineRow & {
sampleMs: number | string;
}>;
const rows = db.prepare(select).all(sessionId) as Array<
SessionTimelineRow & {
sampleMs: number | string;
}
>;
return rows.map((row) => ({
...row,
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,
}));
}
const rows = db
.prepare(`${select}\n LIMIT ?`)
.all(sessionId, limit) as Array<SessionTimelineRow & {
sampleMs: number | string;
}>;
const rows = db.prepare(`${select}\n LIMIT ?`).all(sessionId, limit) as Array<
SessionTimelineRow & {
sampleMs: number | string;
}
>;
return rows.map((row) => ({
...row,
sampleMs: fromDbTimestamp(row.sampleMs) ?? 0,

View File

@@ -359,10 +359,7 @@ function getNumericCalendarValue(
return Number(row?.value ?? 0);
}
export function getLocalEpochDay(
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
export function getLocalEpochDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
return getNumericCalendarValue(
db,
`
@@ -375,10 +372,7 @@ export function getLocalEpochDay(
);
}
export function getLocalMonthKey(
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
export function getLocalMonthKey(db: DatabaseSync, timestampMs: number | bigint | string): number {
return getNumericCalendarValue(
db,
`
@@ -391,10 +385,7 @@ export function getLocalMonthKey(
);
}
export function getLocalDayOfWeek(
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
export function getLocalDayOfWeek(db: DatabaseSync, timestampMs: number | bigint | string): number {
return getNumericCalendarValue(
db,
`
@@ -407,10 +398,7 @@ export function getLocalDayOfWeek(
);
}
export function getLocalHourOfDay(
db: DatabaseSync,
timestampMs: number | bigint | string,
): number {
export function getLocalHourOfDay(db: DatabaseSync, timestampMs: number | bigint | string): number {
return getNumericCalendarValue(
db,
`
@@ -458,7 +446,8 @@ export function getShiftedLocalDayTimestamp(
dayOffset: number,
): string {
const normalizedDayOffset = Math.trunc(dayOffset);
const modifier = normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
const modifier =
normalizedDayOffset >= 0 ? `+${normalizedDayOffset} days` : `${normalizedDayOffset} days`;
const row = db
.prepare(
`

View File

@@ -87,7 +87,20 @@ const TREND_DAY_LIMITS: Record<Exclude<TrendRange, 'all'>, number> = {
'90d': 90,
};
const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const MONTH_NAMES = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
@@ -101,7 +114,11 @@ function getTrendMonthlyLimit(db: DatabaseSync, range: TrendRange): number {
}
const currentTimestamp = currentDbTimestamp();
const todayStartMs = getShiftedLocalDayTimestamp(db, currentTimestamp, 0);
const cutoffMs = getShiftedLocalDayTimestamp(db, currentTimestamp, -(TREND_DAY_LIMITS[range] - 1));
const cutoffMs = getShiftedLocalDayTimestamp(
db,
currentTimestamp,
-(TREND_DAY_LIMITS[range] - 1),
);
const currentMonthKey = getLocalMonthKey(db, todayStartMs);
const cutoffMonthKey = getLocalMonthKey(db, cutoffMs);
const currentYear = Math.floor(currentMonthKey / 100);
@@ -630,8 +647,10 @@ export function getTrendsDashboard(
const animePerDay = {
episodes: buildEpisodesPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId),
watchTime: buildPerAnimeFromDailyRollups(dailyRollups, titlesByVideoId, (rollup) =>
rollup.totalActiveMin,
watchTime: buildPerAnimeFromDailyRollups(
dailyRollups,
titlesByVideoId,
(rollup) => rollup.totalActiveMin,
),
cards: buildPerAnimeFromDailyRollups(
dailyRollups,

View File

@@ -102,7 +102,9 @@ type SubtitleTrackCandidate = {
externalFilename: string | null;
};
function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): SubtitleTrackCandidate | null {
function normalizeSubtitleTrackCandidate(
track: Record<string, unknown>,
): SubtitleTrackCandidate | null {
const id =
typeof track.id === 'number'
? track.id
@@ -122,8 +124,12 @@ function normalizeSubtitleTrackCandidate(track: Record<string, unknown>): Subtit
return {
id,
lang: String(track.lang || '').trim().toLowerCase(),
title: String(track.title || '').trim().toLowerCase(),
lang: String(track.lang || '')
.trim()
.toLowerCase(),
title: String(track.title || '')
.trim()
.toLowerCase(),
selected: track.selected === true,
external: track.external === true,
externalFilename,
@@ -168,9 +174,7 @@ function pickSecondarySubtitleTrackId(
const uniqueTracks = [...dedupedTracks.values()];
for (const language of normalizedLanguages) {
const selectedMatch = uniqueTracks.find(
(track) => track.selected && track.lang === language,
);
const selectedMatch = uniqueTracks.find((track) => track.selected && track.lang === language);
if (selectedMatch) {
return selectedMatch.id;
}

View File

@@ -102,10 +102,7 @@ async function writeFetchResponse(res: ServerResponse, response: Response): Prom
res.end(Buffer.from(body));
}
function startNodeHttpServer(
app: Hono,
config: StatsServerConfig,
): { close: () => void } {
function startNodeHttpServer(app: Hono, config: StatsServerConfig): { close: () => void } {
const server = http.createServer((req, res) => {
void (async () => {
try {
@@ -1075,11 +1072,9 @@ export function startStatsServer(config: StatsServerConfig): { close: () => void
const bunRuntime = globalThis as typeof globalThis & {
Bun?: {
serve?: (options: {
fetch: (typeof app)['fetch'];
port: number;
hostname: string;
}) => { stop: () => void };
serve?: (options: { fetch: (typeof app)['fetch']; port: number; hostname: string }) => {
stop: () => void;
};
};
};

View File

@@ -77,7 +77,11 @@ function normalizeTrackIds(tracks: unknown[]): MpvTrack[] {
}
function getSourceTrackIdentity(track: MpvTrack): string {
if (track.external && typeof track['external-filename'] === 'string' && track['external-filename'].length > 0) {
if (
track.external &&
typeof track['external-filename'] === 'string' &&
track['external-filename'].length > 0
) {
return `external:${track['external-filename'].toLowerCase()}`;
}
if (typeof track.id === 'number') {

View File

@@ -2029,7 +2029,8 @@ export async function addYomitanNoteViaSearch(
: null,
duplicateNoteIds: Array.isArray(envelope.duplicateNoteIds)
? envelope.duplicateNoteIds.filter(
(entry): entry is number => typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
(entry): entry is number =>
typeof entry === 'number' && Number.isInteger(entry) && entry > 0,
)
: [],
};

View File

@@ -68,6 +68,15 @@ export function resolveExternalYomitanExtensionPath(
return null;
}
const candidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
return existsSync(path.join(candidate, 'manifest.json')) ? candidate : null;
const candidate = path.join(normalizedProfilePath, 'extensions', 'yomitan');
const fallbackCandidate = path.join(path.resolve(normalizedProfilePath), 'extensions', 'yomitan');
const candidates = candidate === fallbackCandidate ? [candidate] : [candidate, fallbackCandidate];
for (const root of candidates) {
if (existsSync(path.join(root, 'manifest.json'))) {
return root;
}
}
return null;
}

View File

@@ -16,8 +16,15 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
function makeFakeYtDlpScript(dir: string, payload: string): void {
const scriptPath = path.join(dir, 'yt-dlp');
const script = `#!/usr/bin/env node
const script =
process.platform === 'win32'
? `#!/usr/bin/env bun
process.stdout.write(${JSON.stringify(payload)});
`
: `#!/usr/bin/env sh
cat <<'EOF' | base64 -d
${Buffer.from(payload).toString('base64')}
EOF
`;
fs.writeFileSync(scriptPath, script, 'utf8');
if (process.platform !== 'win32') {
@@ -28,8 +35,15 @@ process.stdout.write(${JSON.stringify(payload)});
function makeHangingFakeYtDlpScript(dir: string): void {
const scriptPath = path.join(dir, 'yt-dlp');
const script = `#!/usr/bin/env node
const script =
process.platform === 'win32'
? `#!/usr/bin/env bun
setInterval(() => {}, 1000);
`
: `#!/usr/bin/env sh
while :; do
sleep 1;
done
`;
fs.writeFileSync(scriptPath, script, 'utf8');
if (process.platform !== 'win32') {
@@ -44,11 +58,19 @@ async function withFakeYtDlp<T>(payload: string, fn: () => Promise<T>): Promise<
fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir, payload);
const originalPath = process.env.PATH ?? '';
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
process.env.SUBMINER_YTDLP_BIN =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
try {
return await fn();
} finally {
process.env.PATH = originalPath;
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}
@@ -59,11 +81,19 @@ async function withHangingFakeYtDlp<T>(fn: () => Promise<T>): Promise<T> {
fs.mkdirSync(binDir, { recursive: true });
makeHangingFakeYtDlpScript(binDir);
const originalPath = process.env.PATH ?? '';
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
process.env.SUBMINER_YTDLP_BIN =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
try {
return await fn();
} finally {
process.env.PATH = originalPath;
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}

View File

@@ -1,5 +1,6 @@
import { spawn } from 'node:child_process';
import type { YoutubeVideoMetadata } from '../immersion-tracker/types';
import { getYoutubeYtDlpCommand } from './ytdlp-command';
const YOUTUBE_METADATA_PROBE_TIMEOUT_MS = 15_000;
@@ -87,7 +88,7 @@ function pickChannelThumbnail(thumbnails: YtDlpThumbnail[] | undefined): string
export async function probeYoutubeVideoMetadata(
targetUrl: string,
): Promise<YoutubeVideoMetadata | null> {
const { stdout } = await runCapture('yt-dlp', [
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
'--dump-single-json',
'--no-warnings',
'--skip-download',

View File

@@ -16,8 +16,15 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
function makeFakeYtDlpScript(dir: string, payload: string): void {
const scriptPath = path.join(dir, 'yt-dlp');
const script = `#!/usr/bin/env node
const script =
process.platform === 'win32'
? `#!/usr/bin/env bun
process.stdout.write(${JSON.stringify(payload)});
`
: `#!/usr/bin/env sh
cat <<'EOF' | base64 -d
${Buffer.from(payload).toString('base64')}
EOF
`;
fs.writeFileSync(scriptPath, script, 'utf8');
if (process.platform !== 'win32') {

View File

@@ -1,4 +1,5 @@
import { spawn } from 'node:child_process';
import { getYoutubeYtDlpCommand } from './ytdlp-command';
const YOUTUBE_PLAYBACK_RESOLVE_TIMEOUT_MS = 15_000;
const DEFAULT_PLAYBACK_FORMAT = 'b';
@@ -88,8 +89,7 @@ export async function resolveYoutubePlaybackUrl(
targetUrl: string,
format = DEFAULT_PLAYBACK_FORMAT,
): Promise<string> {
const ytDlpCommand = process.env.SUBMINER_YTDLP_BIN?.trim() || 'yt-dlp';
const { stdout } = await runCapture(ytDlpCommand, [
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
'--get-url',
'--no-warnings',
'-f',

View File

@@ -16,7 +16,7 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
function makeFakeYtDlpScript(dir: string): string {
const scriptPath = path.join(dir, 'yt-dlp');
const script = `#!/usr/bin/env node
const script = `#!/usr/bin/env bun
const fs = require('node:fs');
const path = require('node:path');
@@ -87,6 +87,87 @@ if (process.env.YTDLP_FAKE_MODE === 'multi') {
fs.writeFileSync(\`\${prefix}.vtt\`, 'WEBVTT\\n');
}
process.exit(0);
`;
fs.writeFileSync(scriptPath, script, 'utf8');
fs.chmodSync(scriptPath, 0o755);
if (process.platform === 'win32') {
fs.writeFileSync(scriptPath + '.cmd', `@echo off\r\nbun "${scriptPath}" %*\r\n`, 'utf8');
}
return scriptPath;
}
function makeFakeYtDlpShellScript(dir: string): string {
const scriptPath = path.join(dir, 'yt-dlp');
const script = `#!/bin/sh
has_auto_subs=0
wants_auto_subs=0
wants_manual_subs=0
sub_lang=''
output_template=''
while [ "$#" -gt 0 ]; do
case "$1" in
--write-auto-subs)
wants_auto_subs=1
;;
--write-subs)
wants_manual_subs=1
;;
--sub-langs)
sub_lang="$2"
shift
;;
-o)
output_template="$2"
shift
;;
esac
shift
done
if [ "$YTDLP_EXPECT_AUTO_SUBS" = "1" ] && [ "$wants_auto_subs" != "1" ]; then
exit 2
fi
if [ "$YTDLP_EXPECT_MANUAL_SUBS" = "1" ] && [ "$wants_manual_subs" != "1" ]; then
exit 3
fi
if [ -n "$YTDLP_EXPECT_SUB_LANG" ] && [ "$sub_lang" != "$YTDLP_EXPECT_SUB_LANG" ]; then
exit 4
fi
prefix="\${output_template%.%(ext)s}"
if [ -z "$prefix" ]; then
exit 1
fi
dir="\${prefix%/*}"
[ -d "$dir" ] || /bin/mkdir -p "$dir"
if [ "$YTDLP_FAKE_MODE" = "multi" ]; then
OLD_IFS="$IFS"
IFS=","
for lang in $sub_lang; do
if [ -n "$lang" ]; then
printf 'WEBVTT\\n' > "\${prefix}.\${lang}.vtt"
fi
done
IFS="$OLD_IFS"
elif [ "$YTDLP_FAKE_MODE" = "rolling-auto" ]; then
printf 'WEBVTT\\n\\n00:00:01.000 --> 00:00:02.000\\n今日は\\n\\n00:00:02.000 --> 00:00:03.000\\n今日はいい天気ですね\\n\\n00:00:03.000 --> 00:00:04.000\\n今日はいい天気ですね本当に\\n' > "\${prefix}.vtt"
elif [ "$YTDLP_FAKE_MODE" = "multi-primary-only-fail" ]; then
primary_lang="\${sub_lang%%,*}"
if [ -n "$primary_lang" ]; then
printf 'WEBVTT\\n' > "\${prefix}.\${primary_lang}.vtt"
fi
printf "ERROR: Unable to download video subtitles for 'en': HTTP Error 429: Too Many Requests\\n" 1>&2
exit 1
elif [ "$YTDLP_FAKE_MODE" = "both" ]; then
printf 'WEBVTT\\n' > "\${prefix}.vtt"
printf 'webp' > "\${prefix}.orig.webp"
elif [ "$YTDLP_FAKE_MODE" = "webp-only" ]; then
printf 'webp' > "\${prefix}.orig.webp"
else
printf 'WEBVTT\\n' > "\${prefix}.vtt"
fi
`;
fs.writeFileSync(scriptPath, script, 'utf8');
fs.chmodSync(scriptPath, 0o755);
@@ -100,7 +181,11 @@ async function withFakeYtDlp<T>(
return await withTempDir(async (root) => {
const binDir = path.join(root, 'bin');
fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir);
if (process.platform === 'win32') {
makeFakeYtDlpScript(binDir);
} else {
makeFakeYtDlpShellScript(binDir);
}
const originalPath = process.env.PATH ?? '';
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
@@ -114,6 +199,43 @@ async function withFakeYtDlp<T>(
});
}
async function withFakeYtDlpCommand<T>(
mode: 'both' | 'webp-only' | 'multi' | 'multi-primary-only-fail' | 'rolling-auto',
fn: (dir: string, binDir: string) => Promise<T>,
): Promise<T> {
return await withTempDir(async (root) => {
const binDir = path.join(root, 'bin');
fs.mkdirSync(binDir, { recursive: true });
const originalPath = process.env.PATH;
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.PATH = '';
process.env.YTDLP_FAKE_MODE = mode;
process.env.SUBMINER_YTDLP_BIN =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
if (process.platform === 'win32') {
makeFakeYtDlpScript(binDir);
} else {
makeFakeYtDlpShellScript(binDir);
}
try {
return await fn(root, binDir);
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
delete process.env.YTDLP_FAKE_MODE;
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}
async function withFakeYtDlpExpectations<T>(
expectations: Partial<
Record<'YTDLP_EXPECT_AUTO_SUBS' | 'YTDLP_EXPECT_MANUAL_SUBS' | 'YTDLP_EXPECT_SUB_LANG', string>
@@ -179,6 +301,29 @@ test('downloadYoutubeSubtitleTrack prefers subtitle files over later webp artifa
});
});
test('downloadYoutubeSubtitleTrack honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
if (process.platform === 'win32') {
return;
}
await withFakeYtDlpCommand('both', async (root) => {
const result = await downloadYoutubeSubtitleTrack({
targetUrl: 'https://www.youtube.com/watch?v=abc123',
outputDir: path.join(root, 'out'),
track: {
id: 'auto:ja-orig',
language: 'ja',
sourceLanguage: 'ja-orig',
kind: 'auto',
label: 'Japanese (auto)',
},
});
assert.equal(path.extname(result.path), '.vtt');
assert.match(path.basename(result.path), /^auto-ja-orig\./);
});
});
test('downloadYoutubeSubtitleTrack ignores stale subtitle files from prior runs', async () => {
if (process.platform === 'win32') {
return;

View File

@@ -2,6 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import { spawn } from 'node:child_process';
import type { YoutubeTrackOption } from './track-probe';
import { getYoutubeYtDlpCommand } from './ytdlp-command';
import {
convertYoutubeTimedTextToVtt,
isYoutubeTimedTextExtension,
@@ -237,7 +238,7 @@ export async function downloadYoutubeSubtitleTrack(input: {
}),
];
await runCapture('yt-dlp', args);
await runCapture(getYoutubeYtDlpCommand(), args);
const subtitlePath = pickLatestSubtitleFile(input.outputDir, prefix);
if (!subtitlePath) {
throw new Error(`No subtitle file was downloaded for ${input.track.sourceLanguage}`);
@@ -281,7 +282,7 @@ export async function downloadYoutubeSubtitleTracks(input: {
const includeManualSubs = input.tracks.some((track) => track.kind === 'manual');
const result = await runCaptureDetailed(
'yt-dlp',
getYoutubeYtDlpCommand(),
buildDownloadArgs({
targetUrl: input.targetUrl,
outputTemplate,

View File

@@ -17,10 +17,18 @@ async function withTempDir<T>(fn: (dir: string) => Promise<T>): Promise<T> {
function makeFakeYtDlpScript(dir: string, payload: unknown, rawScript = false): void {
const scriptPath = path.join(dir, 'yt-dlp');
const stdoutBody = typeof payload === 'string' ? payload : JSON.stringify(payload);
const script = rawScript
? stdoutBody
: `#!/usr/bin/env node
const script =
process.platform === 'win32'
? rawScript
? stdoutBody
: `#!/usr/bin/env bun
process.stdout.write(${JSON.stringify(stdoutBody)});
`
: `#!/bin/sh
PATH=/usr/bin:/bin:/usr/local/bin
cat <<'SUBMINER_EOF' | base64 -d
${Buffer.from(stdoutBody).toString('base64')}
SUBMINER_EOF
`;
fs.writeFileSync(scriptPath, script, 'utf8');
if (process.platform !== 'win32') {
@@ -39,11 +47,50 @@ async function withFakeYtDlp<T>(
fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
const originalPath = process.env.PATH ?? '';
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.PATH = `${binDir}${path.delimiter}${originalPath}`;
process.env.SUBMINER_YTDLP_BIN =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
try {
return await fn();
} finally {
process.env.PATH = originalPath;
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}
async function withFakeYtDlpCommand<T>(
payload: unknown,
fn: () => Promise<T>,
options: { rawScript?: boolean } = {},
): Promise<T> {
return await withTempDir(async (root) => {
const binDir = path.join(root, 'bin');
fs.mkdirSync(binDir, { recursive: true });
makeFakeYtDlpScript(binDir, payload, options.rawScript === true);
const originalPath = process.env.PATH;
const originalCommand = process.env.SUBMINER_YTDLP_BIN;
process.env.PATH = '';
process.env.SUBMINER_YTDLP_BIN =
process.platform === 'win32' ? path.join(binDir, 'yt-dlp.cmd') : path.join(binDir, 'yt-dlp');
try {
return await fn();
} finally {
if (originalPath === undefined) {
delete process.env.PATH;
} else {
process.env.PATH = originalPath;
}
if (originalCommand === undefined) {
delete process.env.SUBMINER_YTDLP_BIN;
} else {
process.env.SUBMINER_YTDLP_BIN = originalCommand;
}
}
});
}
@@ -69,6 +116,28 @@ test('probeYoutubeTracks prefers srv3 over vtt for automatic captions', async ()
);
});
test('probeYoutubeTracks honors SUBMINER_YTDLP_BIN when yt-dlp is not on PATH', async () => {
if (process.platform === 'win32') {
return;
}
await withFakeYtDlpCommand(
{
id: 'abc123',
title: 'Example',
subtitles: {
ja: [{ ext: 'vtt', url: 'https://example.com/ja.vtt', name: 'Japanese manual' }],
},
},
async () => {
const result = await probeYoutubeTracks('https://www.youtube.com/watch?v=abc123');
assert.equal(result.videoId, 'abc123');
assert.equal(result.tracks[0]?.downloadUrl, 'https://example.com/ja.vtt');
assert.equal(result.tracks[0]?.fileExtension, 'vtt');
},
);
});
test('probeYoutubeTracks keeps preferring srt for manual captions', async () => {
await withFakeYtDlp(
{

View File

@@ -1,6 +1,7 @@
import { spawn } from 'node:child_process';
import type { YoutubeTrackOption } from '../../../types';
import { formatYoutubeTrackLabel, normalizeYoutubeLangCode, type YoutubeTrackKind } from './labels';
import { getYoutubeYtDlpCommand } from './ytdlp-command';
const YOUTUBE_TRACK_PROBE_TIMEOUT_MS = 15_000;
@@ -111,7 +112,11 @@ function toTracks(entries: Record<string, YtDlpSubtitleEntry> | undefined, kind:
export type { YoutubeTrackOption };
export async function probeYoutubeTracks(targetUrl: string): Promise<YoutubeTrackProbeResult> {
const { stdout } = await runCapture('yt-dlp', ['--dump-single-json', '--no-warnings', targetUrl]);
const { stdout } = await runCapture(getYoutubeYtDlpCommand(), [
'--dump-single-json',
'--no-warnings',
targetUrl,
]);
const trimmedStdout = stdout.trim();
if (!trimmedStdout) {
throw new Error('yt-dlp returned empty output while probing subtitle tracks');

View File

@@ -0,0 +1,44 @@
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_YTDLP_COMMAND = 'yt-dlp';
const WINDOWS_YTDLP_COMMANDS = ['yt-dlp.cmd', 'yt-dlp.exe', 'yt-dlp'];
function resolveFromPath(commandName: string): string | null {
if (!process.env.PATH) {
return null;
}
const searchPaths = process.env.PATH.split(path.delimiter);
for (const searchPath of searchPaths) {
const candidate = path.join(searchPath, commandName);
try {
fs.accessSync(candidate, fs.constants.X_OK);
return candidate;
} catch {
continue;
}
}
return null;
}
export function getYoutubeYtDlpCommand(): string {
const explicitCommand = process.env.SUBMINER_YTDLP_BIN?.trim();
if (explicitCommand) {
return explicitCommand;
}
if (process.platform !== 'win32') {
return DEFAULT_YTDLP_COMMAND;
}
for (const commandName of WINDOWS_YTDLP_COMMANDS) {
const resolved = resolveFromPath(commandName);
if (resolved) {
return resolved;
}
}
return DEFAULT_YTDLP_COMMAND;
}

View File

@@ -113,6 +113,10 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
]),
['--input-ipc-server', '\\\\.\\pipe\\custom-subminer-socket', '--alang', 'ja,jpn'],
);
assert.deepEqual(
normalizeLaunchMpvExtraArgs(['SubMiner.exe', '--launch-mpv', '--fullscreen', 'C:\\a.mkv']),
['--fullscreen'],
);
assert.deepEqual(
normalizeLaunchMpvTargets([
'SubMiner.exe',
@@ -126,6 +130,20 @@ test('launch-mpv entry helpers detect and normalize targets', () => {
]),
['C:\\a.mkv', 'C:\\b.mkv'],
);
assert.deepEqual(
normalizeLaunchMpvTargets(['SubMiner.exe', '--launch-mpv', '--fullscreen', 'C:\\a.mkv']),
['C:\\a.mkv'],
);
assert.deepEqual(
normalizeLaunchMpvExtraArgs([
'SubMiner.exe',
'--launch-mpv',
'--msg-level',
'all=warn',
'C:\\a.mkv',
]),
['--msg-level', 'all=warn'],
);
});
test('stats-daemon entry helper detects internal daemon commands', () => {

View File

@@ -8,6 +8,23 @@ const START_ARG = '--start';
const PASSWORD_STORE_ARG = '--password-store';
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
const APP_NAME = 'SubMiner';
const MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES = new Set([
'--alang',
'--audio-file',
'--input-ipc-server',
'--log-file',
'--msg-level',
'--profile',
'--script',
'--script-opts',
'--scripts',
'--slang',
'--sub-file',
'--sub-file-paths',
'--title',
'--volume',
'--ytdl-format',
]);
type EarlyAppLike = {
setName: (name: string) => void;
@@ -53,6 +70,15 @@ function removePassiveStartupArgs(argv: string[]): string[] {
return filtered;
}
function consumesLaunchMpvValue(token: string): boolean {
return (
token.startsWith('--') &&
token !== '--' &&
!token.includes('=') &&
MPV_LONG_OPTIONS_WITH_SEPARATE_VALUES.has(token)
);
}
function parseCliArgs(argv: string[]): CliArgs {
return parseArgs(argv);
}
@@ -144,7 +170,7 @@ export function normalizeLaunchMpvTargets(argv: string[]): string[] {
}
if (token.startsWith('--')) {
if (!token.includes('=') && i + 1 < argv.length) {
if (consumesLaunchMpvValue(token) && i + 1 < argv.length) {
const value = argv[i + 1];
if (value && !value.startsWith('-')) {
i += 1;
@@ -179,7 +205,7 @@ export function normalizeLaunchMpvExtraArgs(argv: string[]): string[] {
}
if (token.startsWith('--')) {
extraArgs.push(token);
if (!token.includes('=') && i + 1 < argv.length) {
if (consumesLaunchMpvValue(token) && i + 1 < argv.length) {
const value = argv[i + 1];
if (value && !value.startsWith('-')) {
extraArgs.push(value);

View File

@@ -2,6 +2,7 @@ import path from 'node:path';
import { spawn } from 'node:child_process';
import { app, dialog } from 'electron';
import { printHelp } from './cli/help';
import { loadRawConfigStrict } from './config/load';
import {
configureEarlyAppPaths,
normalizeLaunchMpvExtraArgs,
@@ -35,6 +36,21 @@ function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
}
}
function readConfiguredWindowsMpvExecutablePath(configDir: string): string {
const loadResult = loadRawConfigStrict({
configDir,
configFileJsonc: path.join(configDir, 'config.jsonc'),
configFileJson: path.join(configDir, 'config.json'),
});
if (!loadResult.ok) {
return '';
}
return typeof loadResult.config.mpv?.executablePath === 'string'
? loadResult.config.mpv.executablePath.trim()
: '';
}
function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
const assets = resolvePackagedFirstRunPluginAssets({
dirname: __dirname,
@@ -50,7 +66,7 @@ function resolveBundledWindowsMpvPluginEntrypoint(): string | undefined {
process.argv = normalizeStartupArgv(process.argv, process.env);
applySanitizedEnv(sanitizeStartupEnv(process.env));
configureEarlyAppPaths(app);
const userDataPath = configureEarlyAppPaths(app);
if (shouldDetachBackgroundLaunch(process.argv, process.env)) {
const child = spawn(process.execPath, process.argv.slice(1), {
@@ -75,8 +91,8 @@ if (shouldHandleHelpOnlyAtEntry(process.argv, process.env)) {
if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
const sanitizedEnv = sanitizeLaunchMpvEnv(process.env);
applySanitizedEnv(sanitizedEnv);
void app.whenReady().then(() => {
const result = launchWindowsMpv(
void app.whenReady().then(async () => {
const result = await launchWindowsMpv(
normalizeLaunchMpvTargets(process.argv),
createWindowsMpvLaunchDeps({
getEnv: (name) => process.env[name],
@@ -87,6 +103,7 @@ if (shouldHandleLaunchMpvAtEntry(process.argv, process.env)) {
normalizeLaunchMpvExtraArgs(process.argv),
process.execPath,
resolveBundledWindowsMpvPluginEntrypoint(),
readConfiguredWindowsMpvExecutablePath(userDataPath),
);
app.exit(result.ok ? 0 : 1);
});

View File

@@ -77,15 +77,15 @@ function getStartupModeFlags(initialArgs: CliArgs | null | undefined): {
return {
shouldUseMinimalStartup: Boolean(
(initialArgs && isStandaloneTexthookerCommand(initialArgs)) ||
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
(initialArgs?.stats &&
(initialArgs.statsCleanup || initialArgs.statsBackground || initialArgs.statsStop)),
),
shouldSkipHeavyStartup: Boolean(
initialArgs &&
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.setup),
(shouldRunSettingsOnlyStartup(initialArgs) ||
initialArgs.stats ||
initialArgs.dictionary ||
initialArgs.setup),
),
};
}
@@ -123,7 +123,12 @@ import { AnkiIntegration } from './anki-integration';
import { SubtitleTimingTracker } from './subtitle-timing-tracker';
import { RuntimeOptionsManager } from './runtime-options';
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
import { createLogger, setLogLevel, resolveDefaultLogFilePath, type LogLevelSource } from './logger';
import {
createLogger,
setLogLevel,
resolveDefaultLogFilePath,
type LogLevelSource,
} from './logger';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import {
commandNeedsOverlayStartupPrereqs,
@@ -367,7 +372,11 @@ import {
detectWindowsMpvShortcuts,
resolveWindowsMpvShortcutPaths,
} from './main/runtime/windows-mpv-shortcuts';
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import {
createWindowsMpvLaunchDeps,
getConfiguredWindowsMpvPathStatus,
launchWindowsMpv,
} from './main/runtime/windows-mpv-launch';
import { createWaitForMpvConnectedHandler } from './main/runtime/jellyfin-remote-connection';
import { createPrepareYoutubePlaybackInMpvHandler } from './main/runtime/youtube-playback-launch';
import { shouldEnsureTrayOnStartupForInitialArgs } from './main/runtime/startup-tray-policy';
@@ -492,7 +501,10 @@ import {
} from './config';
import { resolveConfigDir } from './config/path-resolution';
import { parseSubtitleCues } from './core/services/subtitle-cue-parser';
import { createSubtitlePrefetchService, type SubtitlePrefetchService } from './core/services/subtitle-prefetch';
import {
createSubtitlePrefetchService,
type SubtitlePrefetchService,
} from './core/services/subtitle-prefetch';
import {
buildSubtitleSidebarSourceKey,
resolveSubtitleSourcePath,
@@ -1037,6 +1049,9 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
showError: (title, content) => dialog.showErrorBox(title, content),
}),
[...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`],
undefined,
undefined,
getResolvedConfig().mpv.executablePath,
),
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
@@ -1405,8 +1420,7 @@ const refreshSubtitlePrefetchFromActiveTrackHandler =
getMpvClient: () => appState.mpvClient,
getLastObservedTimePos: () => lastObservedTimePos,
subtitlePrefetchInitController,
resolveActiveSubtitleSidebarSource: (input) =>
resolveActiveSubtitleSidebarSourceHandler(input),
resolveActiveSubtitleSidebarSource: (input) => resolveActiveSubtitleSidebarSourceHandler(input),
});
function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
@@ -1419,7 +1433,8 @@ function scheduleSubtitlePrefetchRefresh(delayMs = 0): void {
const subtitlePrefetchRuntime = {
cancelPendingInit: () => subtitlePrefetchInitController.cancelPendingInit(),
initSubtitlePrefetch: subtitlePrefetchInitController.initSubtitlePrefetch,
refreshSubtitleSidebarFromSource: (sourcePath: string) => refreshSubtitleSidebarFromSource(sourcePath),
refreshSubtitleSidebarFromSource: (sourcePath: string) =>
refreshSubtitleSidebarFromSource(sourcePath),
refreshSubtitlePrefetchFromActiveTrack: () => refreshSubtitlePrefetchFromActiveTrackHandler(),
scheduleSubtitlePrefetchRefresh: (delayMs?: number) => scheduleSubtitlePrefetchRefresh(delayMs),
clearScheduledSubtitlePrefetchRefresh: () => clearScheduledSubtitlePrefetchRefresh(),
@@ -1854,10 +1869,11 @@ const overlayVisibilityRuntime = createOverlayVisibilityRuntimeService(
},
})(),
);
const buildGetRuntimeOptionsStateMainDepsHandler =
createBuildGetRuntimeOptionsStateMainDepsHandler({
const buildGetRuntimeOptionsStateMainDepsHandler = createBuildGetRuntimeOptionsStateMainDepsHandler(
{
getRuntimeOptionsManager: () => appState.runtimeOptionsManager,
});
},
);
const getRuntimeOptionsStateMainDeps = buildGetRuntimeOptionsStateMainDepsHandler();
const getRuntimeOptionsStateHandler = createGetRuntimeOptionsStateHandler(
getRuntimeOptionsStateMainDeps,
@@ -2213,6 +2229,7 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
}),
getSetupSnapshot: async () => {
const snapshot = await firstRunSetupService.getSetupStatus();
const mpvExecutablePath = getResolvedConfig().mpv.executablePath;
return {
configReady: snapshot.configReady,
dictionaryCount: snapshot.dictionaryCount,
@@ -2220,6 +2237,8 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
externalYomitanConfigured: snapshot.externalYomitanConfigured,
pluginStatus: snapshot.pluginStatus,
pluginInstallPathSummary: snapshot.pluginInstallPathSummary,
mpvExecutablePath,
mpvExecutablePathStatus: getConfiguredWindowsMpvPathStatus(mpvExecutablePath),
windowsMpvShortcuts: snapshot.windowsMpvShortcuts,
message: firstRunSetupMessage,
};
@@ -2232,6 +2251,22 @@ const openFirstRunSetupWindowHandler = createOpenFirstRunSetupWindowHandler({
firstRunSetupMessage = snapshot.message;
return;
}
if (submission.action === 'configure-mpv-executable-path') {
const mpvExecutablePath = submission.mpvExecutablePath?.trim() ?? '';
const pathStatus = getConfiguredWindowsMpvPathStatus(mpvExecutablePath);
configService.patchRawConfig({
mpv: {
executablePath: mpvExecutablePath,
},
});
firstRunSetupMessage =
pathStatus === 'invalid'
? `Saved mpv executable path, but the file was not found: ${mpvExecutablePath}`
: mpvExecutablePath
? `Saved mpv executable path: ${mpvExecutablePath}`
: 'Cleared mpv executable path. SubMiner will auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.';
return;
}
if (submission.action === 'configure-windows-mpv-shortcuts') {
const snapshot = await firstRunSetupService.configureWindowsMpvShortcuts({
startMenuEnabled: submission.startMenuEnabled === true,
@@ -3016,7 +3051,8 @@ const runStatsCliCommand = createRunStatsCliCommandHandler({
},
getImmersionTracker: () => appState.immersionTracker,
ensureStatsServerStarted: () => statsStartupRuntime.ensureStatsServerStarted(),
ensureBackgroundStatsServerStarted: () => statsStartupRuntime.ensureBackgroundStatsServerStarted(),
ensureBackgroundStatsServerStarted: () =>
statsStartupRuntime.ensureBackgroundStatsServerStarted(),
stopBackgroundStatsServer: () => statsStartupRuntime.stopBackgroundStatsServer(),
openExternal: (url: string) => shell.openExternal(url),
writeResponse: (responsePath, payload) => {
@@ -3232,8 +3268,7 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
Boolean(appState.initialArgs && isHeadlessInitialCommand(appState.initialArgs)),
shouldUseMinimalStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldUseMinimalStartup,
shouldSkipHeavyStartup: () =>
getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
shouldSkipHeavyStartup: () => getStartupModeFlags(appState.initialArgs).shouldSkipHeavyStartup,
createImmersionTracker: () => {
ensureImmersionTrackerStarted();
},

View File

@@ -24,7 +24,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
{ scope: string; warn: () => void; info: () => void; error: () => void },
{ registry: boolean },
{ getModalWindow: () => null },
{ inputState: boolean; getModalInputExclusive: () => boolean; handleModalInputStateChange: (isActive: boolean) => void },
{
inputState: boolean;
getModalInputExclusive: () => boolean;
handleModalInputStateChange: (isActive: boolean) => void;
},
{ measurementStore: boolean },
{ modalRuntime: boolean },
{ mpvSocketPath: string; texthookerPort: number },
@@ -80,7 +84,11 @@ test('createMainBootServices builds boot-phase service bundle', () => {
createOverlayManager: () => ({
getModalWindow: () => null,
}),
createOverlayModalInputState: () => ({ inputState: true, getModalInputExclusive: () => false, handleModalInputStateChange: () => {} }),
createOverlayModalInputState: () => ({
inputState: true,
getModalInputExclusive: () => false,
handleModalInputStateChange: () => {},
}),
createOverlayContentMeasurementStore: () => ({ measurementStore: true }),
getSyncOverlayShortcutsForModal: () => () => {},
getSyncOverlayVisibilityForModal: () => () => {},
@@ -106,8 +114,14 @@ test('createMainBootServices builds boot-phase service bundle', () => {
mpvSocketPath: '/tmp/subminer.sock',
texthookerPort: 5174,
});
assert.equal(services.appLifecycleApp.on('ready', () => {}), services.appLifecycleApp);
assert.equal(services.appLifecycleApp.on('second-instance', () => {}), services.appLifecycleApp);
assert.equal(
services.appLifecycleApp.on('ready', () => {}),
services.appLifecycleApp,
);
assert.equal(
services.appLifecycleApp.on('second-instance', () => {}),
services.appLifecycleApp,
);
assert.deepEqual(appOnCalls, ['ready']);
assert.equal(secondInstanceHandlerRegistered, true);
assert.deepEqual(calls, ['mkdir:/tmp/subminer-config']);

View File

@@ -56,9 +56,7 @@ export interface MainBootServicesParams<
};
shouldBypassSingleInstanceLock: () => boolean;
requestSingleInstanceLockEarly: () => boolean;
registerSecondInstanceHandlerEarly: (
listener: (_event: unknown, argv: string[]) => void,
) => void;
registerSecondInstanceHandlerEarly: (listener: (_event: unknown, argv: string[]) => void) => void;
onConfigStartupParseError: (error: ConfigStartupParseError) => void;
createConfigService: (configDir: string) => TConfigService;
createAnilistTokenStore: (targetPath: string) => TAnilistTokenStore;
@@ -87,10 +85,7 @@ export interface MainBootServicesParams<
overlayModalInputState: TOverlayModalInputState;
onModalStateChange: (isActive: boolean) => void;
}) => TOverlayModalRuntime;
createAppState: (input: {
mpvSocketPath: string;
texthookerPort: number;
}) => TAppState;
createAppState: (input: { mpvSocketPath: string; texthookerPort: number }) => TAppState;
}
export interface MainBootServicesResult<
@@ -239,9 +234,7 @@ export function createMainBootServices<
const appLifecycleApp = {
requestSingleInstanceLock: () =>
params.shouldBypassSingleInstanceLock()
? true
: params.requestSingleInstanceLockEarly(),
params.shouldBypassSingleInstanceLock() ? true : params.requestSingleInstanceLockEarly(),
quit: () => params.app.quit(),
on: (event: string, listener: (...args: unknown[]) => void) => {
if (event === 'second-instance') {

View File

@@ -31,9 +31,9 @@ function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
const extraLength = archive.readUInt16LE(cursor + 28);
const fileNameStart = cursor + 30;
const dataStart = fileNameStart + fileNameLength + extraLength;
const fileName = archive.subarray(fileNameStart, fileNameStart + fileNameLength).toString(
'utf8',
);
const fileName = archive
.subarray(fileNameStart, fileNameStart + fileNameLength)
.toString('utf8');
const data = archive.subarray(dataStart, dataStart + compressedSize);
entries.set(fileName, Buffer.from(data));
cursor = dataStart + compressedSize;
@@ -57,7 +57,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
}) as typeof fs.writeFileSync;
Buffer.concat = ((...args: Parameters<typeof Buffer.concat>) => {
throw new Error(`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`);
throw new Error(
`buildDictionaryZip should not Buffer.concat the full archive (${args[0].length} chunks)`,
);
}) as typeof Buffer.concat;
const result = buildDictionaryZip(
@@ -91,8 +93,9 @@ test('buildDictionaryZip writes a valid stored zip without fs.writeFileSync', ()
assert.equal(indexJson.revision, '2026-03-27');
assert.equal(indexJson.format, 3);
const termBank = JSON.parse(entries.get('term_bank_1.json')!.toString('utf8')) as
CharacterDictionaryTermEntry[];
const termBank = JSON.parse(
entries.get('term_bank_1.json')!.toString('utf8'),
) as CharacterDictionaryTermEntry[];
assert.equal(termBank.length, 1);
assert.equal(termBank[0]?.[0], 'アルファ');
assert.deepEqual(entries.get('images/alpha.bin'), Buffer.from([1, 2, 3]));

View File

@@ -138,7 +138,11 @@ function createCentralDirectoryHeader(entry: ZipEntry): Buffer {
return central;
}
function createEndOfCentralDirectory(entriesLength: number, centralSize: number, centralStart: number): Buffer {
function createEndOfCentralDirectory(
entriesLength: number,
centralSize: number,
centralStart: number,
): Buffer {
const end = Buffer.alloc(22);
let cursor = 0;
writeUint32LE(end, 0x06054b50, cursor);

View File

@@ -37,13 +37,13 @@ test('autoplay ready gate suppresses duplicate media signals for the same media'
firstScheduled?.();
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
['script-message', 'subminer-autoplay-ready'],
]);
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.ok(
commands.some(
(command) =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
),
);
assert.equal(scheduled.length > 0, true);
@@ -84,13 +84,13 @@ test('autoplay ready gate retry loop does not re-signal plugin readiness', async
await new Promise((resolve) => setTimeout(resolve, 0));
}
assert.deepEqual(commands.filter((command) => command[0] === 'script-message'), [
['script-message', 'subminer-autoplay-ready'],
]);
assert.deepEqual(
commands.filter((command) => command[0] === 'script-message'),
[['script-message', 'subminer-autoplay-ready']],
);
assert.equal(
commands.filter(
(command) =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length > 0,
true,
);
@@ -130,13 +130,15 @@ test('autoplay ready gate does not unpause again after a later manual pause on t
await new Promise((resolve) => setTimeout(resolve, 0));
playbackPaused = true;
gate.maybeSignalPluginAutoplayReady({ text: '字幕その2', tokens: null }, { forceWhilePaused: true });
gate.maybeSignalPluginAutoplayReady(
{ text: '字幕その2', tokens: null },
{ forceWhilePaused: true },
);
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(
commands.filter(
(command) =>
command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
(command) => command[0] === 'set_property' && command[1] === 'pause' && command[2] === false,
).length,
1,
);

View File

@@ -40,9 +40,7 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
}
const mediaPath =
deps.getCurrentMediaPath()?.trim() ||
deps.getCurrentVideoPath()?.trim() ||
'__unknown__';
deps.getCurrentMediaPath()?.trim() || deps.getCurrentVideoPath()?.trim() || '__unknown__';
const duplicateMediaSignal = autoPlayReadySignalMediaPath === mediaPath;
const releaseRetryDelayMs = 200;
const maxReleaseAttempts = resolveAutoplayReadyMaxReleaseAttempts({
@@ -85,7 +83,10 @@ export function createAutoplayReadyGate(deps: AutoplayReadyGateDeps) {
const mpvClient = deps.getMpvClient();
if (!mpvClient?.connected) {
if (attempt < maxReleaseAttempts) {
deps.schedule(() => attemptRelease(playbackGeneration, attempt + 1), releaseRetryDelayMs);
deps.schedule(
() => attemptRelease(playbackGeneration, attempt + 1),
releaseRetryDelayMs,
);
}
return;
}

View File

@@ -50,6 +50,8 @@ test('composeAnilistSetupHandlers returns callable setup handlers', () => {
assert.equal(handled, false);
// handleAnilistSetupProtocolUrl returns true for subminer:// URLs
const handledProtocol = composed.handleAnilistSetupProtocolUrl('subminer://anilist-setup?code=abc');
const handledProtocol = composed.handleAnilistSetupProtocolUrl(
'subminer://anilist-setup?code=abc',
);
assert.equal(handledProtocol, true);
});

View File

@@ -36,8 +36,13 @@ test('composeCliStartupHandlers returns callable CLI startup handlers', () => {
openJellyfinSetupWindow: () => {},
getAnilistQueueStatus: () => ({}) as never,
processNextAnilistRetryUpdate: async () => ({ ok: true, message: 'done' }),
generateCharacterDictionary: async () =>
({ zipPath: '/tmp/test.zip', fromCache: false, mediaId: 1, mediaTitle: 'Test', entryCount: 1 }),
generateCharacterDictionary: async () => ({
zipPath: '/tmp/test.zip',
fromCache: false,
mediaId: 1,
mediaTitle: 'Test',
entryCount: 1,
}),
runJellyfinCommand: async () => {},
runStatsCommand: async () => {},
runYoutubePlaybackFlow: async () => {},

View File

@@ -30,9 +30,7 @@ export type CliStartupComposerResult = ComposerOutputs<{
export function composeCliStartupHandlers(
options: CliStartupComposerOptions,
): CliStartupComposerResult {
const createCliCommandContext = createCliCommandContextFactory(
options.cliCommandContextMainDeps,
);
const createCliCommandContext = createCliCommandContextFactory(options.cliCommandContextMainDeps);
const handleCliCommand = createCliCommandRuntimeHandler({
...options.cliCommandRuntimeHandlerMainDeps,
createCliCommandContext: () => createCliCommandContext(),

View File

@@ -8,28 +8,22 @@ type StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDep
typeof createStartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>
>;
export type HeadlessStartupComposerOptions<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps,
> = ComposerInputs<{
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps
>;
}>;
export type HeadlessStartupComposerOptions<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
ComposerInputs<{
startupRuntimeHandlersDeps: StartupRuntimeHandlersDeps<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps
>;
}>;
export type HeadlessStartupComposerResult<
TCliArgs,
TStartupState,
TStartupBootstrapRuntimeDeps,
> = ComposerOutputs<
Pick<
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
>
>;
export type HeadlessStartupComposerResult<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps> =
ComposerOutputs<
Pick<
StartupRuntimeHandlers<TCliArgs, TStartupState, TStartupBootstrapRuntimeDeps>,
'appLifecycleRuntimeRunner' | 'runAndApplyStartupState'
>
>;
export function composeHeadlessStartupHandlers<
TCliArgs,

View File

@@ -4,7 +4,13 @@ import { composeJellyfinRemoteHandlers } from './jellyfin-remote-composer';
test('composeJellyfinRemoteHandlers returns callable jellyfin remote handlers', async () => {
let lastProgressAt = 0;
let activePlayback: unknown = { itemId: 'item-1', mediaSourceId: 'src-1', playMethod: 'DirectPlay', audioStreamIndex: null, subtitleStreamIndex: null };
let activePlayback: unknown = {
itemId: 'item-1',
mediaSourceId: 'src-1',
playMethod: 'DirectPlay',
audioStreamIndex: null,
subtitleStreamIndex: null,
};
const calls: string[] = [];
const composed = composeJellyfinRemoteHandlers({

View File

@@ -85,7 +85,10 @@ function createDefaultMpvFixture() {
updateMpvSubtitleRenderMetricsMainDeps: {
getCurrentMetrics: () => BASE_METRICS,
setCurrentMetrics: () => {},
applyPatch: (current: MpvSubtitleRenderMetrics, patch: Partial<MpvSubtitleRenderMetrics>) => ({
applyPatch: (
current: MpvSubtitleRenderMetrics,
patch: Partial<MpvSubtitleRenderMetrics>,
) => ({
next: { ...current, ...patch },
changed: true,
}),

View File

@@ -58,7 +58,8 @@ export function composeOverlayVisibilityRuntime(
options: OverlayVisibilityRuntimeComposerOptions,
): OverlayVisibilityRuntimeComposerResult {
return {
updateVisibleOverlayVisibility: () => options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
updateVisibleOverlayVisibility: () =>
options.overlayVisibilityRuntime.updateVisibleOverlayVisibility(),
restorePreviousSecondarySubVisibility: createRestorePreviousSecondarySubVisibilityHandler(
createBuildRestorePreviousSecondarySubVisibilityMainDepsHandler(
options.restorePreviousSecondarySubVisibilityMainDeps,

View File

@@ -31,7 +31,10 @@ function requireUser(client: DiscordRpcRawClient): DiscordRpcClientUserLike {
export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcClient {
return {
login: () => client.login(),
setActivity: (activity) => requireUser(client).setActivity(activity).then(() => undefined),
setActivity: (activity) =>
requireUser(client)
.setActivity(activity)
.then(() => undefined),
clearActivity: () => requireUser(client).clearActivity(),
destroy: () => client.destroy(),
};
@@ -39,7 +42,12 @@ export function wrapDiscordRpcClient(client: DiscordRpcRawClient): DiscordRpcCli
export function createDiscordRpcClient(
clientId: string,
deps?: { createClient?: (options: { clientId: string; transport: { type: 'ipc' } }) => DiscordRpcRawClient },
deps?: {
createClient?: (options: {
clientId: string;
transport: { type: 'ipc' };
}) => DiscordRpcRawClient;
},
): DiscordRpcClient {
const client =
deps?.createClient?.({ clientId, transport: { type: 'ipc' } }) ??

View File

@@ -173,7 +173,10 @@ test('syncInstalledFirstRunPluginBinaryPath fills blank binary_path for existing
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
fs.writeFileSync(installPaths.pluginConfigPath, 'binary_path=\nsocket_path=/tmp/subminer-socket\n');
fs.writeFileSync(
installPaths.pluginConfigPath,
'binary_path=\nsocket_path=/tmp/subminer-socket\n',
);
const result = syncInstalledFirstRunPluginBinaryPath({
platform: 'linux',
@@ -222,3 +225,72 @@ test('syncInstalledFirstRunPluginBinaryPath preserves explicit binary_path overr
);
});
});
test('detectInstalledFirstRunPlugin detects plugin installed in canonical mpv config location on macOS', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir);
const pluginDir = path.join(homeDir, '.config', 'mpv', 'scripts', 'subminer');
const pluginEntrypointPath = path.join(pluginDir, 'main.lua');
fs.mkdirSync(pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
fs.writeFileSync(pluginEntrypointPath, '-- plugin');
assert.equal(
detectInstalledFirstRunPlugin(installPaths),
true,
);
});
});
test('detectInstalledFirstRunPlugin ignores scoped plugin layout path', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir, xdgConfigHome);
const pluginDir = path.join(xdgConfigHome, 'mpv', 'scripts', '@plugin', 'subminer');
const pluginEntrypointPath = path.join(pluginDir, 'main.lua');
fs.mkdirSync(pluginDir, { recursive: true });
fs.mkdirSync(path.dirname(pluginEntrypointPath), { recursive: true });
fs.writeFileSync(pluginEntrypointPath, '-- plugin');
assert.equal(
detectInstalledFirstRunPlugin(installPaths),
false,
);
});
});
test('detectInstalledFirstRunPlugin ignores legacy loader file', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir);
const legacyLoaderPath = path.join(installPaths.scriptsDir, 'subminer.lua');
fs.mkdirSync(path.dirname(legacyLoaderPath), { recursive: true });
fs.writeFileSync(legacyLoaderPath, '-- plugin');
assert.equal(
detectInstalledFirstRunPlugin(installPaths),
false,
);
});
});
test('detectInstalledFirstRunPlugin requires main.lua in subminer directory', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const installPaths = resolveDefaultMpvInstallPaths('darwin', homeDir);
const pluginDir = path.join(installPaths.scriptsDir, 'subminer');
fs.mkdirSync(pluginDir, { recursive: true });
fs.writeFileSync(path.join(pluginDir, 'not_main.lua'), '-- plugin');
assert.equal(
detectInstalledFirstRunPlugin(installPaths),
false,
);
});
});

View File

@@ -12,14 +12,6 @@ function backupExistingPath(targetPath: string): void {
fs.renameSync(targetPath, `${targetPath}.bak.${timestamp()}`);
}
function resolveLegacyPluginLoaderPath(installPaths: MpvInstallPaths): string {
return path.join(installPaths.scriptsDir, 'subminer.lua');
}
function resolveLegacyPluginDebugLoaderPath(installPaths: MpvInstallPaths): string {
return path.join(installPaths.scriptsDir, 'subminer-loader.lua');
}
function rewriteInstalledWindowsPluginConfig(configPath: string): void {
const content = fs.readFileSync(configPath, 'utf8');
const updated = content.replace(/^socket_path=.*$/m, 'socket_path=\\\\.\\pipe\\subminer-socket');
@@ -99,14 +91,13 @@ export function resolvePackagedFirstRunPluginAssets(deps: {
export function detectInstalledFirstRunPlugin(
installPaths: MpvInstallPaths,
deps?: { existsSync?: (candidate: string) => boolean },
deps?: {
existsSync?: (candidate: string) => boolean;
},
): boolean {
const existsSync = deps?.existsSync ?? fs.existsSync;
return (
existsSync(installPaths.pluginEntrypointPath) &&
existsSync(installPaths.pluginDir) &&
existsSync(installPaths.pluginConfigPath)
);
const pluginEntrypointPath = path.join(installPaths.scriptsDir, 'subminer', 'main.lua');
return existsSync(pluginEntrypointPath);
}
export function installFirstRunPluginToDefaultLocation(options: {
@@ -148,8 +139,8 @@ export function installFirstRunPluginToDefaultLocation(options: {
fs.mkdirSync(installPaths.scriptsDir, { recursive: true });
fs.mkdirSync(installPaths.scriptOptsDir, { recursive: true });
backupExistingPath(resolveLegacyPluginLoaderPath(installPaths));
backupExistingPath(resolveLegacyPluginDebugLoaderPath(installPaths));
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer.lua'));
backupExistingPath(path.join(installPaths.scriptsDir, 'subminer-loader.lua'));
backupExistingPath(installPaths.pluginDir);
backupExistingPath(installPaths.pluginConfigPath);
fs.cpSync(assets.pluginDirSource, installPaths.pluginDir, { recursive: true });
@@ -187,7 +178,10 @@ export function syncInstalledFirstRunPluginBinaryPath(options: {
return { updated: false, configPath: installPaths.pluginConfigPath };
}
const updated = rewriteInstalledPluginBinaryPath(installPaths.pluginConfigPath, options.binaryPath);
const updated = rewriteInstalledPluginBinaryPath(
installPaths.pluginConfigPath,
options.binaryPath,
);
if (options.platform === 'win32') {
rewriteInstalledWindowsPluginConfig(installPaths.pluginConfigPath);
}

View File

@@ -16,6 +16,8 @@ test('buildFirstRunSetupHtml renders macchiato setup actions and disabled finish
externalYomitanConfigured: false,
pluginStatus: 'required',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
@@ -43,6 +45,8 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
mpvExecutablePath: 'C:\\Program Files\\mpv\\mpv.exe',
mpvExecutablePathStatus: 'configured',
windowsMpvShortcuts: {
supported: true,
startMenuEnabled: true,
@@ -55,12 +59,40 @@ test('buildFirstRunSetupHtml switches plugin action to reinstall when already in
});
assert.match(html, /Reinstall mpv plugin/);
assert.match(html, /mpv executable path/);
assert.match(html, /Leave blank to auto-discover mpv\.exe from PATH\./);
assert.match(html, /aria-label="Path to mpv\.exe"/);
assert.match(
html,
/Finish stays unlocked once the mpv plugin is installed and Yomitan reports at least one installed dictionary\./,
);
});
test('buildFirstRunSetupHtml marks an invalid configured mpv path as invalid', () => {
const html = buildFirstRunSetupHtml({
configReady: true,
dictionaryCount: 1,
canFinish: true,
externalYomitanConfigured: false,
pluginStatus: 'installed',
pluginInstallPathSummary: '/tmp/mpv',
mpvExecutablePath: 'C:\\Broken\\mpv.exe',
mpvExecutablePathStatus: 'invalid',
windowsMpvShortcuts: {
supported: true,
startMenuEnabled: true,
desktopEnabled: true,
startMenuInstalled: false,
desktopInstalled: false,
status: 'optional',
},
message: null,
});
assert.match(html, />Invalid</);
assert.match(html, /Current: C:\\Broken\\mpv\.exe \(invalid; file not found\)/);
});
test('buildFirstRunSetupHtml explains the config blocker when setup is missing config', () => {
const html = buildFirstRunSetupHtml({
configReady: false,
@@ -69,6 +101,8 @@ test('buildFirstRunSetupHtml explains the config blocker when setup is missing c
externalYomitanConfigured: false,
pluginStatus: 'required',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
@@ -91,6 +125,8 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
externalYomitanConfigured: true,
pluginStatus: 'installed',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,
@@ -103,14 +139,29 @@ test('buildFirstRunSetupHtml explains external yomitan mode and keeps finish ena
});
assert.match(html, /External profile configured/);
assert.match(html, /Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./);
assert.match(
html,
/Finish stays unlocked while SubMiner is reusing an external Yomitan profile\./,
);
});
test('parseFirstRunSetupSubmissionUrl parses supported custom actions', () => {
assert.deepEqual(
parseFirstRunSetupSubmissionUrl(
'subminer://first-run-setup?action=configure-mpv-executable-path&mpvExecutablePath=C%3A%5CApps%5Cmpv%5Cmpv.exe',
),
{
action: 'configure-mpv-executable-path',
mpvExecutablePath: 'C:\\Apps\\mpv\\mpv.exe',
},
);
assert.deepEqual(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=refresh'), {
action: 'refresh',
});
assert.equal(parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'), null);
assert.equal(
parseFirstRunSetupSubmissionUrl('subminer://first-run-setup?action=skip-plugin'),
null,
);
assert.equal(parseFirstRunSetupSubmissionUrl('https://example.com'), null);
});
@@ -192,6 +243,8 @@ test('closing incomplete first-run setup quits app outside background mode', asy
externalYomitanConfigured: false,
pluginStatus: 'required',
pluginInstallPathSummary: null,
mpvExecutablePath: '',
mpvExecutablePathStatus: 'blank',
windowsMpvShortcuts: {
supported: false,
startMenuEnabled: true,

View File

@@ -17,6 +17,7 @@ type FirstRunSetupWindowLike = FocusableWindowLike & {
};
export type FirstRunSetupAction =
| 'configure-mpv-executable-path'
| 'install-plugin'
| 'configure-windows-mpv-shortcuts'
| 'open-yomitan-settings'
@@ -25,6 +26,7 @@ export type FirstRunSetupAction =
export interface FirstRunSetupSubmission {
action: FirstRunSetupAction;
mpvExecutablePath?: string;
startMenuEnabled?: boolean;
desktopEnabled?: boolean;
}
@@ -36,6 +38,8 @@ export interface FirstRunSetupHtmlModel {
externalYomitanConfigured: boolean;
pluginStatus: 'installed' | 'required' | 'failed';
pluginInstallPathSummary: string | null;
mpvExecutablePath: string;
mpvExecutablePathStatus: 'blank' | 'configured' | 'invalid';
windowsMpvShortcuts: {
supported: boolean;
startMenuEnabled: boolean;
@@ -90,6 +94,50 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
: model.windowsMpvShortcuts.status === 'skipped'
? 'muted'
: 'warn';
const mpvExecutablePathLabel =
model.mpvExecutablePathStatus === 'configured'
? 'Configured'
: model.mpvExecutablePathStatus === 'invalid'
? 'Invalid'
: 'Blank';
const mpvExecutablePathTone =
model.mpvExecutablePathStatus === 'configured'
? 'ready'
: model.mpvExecutablePathStatus === 'invalid'
? 'danger'
: 'muted';
const mpvExecutablePathCurrent =
model.mpvExecutablePathStatus === 'blank'
? 'blank (PATH discovery)'
: model.mpvExecutablePathStatus === 'invalid'
? `${model.mpvExecutablePath} (invalid; file not found)`
: model.mpvExecutablePath;
const mpvExecutablePathCard = model.windowsMpvShortcuts.supported
? `
<div class="card block">
<div class="card-head">
<div>
<strong>mpv executable path</strong>
<div class="meta">Leave blank to auto-discover mpv.exe from PATH.</div>
<div class="meta">Current: ${escapeHtml(mpvExecutablePathCurrent)}</div>
</div>
${renderStatusBadge(mpvExecutablePathLabel, mpvExecutablePathTone)}
</div>
<form
class="path-form"
onsubmit="event.preventDefault(); const params = new URLSearchParams({ action: 'configure-mpv-executable-path', mpvExecutablePath: document.getElementById('mpv-executable-path').value }); window.location.href = 'subminer://first-run-setup?' + params.toString();"
>
<input
id="mpv-executable-path"
type="text"
aria-label="Path to mpv.exe"
value="${escapeHtml(model.mpvExecutablePath)}"
placeholder="C:\\Program Files\\mpv\\mpv.exe"
/>
<button type="submit">Save mpv executable path</button>
</form>
</div>`
: '';
const windowsShortcutCard = model.windowsMpvShortcuts.supported
? `
<div class="card block">
@@ -218,6 +266,24 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
.badge.warn { background: rgba(238, 212, 159, 0.18); color: var(--yellow); }
.badge.muted { background: rgba(184, 192, 224, 0.12); color: var(--muted); }
.badge.danger { background: rgba(237, 135, 150, 0.16); color: var(--red); }
.path-form {
display: grid;
gap: 8px;
margin-top: 12px;
}
.path-form input[type='text'] {
width: 100%;
box-sizing: border-box;
border: 1px solid rgba(202, 211, 245, 0.12);
border-radius: 10px;
padding: 9px 10px;
color: var(--text);
background: rgba(30, 32, 48, 0.72);
font: inherit;
}
.path-form input[type='text']::placeholder {
color: rgba(184, 192, 224, 0.65);
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -282,6 +348,7 @@ export function buildFirstRunSetupHtml(model: FirstRunSetupHtmlModel): string {
</div>
${renderStatusBadge(yomitanBadgeLabel, yomitanBadgeTone)}
</div>
${mpvExecutablePathCard}
${windowsShortcutCard}
<div class="actions">
<button onclick="window.location.href='subminer://first-run-setup?action=install-plugin'">${pluginActionLabel}</button>
@@ -303,6 +370,7 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
const parsed = new URL(rawUrl);
const action = parsed.searchParams.get('action');
if (
action !== 'configure-mpv-executable-path' &&
action !== 'install-plugin' &&
action !== 'configure-windows-mpv-shortcuts' &&
action !== 'open-yomitan-settings' &&
@@ -311,6 +379,12 @@ export function parseFirstRunSetupSubmissionUrl(rawUrl: string): FirstRunSetupSu
) {
return null;
}
if (action === 'configure-mpv-executable-path') {
return {
action,
mpvExecutablePath: parsed.searchParams.get('mpvExecutablePath') ?? '',
};
}
if (action === 'configure-windows-mpv-shortcuts') {
return {
action,
@@ -339,7 +413,8 @@ export function createHandleFirstRunSetupNavigationHandler(deps: {
}) {
return (params: { url: string; preventDefault: () => void }): boolean => {
if (!params.url.startsWith('subminer://first-run-setup')) {
return false;
params.preventDefault();
return true;
}
params.preventDefault();
let submission: FirstRunSetupSubmission | null;

View File

@@ -109,7 +109,9 @@ function pickBestTrackId(
if (left.track.external !== right.track.external) {
return left.track.external ? -1 : 1;
}
if (isLikelyHearingImpaired(left.track.title) !== isLikelyHearingImpaired(right.track.title)) {
if (
isLikelyHearingImpaired(left.track.title) !== isLikelyHearingImpaired(right.track.title)
) {
return isLikelyHearingImpaired(left.track.title) ? 1 : -1;
}
if (/\bdefault\b/i.test(left.track.title) !== /\bdefault\b/i.test(right.track.title)) {
@@ -130,7 +132,9 @@ export function resolveManagedLocalSubtitleSelection(input: {
secondaryLanguages: string[];
}): ManagedLocalSubtitleSelection {
const tracks = Array.isArray(input.trackList)
? input.trackList.map(normalizeTrack).filter((track): track is NormalizedSubtitleTrack => track !== null)
? input.trackList
.map(normalizeTrack)
.filter((track): track is NormalizedSubtitleTrack => track !== null)
: [];
const preferredPrimaryLanguages = normalizeLanguageList(
input.primaryLanguages,
@@ -165,12 +169,10 @@ function normalizeLocalMediaPath(mediaPath: string | null | undefined): string |
export function createManagedLocalSubtitleSelectionRuntime(deps: {
getCurrentMediaPath: () => string | null;
getMpvClient: () =>
| {
connected?: boolean;
requestProperty?: (name: string) => Promise<unknown>;
}
| null;
getMpvClient: () => {
connected?: boolean;
requestProperty?: (name: string) => Promise<unknown>;
} | null;
getPrimarySubtitleLanguages: () => string[];
getSecondarySubtitleLanguages: () => string[];
sendMpvCommand: (command: ['set_property', 'sid' | 'secondary-sid', number]) => void;

View File

@@ -38,7 +38,8 @@ export function createPlaylistBrowserIpcRuntime(
return {
playlistBrowserRuntimeDeps,
playlistBrowserMainDeps: {
getPlaylistBrowserSnapshot: () => getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
getPlaylistBrowserSnapshot: () =>
getPlaylistBrowserSnapshotRuntime(playlistBrowserRuntimeDeps),
appendPlaylistBrowserFile: (filePath: string) =>
appendPlaylistBrowserFileRuntime(playlistBrowserRuntimeDeps, filePath),
playPlaylistBrowserIndex: (index: number) =>

Some files were not shown because too many files have changed in this diff Show More