From 11c196821d271085dc5395d7a1b6e0f9cac87345 Mon Sep 17 00:00:00 2001 From: sudacode Date: Tue, 26 May 2026 00:31:38 -0700 Subject: [PATCH] Fix Windows mpv logging and add log export (#88) --- changes/character-dictionary-manager-gate.md | 4 + ...v-character-dictionary-manager-bindings.md | 4 + changes/fix-mpv-logging-config-forwarding.md | 7 + changes/log-export.md | 4 + changes/mpv-ipc-background-log-throttle.md | 4 + ...ame-match-controls-character-dictionary.md | 4 + config.example.jsonc | 17 +- docs-site/.vitepress/config.ts | 39 ++-- docs-site/anilist-integration.md | 3 +- docs-site/character-dictionary.md | 21 +- docs-site/configuration.md | 65 +++--- docs-site/launcher-script.md | 2 + docs-site/public/config.example.jsonc | 17 +- docs-site/seo.test.ts | 4 +- docs-site/usage.md | 15 +- launcher/aniskip-metadata.test.ts | 2 +- launcher/aniskip-metadata.ts | 5 +- launcher/commands/command-modules.test.ts | 32 ++- launcher/commands/dictionary-command.ts | 3 +- launcher/commands/jellyfin-command.ts | 9 +- launcher/commands/logs-command.ts | 24 ++ launcher/commands/playback-command.test.ts | 9 + launcher/commands/stats-command.ts | 3 +- launcher/config.test.ts | 1 + launcher/config.ts | 51 +++- launcher/config/args-normalizer.test.ts | 15 ++ launcher/config/args-normalizer.ts | 10 +- launcher/config/cli-parser-builder.ts | 16 ++ launcher/config/plugin-runtime-config.ts | 4 +- launcher/jellyfin.ts | 3 +- launcher/log.ts | 29 ++- launcher/main.test.ts | 34 ++- launcher/main.ts | 21 +- launcher/mpv.test.ts | 2 + launcher/mpv.ts | 46 +++- launcher/parse-args.test.ts | 15 ++ launcher/smoke.e2e.test.ts | 12 +- launcher/types.ts | 18 +- package.json | 4 +- plugin/subminer/lifecycle.lua | 4 +- plugin/subminer/log.lua | 2 +- plugin/subminer/process.lua | 23 +- plugin/subminer/session_bindings.lua | 40 +++- scripts/test-plugin-session-bindings.lua | 57 ++++- scripts/test-plugin-start-gate.lua | 16 ++ src/anki-connect.test.ts | 3 + src/cli/args.test.ts | 15 ++ src/cli/args.ts | 47 +++- src/cli/help.ts | 1 - src/config/config.test.ts | 112 ++++++++- src/config/definitions/defaults-core.ts | 8 +- .../definitions/defaults-integrations.ts | 1 - .../definitions/domain-registry.test.ts | 3 +- src/config/definitions/options-core.ts | 24 ++ .../definitions/options-integrations.ts | 9 +- src/config/definitions/options-subtitle.ts | 2 +- src/config/definitions/template-sections.ts | 2 +- src/config/resolve/core-domains.ts | 30 +++ src/config/resolve/integrations.ts | 12 - src/config/resolve/jellyfin.test.ts | 2 - src/config/settings/registry.test.ts | 11 +- src/config/settings/registry.ts | 8 + src/core/services/app-lifecycle.test.ts | 1 - src/core/services/cli-command.test.ts | 41 ++-- src/core/services/cli-command.ts | 14 +- src/core/services/config-hot-reload.test.ts | 4 + src/core/services/config-hot-reload.ts | 2 + src/core/services/mpv.test.ts | 76 ++++++ src/core/services/mpv.ts | 75 +++++- .../services/overlay-shortcut-handler.test.ts | 13 -- src/core/services/overlay-shortcut-handler.ts | 6 - src/core/services/overlay-shortcut.test.ts | 3 - src/core/services/overlay-shortcut.ts | 1 - src/core/services/session-actions.test.ts | 1 - src/core/services/session-actions.ts | 4 - src/core/services/session-bindings.test.ts | 55 ++++- src/core/services/session-bindings.ts | 19 +- src/core/services/startup-bootstrap.test.ts | 1 - src/core/services/startup.ts | 13 ++ src/core/services/tokenizer.test.ts | 3 + .../tokenizer/yomitan-parser-runtime.test.ts | 41 ++++ .../tokenizer/yomitan-parser-runtime.ts | 34 +++ src/core/services/yomitan-extension-copy.ts | 15 +- .../services/yomitan-extension-loader.test.ts | 10 +- src/core/services/yomitan-extension-loader.ts | 14 ++ src/generate-config-example.test.ts | 7 + src/generate-config-example.ts | 13 +- src/logger.test.ts | 38 ++- src/logger.ts | 24 +- src/main-entry-launch-config.ts | 12 +- src/main-entry-runtime.test.ts | 17 ++ src/main-entry.ts | 32 ++- src/main.ts | 158 ++++++++++--- src/main/app-lifecycle.ts | 4 + src/main/character-dictionary-runtime/zip.ts | 181 +-------------- src/main/overlay-shortcuts-runtime.ts | 4 - src/main/runtime/app-ready-main-deps.ts | 2 + .../character-dictionary-manager-gate.test.ts | 55 +++++ .../character-dictionary-manager-gate.ts | 39 ++++ src/main/runtime/character-dictionary-open.ts | 11 - .../jellyfin-runtime-composer.test.ts | 2 +- .../config-hot-reload-handlers.test.ts | 8 + .../runtime/config-hot-reload-handlers.ts | 8 + .../runtime/config-hot-reload-main-deps.ts | 6 + .../runtime/first-run-setup-plugin.test.ts | 22 +- .../runtime/first-run-setup-service.test.ts | 1 - ...llyfin-remote-connection-main-deps.test.ts | 4 +- .../jellyfin-remote-connection-main-deps.ts | 2 +- .../jellyfin-remote-connection.test.ts | 7 +- .../runtime/jellyfin-remote-connection.ts | 5 +- src/main/runtime/log-export.test.ts | 204 ++++++++++++++++ src/main/runtime/log-export.ts | 184 +++++++++++++++ .../runtime/mpv-osd-log-main-deps.test.ts | 4 +- src/main/runtime/mpv-osd-log-main-deps.ts | 2 +- src/main/runtime/mpv-osd-log.test.ts | 27 ++- src/main/runtime/mpv-osd-log.ts | 13 +- .../runtime/mpv-osd-runtime-handlers.test.ts | 2 +- .../runtime/mpv-track-diagnostics.test.ts | 38 +++ src/main/runtime/mpv-track-diagnostics.ts | 113 +++++++++ ...verlay-shortcuts-runtime-main-deps.test.ts | 3 - .../overlay-shortcuts-runtime-main-deps.ts | 1 - .../subtitle-tokenization-main-deps.test.ts | 2 +- src/main/runtime/tray-main-actions.test.ts | 4 + src/main/runtime/tray-main-actions.ts | 5 + src/main/runtime/tray-main-deps.test.ts | 2 + src/main/runtime/tray-main-deps.ts | 3 + .../runtime/tray-runtime-handlers.test.ts | 1 + src/main/runtime/tray-runtime.test.ts | 18 +- src/main/runtime/tray-runtime.ts | 5 + src/main/runtime/update/appimage-updater.ts | 7 +- .../runtime/update/support-assets.test.ts | 2 +- src/main/runtime/update/support-assets.ts | 8 +- src/main/runtime/windows-mpv-launch.test.ts | 55 +++++ src/main/runtime/windows-mpv-launch.ts | 60 ++++- src/main/startup.ts | 2 + src/preload.ts | 4 - src/renderer/modals/session-help-sections.ts | 3 - src/renderer/renderer.ts | 5 - src/shared/ipc/contracts.ts | 1 - src/shared/ipc/validators.ts | 1 - src/shared/log-files.test.ts | 73 +++++- src/shared/log-files.ts | 79 ++++++- src/shared/mpv-logging-args.ts | 27 +++ src/shared/stored-zip.ts | 219 ++++++++++++++++++ src/shared/subminer-plugin-script-opts.ts | 3 + src/types/config.ts | 13 +- src/types/integrations.ts | 1 - src/types/runtime.ts | 1 - src/types/session-bindings.ts | 9 +- src/verify-config-example.ts | 8 +- 150 files changed, 2748 insertions(+), 582 deletions(-) create mode 100644 changes/character-dictionary-manager-gate.md create mode 100644 changes/fix-mpv-character-dictionary-manager-bindings.md create mode 100644 changes/fix-mpv-logging-config-forwarding.md create mode 100644 changes/log-export.md create mode 100644 changes/mpv-ipc-background-log-throttle.md create mode 100644 changes/name-match-controls-character-dictionary.md create mode 100644 launcher/commands/logs-command.ts create mode 100644 src/main/runtime/character-dictionary-manager-gate.test.ts create mode 100644 src/main/runtime/character-dictionary-manager-gate.ts create mode 100644 src/main/runtime/log-export.test.ts create mode 100644 src/main/runtime/log-export.ts create mode 100644 src/main/runtime/mpv-track-diagnostics.test.ts create mode 100644 src/main/runtime/mpv-track-diagnostics.ts create mode 100644 src/shared/mpv-logging-args.ts create mode 100644 src/shared/stored-zip.ts diff --git a/changes/character-dictionary-manager-gate.md b/changes/character-dictionary-manager-gate.md new file mode 100644 index 00000000..82e07f35 --- /dev/null +++ b/changes/character-dictionary-manager-gate.md @@ -0,0 +1,4 @@ +type: fixed +area: character-dictionary + +- Block the character dictionary manager when character dictionary annotations are disabled, and notify through the configured OSD/system notification surfaces. diff --git a/changes/fix-mpv-character-dictionary-manager-bindings.md b/changes/fix-mpv-character-dictionary-manager-bindings.md new file mode 100644 index 00000000..b982c450 --- /dev/null +++ b/changes/fix-mpv-character-dictionary-manager-bindings.md @@ -0,0 +1,4 @@ +type: fixed +area: mpv + +- Pass generated session-action CLI args to the mpv plugin. diff --git a/changes/fix-mpv-logging-config-forwarding.md b/changes/fix-mpv-logging-config-forwarding.md new file mode 100644 index 00000000..72943039 --- /dev/null +++ b/changes/fix-mpv-logging-config-forwarding.md @@ -0,0 +1,7 @@ +type: fixed +area: logging + +- Forward SubMiner `logging.level` into launcher-started and Windows shortcut-started mpv sessions, including mpv log verbosity, plugin script logging, and plugin-launched app logging. +- Add numeric `logging.rotation`, defaulting to 7 days of retained daily app, launcher, and mpv logs. +- Log Windows mpv launch diagnostics, IPC socket connection state, subtitle track summaries, Yomitan extension load state, dictionary counts, and expected/active IPC socket values when plugin auto-start skips due to a socket mismatch. +- Add `logging.files` toggles for app, launcher, and mpv logs, with mpv logs disabled by default unless explicitly enabled for debugging. diff --git a/changes/log-export.md b/changes/log-export.md new file mode 100644 index 00000000..fc1d9ccb --- /dev/null +++ b/changes/log-export.md @@ -0,0 +1,4 @@ +type: added +area: logs + +- Add sanitized log ZIP exports from the tray menu and `subminer logs -e`, with home-directory usernames redacted from exported log contents. diff --git a/changes/mpv-ipc-background-log-throttle.md b/changes/mpv-ipc-background-log-throttle.md new file mode 100644 index 00000000..6f5cf006 --- /dev/null +++ b/changes/mpv-ipc-background-log-throttle.md @@ -0,0 +1,4 @@ +type: fixed +area: logging + +- Stop repeated MPV IPC socket warning spam while the app waits in the background for mpv to recreate the IPC socket. diff --git a/changes/name-match-controls-character-dictionary.md b/changes/name-match-controls-character-dictionary.md new file mode 100644 index 00000000..3117384a --- /dev/null +++ b/changes/name-match-controls-character-dictionary.md @@ -0,0 +1,4 @@ +type: fixed +area: character-dictionary + +- Use `subtitleStyle.nameMatchEnabled` as the only switch for character-dictionary sync/builds and hide the legacy `anilist.characterDictionary.enabled` option. diff --git a/config.example.jsonc b/config.example.jsonc index ebcfb941..fe19c2a6 100644 --- a/config.example.jsonc +++ b/config.example.jsonc @@ -46,10 +46,16 @@ // Logging // Controls logging verbosity. // Set to debug for full runtime diagnostics. - // Hot-reload: logging.level applies live while SubMiner is running. + // Hot-reload: logging.level and logging.files apply live while SubMiner is running. // ========================================== "logging": { - "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error + "level": "warn", // Minimum log level for runtime logging. Values: debug | info | warn | error + "rotation": 7, // Number of days of app, launcher, and mpv logs to retain. + "files": { + "app": true, // Write SubMiner app runtime logs. Values: true | false + "launcher": true, // Write launcher command logs. Values: true | false + "mpv": false // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false + } // Files setting. }, // Controls logging verbosity. // ========================================== @@ -383,7 +389,7 @@ "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false "autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false - "nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false + "nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false "nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. @@ -588,11 +594,10 @@ "enabled": false, // Enable AniList post-watch progress updates. Values: true | false "accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup. "characterDictionary": { - "enabled": false, // Enable automatic Yomitan character dictionary sync for currently watched AniList media. Values: true | false "refreshTtlHours": 168, // Legacy setting; merged character dictionary retention is now usage-based and this value is ignored. "maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary. "evictionPolicy": "delete", // Legacy setting; merged character dictionary eviction is usage-based and this value is ignored. Values: disable | delete - "profileScope": "all", // Yomitan profile scope for dictionary enable/disable updates. Values: all | active + "profileScope": "all", // Yomitan profile scope for character dictionary settings updates. Values: all | active "collapsibleSections": { "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false @@ -625,7 +630,7 @@ "executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH. "launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen "profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile. - "socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin. + "socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin. "backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows "autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false diff --git a/docs-site/.vitepress/config.ts b/docs-site/.vitepress/config.ts index 6628c740..463ca476 100644 --- a/docs-site/.vitepress/config.ts +++ b/docs-site/.vitepress/config.ts @@ -19,18 +19,26 @@ type VersionManifest = { versions: Array<{ version: string; path: string }>; }; -const base = normalizeBase(process.env.SUBMINER_DOCS_BASE ?? '/'); -const outDir = process.env.SUBMINER_DOCS_OUT_DIR; -const docsSourceDir = process.env.SUBMINER_DOCS_SOURCE_DIR ?? process.cwd(); -const localArchiveDir = resolve( - process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR ?? - join(docsSourceDir, '..', '.tmp/docs-versioned-site'), -); -const channel = normalizeChannel(process.env.SUBMINER_DOCS_CHANNEL); -const docsVersion = process.env.SUBMINER_DOCS_VERSION; -const latestStable = process.env.SUBMINER_DOCS_LATEST_STABLE ?? 'v0.14.0'; +function optionalEnv(value: string | undefined): string | undefined { + return value && value !== 'undefined' ? value : undefined; +} + +const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/'); +const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR); +const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd(); +const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL)); +const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION); +const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.14.0'; const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST); -const versionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN ?? 'production'; +const versionLinkOrigin = + optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? 'production'; + +function getLocalArchiveDir(): string { + return resolve( + optionalEnv(process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR) ?? + join(docsSourceDir, '..', '.tmp/docs-versioned-site'), + ); +} function normalizeBase(value: string): string { if (!value || value === '/') return '/'; @@ -43,7 +51,7 @@ function normalizeChannel(value: string | undefined): DocsChannel { } function parseVersionManifest(value: string | undefined): VersionManifest { - if (!value) { + if (!value || value === 'undefined') { return { latestStable, channels: [ @@ -218,6 +226,7 @@ function isFile(path: string): boolean { function archiveFileForPathname(pathname: string): string | null { if (!shouldHandleLocalVersionRoute(pathname)) return null; + const localArchiveDir = getLocalArchiveDir(); const routePath = decodeURIComponent(pathname).replace(/^\/+/, ''); const filePath = resolve(localArchiveDir, routePath); if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) { @@ -234,7 +243,11 @@ function archiveFileForPathname(pathname: string): string | null { } function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean { - if (versionLinkOrigin !== 'local') return false; + if ( + (optionalEnv(process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN) ?? versionLinkOrigin) !== 'local' + ) { + return false; + } const filePath = archiveFileForPathname(pathname); if (!filePath) return false; diff --git a/docs-site/anilist-integration.md b/docs-site/anilist-integration.md index 76cb111f..89452397 100644 --- a/docs-site/anilist-integration.md +++ b/docs-site/anilist-integration.md @@ -98,12 +98,11 @@ All AniList API calls go through a shared rate limiter that enforces a sliding w | ------------------------------------------- | ------------------- | ------------------------------------------------------------------------------------------------------------ | | `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | | `accessToken` | string | Explicit AniList access token override; when blank, SubMiner uses the stored encrypted token (default: `""`) | -| `characterDictionary.enabled` | `true`, `false` | Enable auto-sync of the merged character dictionary from AniList (default: `false`) | | `characterDictionary.maxLoaded` | number | Number of recent media snapshots kept in the merged dictionary (default: `3`) | | `characterDictionary.profileScope` | `"all"`, `"active"` | Apply dictionary to all Yomitan profiles or only the active one | | `characterDictionary.collapsibleSections.*` | `true`, `false` | Control which dictionary entry sections start expanded | -See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format. +See the [Character Dictionary](/character-dictionary) page for full details on the character dictionary feature, including name generation, matching, auto-sync lifecycle, and dictionary entry format. Character dictionary sync follows `subtitleStyle.nameMatchEnabled`. ## CLI Commands diff --git a/docs-site/character-dictionary.md b/docs-site/character-dictionary.md index ea1c959d..1d2bbd6c 100644 --- a/docs-site/character-dictionary.md +++ b/docs-site/character-dictionary.md @@ -21,7 +21,7 @@ The feature has three stages: **snapshot**, **merge**, and **match**. Character dictionary sync is disabled by default. To turn it on: 1. Authenticate with AniList (see [AniList Integration](/anilist-integration#setup)). -2. Set `anilist.characterDictionary.enabled` to `true` in your config. +2. Set `subtitleStyle.nameMatchEnabled` to `true` in your config or enable **Name Match Enabled** in Settings. 3. Start watching — SubMiner will generate a snapshot for the current media and import the merged dictionary into Yomitan automatically. ```jsonc @@ -29,9 +29,9 @@ Character dictionary sync is disabled by default. To turn it on: "anilist": { "enabled": true, "accessToken": "your-token", - "characterDictionary": { - "enabled": true, - }, + }, + "subtitleStyle": { + "nameMatchEnabled": true, }, } ``` @@ -102,7 +102,7 @@ Name matches are visually distinct from [N+1 targeting, frequency highlighting, | Option | Default | Description | | -------------------------------------- | --------- | ----------------------------------------- | -| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting | +| `subtitleStyle.nameMatchEnabled` | `false` | Enable dictionary sync and highlighting | | `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names | @@ -136,7 +136,7 @@ The three collapsible sections can be configured to start open or closed: ## Auto-Sync Lifecycle -When `characterDictionary.enabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes. +When `subtitleStyle.nameMatchEnabled` is `true`, SubMiner runs an auto-sync routine whenever the active media changes. **Phases:** @@ -179,7 +179,7 @@ SubMiner uses `guessit` to infer the anime title from the active filename before Use the in-app selector or CLI to pin the correct AniList media for the whole series: -- In-app: open the manager with `Ctrl/Cmd+D`, use the **Override** tab/button, edit the prefilled title if needed, then search and choose the correct result. The CLI flag `--open-character-dictionary` still opens the selector directly. +- In-app: open the manager with `Ctrl/Cmd+D`, use the **Override** tab/button, edit the prefilled title if needed, then search and choose the correct result. - CLI: `--dictionary-candidates` still lists matches for the current filename guess. ```bash @@ -194,7 +194,7 @@ SubMiner.AppImage --dictionary-candidates --dictionary-target "/path/to/episode. SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv" # Open the in-app selector from the running app -subminer app --open-character-dictionary +subminer app --session-action '{"actionId":"openCharacterDictionaryManager"}' ``` Manual selections are stored in `character-dictionaries/anilist-overrides.json` using a series key derived from the episode's parent directory plus the filename guess. Later episodes in the same directory use the selected AniList ID automatically, while separate season directories can keep separate overrides and character dictionaries. When the override replaces a previous wrong match, SubMiner removes that stale media ID from the merged dictionary's active set and rebuilds/imports the merged character dictionary. @@ -243,13 +243,12 @@ merged.zip | Option | Default | Description | | ---------------------------------------------------------------------- | --------- | --------------------------------------------------------------- | -| `anilist.characterDictionary.enabled` | `false` | Enable auto-sync of character dictionary from AniList | | `anilist.characterDictionary.maxLoaded` | `3` | Number of recent media snapshots kept in the merged dictionary | | `anilist.characterDictionary.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only | | `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded | | `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded | | `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By section expanded | -| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting in subtitles | +| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-dictionary sync and name highlighting | | `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside matched names | | `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches | @@ -272,7 +271,7 @@ If you work with visual novels or want a standalone dictionary generator indepen ## Troubleshooting -- **Names not highlighting:** Confirm `anilist.characterDictionary.enabled` is `true` and `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters. +- **Names not highlighting:** Confirm `subtitleStyle.nameMatchEnabled` is `true`. Check that the current media has an AniList entry — SubMiner needs a media ID to fetch characters. - **Inline portraits missing:** Confirm `subtitleStyle.nameMatchImagesEnabled` is `true`. On the next character dictionary sync, SubMiner refreshes current-version snapshots that do not contain usable cached character portrait data. Portraits still require AniList to return an image and the image download to succeed. - **Sync seems stuck:** The auto-sync debounces for 800ms after media changes and throttles image downloads at 250ms per image. Large casts (50+ characters) take longer. Check the status bar for the current sync phase. - **Wrong characters showing:** Open the in-app character dictionary manager (`Ctrl/Cmd+D`) to remove/reorder loaded titles, then use **Override** to correct the active AniList match. You can also run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id `. SubMiner ignores character entries from other loaded titles for subtitle name matching and inline portraits once the current media ID is known. diff --git a/docs-site/configuration.md b/docs-site/configuration.md index 5b58a94d..94a851b4 100644 --- a/docs-site/configuration.md +++ b/docs-site/configuration.md @@ -193,14 +193,24 @@ Control the minimum log level for runtime output: ```json { "logging": { - "level": "info" + "level": "warn", + "rotation": 7, + "files": { + "app": true, + "launcher": true, + "mpv": false + } } } ``` -| Option | Values | Description | -| ------- | ---------------------------------------- | --------------------------------------------------------- | -| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) | +| Option | Values | Description | +| ---------------- | ---------------------------------------- | -------------------------------------------------------------------- | +| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"warn"`) | +| `rotation` | positive integer | Number of days of app, launcher, and mpv logs to retain (default: 7) | +| `files.app` | boolean | Write SubMiner app runtime logs (default: `true`) | +| `files.launcher` | boolean | Write launcher command logs (default: `true`) | +| `files.mpv` | boolean | Write mpv player logs. Enable temporarily for mpv/plugin debugging. | ### Updates @@ -386,7 +396,7 @@ See `config.example.jsonc` for detailed configuration options. | `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). | | `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) | | `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight (default: `"transparent"`); `hoverBackground` is accepted as an alias | -| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`false` by default) | +| `nameMatchEnabled` | boolean | Enable character dictionary sync and subtitle token coloring for character-name matches (`false` by default) | | `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) | | `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) | | `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) | @@ -420,10 +430,10 @@ In `single` mode all highlights use `singleColor`; in `banded` mode tokens map t Character-name highlighting is separate from N+1 and frequency highlighting: -- `nameMatchEnabled` controls whether SubMiner includes character-dictionary name matches in subtitle token metadata and renderer styling. +- `nameMatchEnabled` controls whether SubMiner syncs the character dictionary and includes character-dictionary name matches in subtitle token metadata and renderer styling. - `nameMatchImagesEnabled` adds small circular portraits beside matched names using the AniList images already cached with character dictionary snapshots. - `nameMatchColor` sets the highlight color for those matched character names. -- Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when enabled. +- Matches come from the bundled SubMiner character dictionary, including AniList-synced merged dictionaries when name matching is enabled. Secondary subtitle defaults: `fontFamily: "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`, `fontSize: 24`, `fontColor: "#cad3f5"`, `textShadow: "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"`, `backgroundColor: "transparent"`, `fontWeight: "600"`. Any property not set in `secondary` falls back to the CSS defaults. @@ -630,26 +640,26 @@ See `config.example.jsonc` for detailed configuration options. } ``` -| Option | Values | Description | -| ----------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | -| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | -| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | -| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | -| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when `behavior.autoUpdateNewCards` is `false`) | -| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) | -| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) | -| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) | -| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | -| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | -| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | -| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) | -| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | -| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) | -| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | -| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | -| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | -| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | +| Option | Values | Description | +| -------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) | +| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) | +| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+C"`) | +| `updateLastCardFromClipboard` | string \| `null` | Accelerator for updating card from clipboard (default: `"CommandOrControl+V"`) | +| `triggerFieldGrouping` | string \| `null` | Accelerator for Kiku field grouping on last card (default: `"CommandOrControl+G"`; only active when `behavior.autoUpdateNewCards` is `false`) | +| `triggerSubsync` | string \| `null` | Accelerator for running Subsync (default: `"Ctrl+Alt+S"`) | +| `mineSentence` | string \| `null` | Accelerator for creating sentence card from current subtitle (default: `"CommandOrControl+S"`) | +| `mineSentenceMultiple` | string \| `null` | Accelerator for multi-mine sentence card mode (default: `"CommandOrControl+Shift+S"`) | +| `multiCopyTimeoutMs` | number | Timeout in ms for multi-copy/mine digit input (default: `3000`) | +| `toggleSecondarySub` | string \| `null` | Accelerator for cycling secondary subtitle mode (default: `"CommandOrControl+Shift+V"`) | +| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) | +| `openCharacterDictionaryManager` | string \| `null` | Opens the loaded character dictionary manager (default: `"CommandOrControl+D"`) | +| `openRuntimeOptions` | string \| `null` | Opens runtime options palette for live session-only toggles (default: `"CommandOrControl+Shift+O"`) | +| `openSessionHelp` | string \| `null` | Opens the in-overlay session help modal (default: `"CommandOrControl+Slash"`) | +| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) | +| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+C"`) | +| `openJimaku` | string \| `null` | Opens the Jimaku search modal (default: `"Ctrl+Shift+J"`) | +| `toggleSubtitleSidebar` | string \| `null` | Dispatches the subtitle sidebar toggle action (default: `"Backslash"`). `subtitleSidebar.toggleKey` remains the primary bare-key setting. | **See `config.example.jsonc`** for the complete list of shortcut configuration options. @@ -1166,7 +1176,6 @@ AniList integration is opt-in and disabled by default. Enable it to allow SubMin | -------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------- | | `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) | | `accessToken` | string | Optional explicit AniList access token override (default: empty string) | -| `characterDictionary.enabled` | `true`, `false` | Enable automatic import/update of the merged SubMiner character dictionary for recent AniList media | | `characterDictionary.refreshTtlHours` | number | Legacy compatibility setting. Parsed and preserved, but merged dictionary retention is now usage-based | | `characterDictionary.maxLoaded` | number | Maximum number of most-recently-used AniList media snapshots included in the merged dictionary (default: `3`) | | `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | Legacy compatibility setting. Parsed and preserved, but merged dictionary eviction is now usage-based | diff --git a/docs-site/launcher-script.md b/docs-site/launcher-script.md index 5c46524a..60029e20 100644 --- a/docs-site/launcher-script.md +++ b/docs-site/launcher-script.md @@ -64,6 +64,7 @@ subminer video.mkv # play a specific file (default plugin c subminer https://youtu.be/... # YouTube playback (requires yt-dlp) subminer --backend x11 video.mkv # Force x11 backend for a specific file subminer -u # check for SubMiner updates +subminer logs -e # export sanitized log ZIP subminer stats # open immersion dashboard subminer stats -b # start background stats daemon ``` @@ -78,6 +79,7 @@ subminer stats -b # start background stats daemon | `subminer stats cleanup` | Backfill vocabulary metadata and prune stale rows | | `subminer doctor` | Dependency + config + socket diagnostics | | `subminer settings` | Open the SubMiner settings window | +| `subminer logs -e` | Export a sanitized log ZIP and print its path | | `subminer config path` | Print active config file path | | `subminer config show` | Print active config contents | | `subminer mpv status` | Check mpv socket readiness | diff --git a/docs-site/public/config.example.jsonc b/docs-site/public/config.example.jsonc index ebcfb941..fe19c2a6 100644 --- a/docs-site/public/config.example.jsonc +++ b/docs-site/public/config.example.jsonc @@ -46,10 +46,16 @@ // Logging // Controls logging verbosity. // Set to debug for full runtime diagnostics. - // Hot-reload: logging.level applies live while SubMiner is running. + // Hot-reload: logging.level and logging.files apply live while SubMiner is running. // ========================================== "logging": { - "level": "info" // Minimum log level for runtime logging. Values: debug | info | warn | error + "level": "warn", // Minimum log level for runtime logging. Values: debug | info | warn | error + "rotation": 7, // Number of days of app, launcher, and mpv logs to retain. + "files": { + "app": true, // Write SubMiner app runtime logs. Values: true | false + "launcher": true, // Write launcher command logs. Values: true | false + "mpv": false // Write mpv player logs. Enable temporarily when debugging mpv/plugin startup. Values: true | false + } // Files setting. }, // Controls logging verbosity. // ========================================== @@ -383,7 +389,7 @@ "preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false "autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false "autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false - "nameMatchEnabled": false, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false + "nameMatchEnabled": false, // Enable character dictionary sync and subtitle token coloring for character-name matches. Values: true | false "nameMatchImagesEnabled": false, // Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary. Values: true | false "nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary. "nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight. @@ -588,11 +594,10 @@ "enabled": false, // Enable AniList post-watch progress updates. Values: true | false "accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup. "characterDictionary": { - "enabled": false, // Enable automatic Yomitan character dictionary sync for currently watched AniList media. Values: true | false "refreshTtlHours": 168, // Legacy setting; merged character dictionary retention is now usage-based and this value is ignored. "maxLoaded": 3, // Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary. "evictionPolicy": "delete", // Legacy setting; merged character dictionary eviction is usage-based and this value is ignored. Values: disable | delete - "profileScope": "all", // Yomitan profile scope for dictionary enable/disable updates. Values: all | active + "profileScope": "all", // Yomitan profile scope for character dictionary settings updates. Values: all | active "collapsibleSections": { "description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false "characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false @@ -625,7 +630,7 @@ "executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH. "launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen "profile": "", // Optional mpv profile name passed to SubMiner-managed mpv launches. Leave empty to pass no profile. - "socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin. + "socketPath": "\\\\.\\pipe\\subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin. "backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows "autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false "pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false diff --git a/docs-site/seo.test.ts b/docs-site/seo.test.ts index 0410fe88..2608457e 100644 --- a/docs-site/seo.test.ts +++ b/docs-site/seo.test.ts @@ -362,7 +362,9 @@ test('dev server serves local archive files for local version links', async () = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local'; process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir; try { - const { default: localDevConfig } = await import('./.vitepress/config?local-dev-redirects'); + const { default: localDevConfig } = await import( + `./.vitepress/config?local-dev-redirects-${Date.now()}` + ); let routeHandler: | ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void) | undefined; diff --git a/docs-site/usage.md b/docs-site/usage.md index 9e75eb3b..deab00ff 100644 --- a/docs-site/usage.md +++ b/docs-site/usage.md @@ -48,10 +48,10 @@ From there, subtitles render as interactive, hoverable word spans and you mine c ### Ways to Launch -| Approach | Use when | How | -| ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | -| **`subminer` launcher** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **Recommended for most users.** | `subminer video.mkv` | -| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` | +| Approach | Use when | How | +| ----------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------- | +| **`subminer` launcher** | You want SubMiner to handle everything — launch mpv, set up the socket, start the overlay. **Recommended for most users.** | `subminer video.mkv` | +| **SubMiner mpv shortcut** (Windows) | The recommended Windows entry point. Created during first-run setup, launches mpv with SubMiner's defaults. | Double-click, drag a file onto it, or run `SubMiner.exe --launch-mpv` | | **mpv plugin** (all platforms) | Bundled and injected at runtime. Provides `y` chord keybindings for controlling the overlay from within mpv. No manual install needed. | Automatic when using the launcher or shortcut | The mpv plugin is always available — it's bundled with SubMiner and injected at runtime. If you launch mpv yourself (without the launcher), pass `--input-ipc-server=/tmp/subminer-socket` in your mpv config for the overlay to connect. @@ -105,6 +105,7 @@ subminer jellyfin -p # Interactive Jellyfin library/item picker + p subminer jellyfin -d # Jellyfin cast-discovery mode (background tray app) subminer app --stop # Stop background app (including Jellyfin cast broadcast) subminer doctor # Dependency + config + socket diagnostics +subminer logs -e # Export a sanitized log ZIP and print its path subminer config path # Print active config path subminer config show # Print active config contents subminer mpv socket # Print active mpv socket path @@ -143,10 +144,11 @@ SubMiner.AppImage --jellyfin-remote-announce # Force cast-target capability ann SubMiner.AppImage --dictionary # Generate character dictionary ZIP for current anime SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 # Pin correct AniList media for series -SubMiner.AppImage --open-character-dictionary # Open in-app AniList selector SubMiner.AppImage --help # Show all options ``` +The tray menu includes `Export Logs`, which creates the same sanitized log ZIP as `subminer logs -e` and shows the archive path when complete. + Once Jellyfin is configured, the tray menu includes `Jellyfin Discovery` for starting or stopping cast discovery in the current app session without changing config. ### Logging and App Mode @@ -187,6 +189,7 @@ This flow requires `mpv.exe` to be discoverable. Leave `mpv.executablePath` blan - `subminer jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases. - `subminer doctor`: health checks for core dependencies and runtime paths. - `subminer settings`: open the SubMiner settings window (also `subminer --settings`). +- `subminer logs -e`: export a sanitized ZIP of today's logs, or the most recent logs when no current-day log exists. - `subminer config`: config file helpers (`path`, `show`). - `subminer mpv`: mpv helpers (`status`, `socket`, `idle`). - `subminer dictionary `: generates a Yomitan-importable character dictionary ZIP from a file/directory target. @@ -220,7 +223,7 @@ Setup flow: AniList character dictionary auto-sync (optional): -- Enable with `anilist.characterDictionary.enabled=true` in config. +- Enable with `subtitleStyle.nameMatchEnabled=true` in config or **Name Match Enabled** in Settings. - SubMiner syncs the currently watched AniList media into a per-media snapshot, then rebuilds one merged `SubMiner Character Dictionary` from the most recently used snapshots. - Rotation limit defaults to 3 recent media snapshots in that merged dictionary (`maxLoaded`). diff --git a/launcher/aniskip-metadata.test.ts b/launcher/aniskip-metadata.test.ts index e003177c..516fe427 100644 --- a/launcher/aniskip-metadata.test.ts +++ b/launcher/aniskip-metadata.test.ts @@ -163,7 +163,7 @@ test('buildSubminerScriptOpts includes aniskip payload fields', () => { const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/); assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/); assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/); - assert.match(opts, /subminer-log_level=debug/); + assert.doesNotMatch(opts, /subminer-log_level=/); assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/); assert.match(opts, /subminer-aniskip_season=1/); assert.match(opts, /subminer-aniskip_episode=5/); diff --git a/launcher/aniskip-metadata.ts b/launcher/aniskip-metadata.ts index 68aa0816..99620249 100644 --- a/launcher/aniskip-metadata.ts +++ b/launcher/aniskip-metadata.ts @@ -564,7 +564,7 @@ export function buildSubminerScriptOpts( appPath: string, socketPath: string, aniSkipMetadata: AniSkipMetadata | null, - logLevel: LogLevel = 'info', + _logLevel: LogLevel = 'info', extraParts: string[] = [], ): string { const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path=')); @@ -574,9 +574,6 @@ export function buildSubminerScriptOpts( ...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]), ...extraParts.map(sanitizeScriptOptValue), ]; - if (logLevel !== 'info') { - parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`); - } if (aniSkipMetadata && aniSkipMetadata.title) { parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`); } diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts index 0d5ae93f..d208c901 100644 --- a/launcher/commands/command-modules.test.ts +++ b/launcher/commands/command-modules.test.ts @@ -6,6 +6,7 @@ import type { LauncherCommandContext } from './context.js'; import { runConfigCommand } from './config-command.js'; import { runDictionaryCommand } from './dictionary-command.js'; import { runDoctorCommand } from './doctor-command.js'; +import { runLogsCommand } from './logs-command.js'; import { runMpvPreAppCommand } from './mpv-command.js'; import { runAppPassthroughCommand } from './app-command.js'; import { runStatsCommand } from './stats-command.js'; @@ -169,6 +170,33 @@ test('doctor command forwards refresh-known-words to app binary', () => { assert.deepEqual(forwarded, [['--refresh-known-words']]); }); +test('logs command exports logs and writes archive path', () => { + const writes: string[] = []; + const context = createContext(); + context.args.logsExport = true; + context.processAdapter = { + ...context.processAdapter, + writeStdout: (text) => writes.push(text), + }; + + const handled = runLogsCommand(context, { + exportLogsArchive: () => ({ + zipPath: '/tmp/subminer-logs.zip', + exportedFiles: ['/tmp/app.log'], + mode: 'current-day', + }), + }); + + assert.equal(handled, true); + assert.deepEqual(writes, ['/tmp/subminer-logs.zip\n']); +}); + +test('logs command ignores unrelated launcher commands', () => { + const context = createContext(); + + assert.equal(runLogsCommand(context), false); +}); + test('app command starts default macOS background app detached from launcher', () => { const context = createContext(); context.args.appPassthrough = true; @@ -185,7 +213,7 @@ test('app command starts default macOS background app detached from launcher', ( }); assert.equal(handled, true); - assert.deepEqual(calls, ['detached:/tmp/subminer.app:info']); + assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']); }); test('app command starts default Linux background app detached from launcher', () => { @@ -204,7 +232,7 @@ test('app command starts default Linux background app detached from launcher', ( }); assert.equal(handled, true); - assert.deepEqual(calls, ['detached:/tmp/subminer.app:info']); + assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']); }); test('app command keeps explicit passthrough args attached', () => { diff --git a/launcher/commands/dictionary-command.ts b/launcher/commands/dictionary-command.ts index 210ced30..d316ae93 100644 --- a/launcher/commands/dictionary-command.ts +++ b/launcher/commands/dictionary-command.ts @@ -1,4 +1,5 @@ import { runAppCommandWithInherit } from '../mpv.js'; +import { shouldForwardLogLevel } from '../types.js'; import type { LauncherCommandContext } from './context.js'; interface DictionaryCommandDeps { @@ -35,7 +36,7 @@ export function runDictionaryCommand( if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) { forwarded.push('--dictionary-target', args.dictionaryTarget); } - if (args.logLevel !== 'info') { + if (shouldForwardLogLevel(args.logLevel)) { forwarded.push('--log-level', args.logLevel); } diff --git a/launcher/commands/jellyfin-command.ts b/launcher/commands/jellyfin-command.ts index f6696bdc..cc22b0f1 100644 --- a/launcher/commands/jellyfin-command.ts +++ b/launcher/commands/jellyfin-command.ts @@ -2,6 +2,7 @@ import { fail } from '../log.js'; import { runAppCommandWithInherit } from '../mpv.js'; import { commandExists } from '../util.js'; import { runJellyfinPlayMenu } from '../jellyfin.js'; +import { shouldForwardLogLevel } from '../types.js'; import type { LauncherCommandContext } from './context.js'; export async function runJellyfinCommand(context: LauncherCommandContext): Promise { @@ -18,7 +19,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.jellyfin) { const forwarded = ['--jellyfin']; - if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); + if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); return true; @@ -42,7 +43,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi '--jellyfin-password', password, ]; - if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); + if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); return true; @@ -50,7 +51,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.jellyfinLogout) { const forwarded = ['--jellyfin-logout']; - if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); + if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); return true; @@ -69,7 +70,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi if (args.jellyfinDiscovery) { const forwarded = ['--background', '--jellyfin-remote-announce']; - if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); + if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel); appendPasswordStore(forwarded); runAppCommandWithInherit(appPath, forwarded); return true; diff --git a/launcher/commands/logs-command.ts b/launcher/commands/logs-command.ts new file mode 100644 index 00000000..4805f9b6 --- /dev/null +++ b/launcher/commands/logs-command.ts @@ -0,0 +1,24 @@ +import { exportLogsArchiveForCurrentUser } from '../../src/main/runtime/log-export.js'; +import type { ExportLogsResult } from '../../src/main/runtime/log-export.js'; +import type { LauncherCommandContext } from './context.js'; + +interface LogsCommandDeps { + exportLogsArchive(): ExportLogsResult; +} + +const defaultDeps: LogsCommandDeps = { + exportLogsArchive: () => exportLogsArchiveForCurrentUser(), +}; + +export function runLogsCommand( + context: LauncherCommandContext, + deps: LogsCommandDeps = defaultDeps, +): boolean { + if (!context.args.logsExport) { + return false; + } + + const result = deps.exportLogsArchive(); + context.processAdapter.writeStdout(`${result.zipPath}\n`); + return true; +} diff --git a/launcher/commands/playback-command.test.ts b/launcher/commands/playback-command.test.ts index 3fb69e7a..9f336da9 100644 --- a/launcher/commands/playback-command.test.ts +++ b/launcher/commands/playback-command.test.ts @@ -36,6 +36,7 @@ function createContext(): LauncherCommandContext { texthookerOpenBrowser: false, useRofi: false, logLevel: 'info', + logRotation: 7, passwordStore: '', target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw', targetKind: 'url', @@ -55,6 +56,7 @@ function createContext(): LauncherCommandContext { stats: false, doctor: false, doctorRefreshKnownWords: false, + logsExport: false, version: false, settings: false, configPath: false, @@ -321,6 +323,7 @@ test('plugin auto-start playback attaches a warm background app through the laun test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => { const context = createContext(); const originalXdgConfigHome = process.env.XDG_CONFIG_HOME; + const originalAppData = process.env.APPDATA; const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-')); const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner'); fs.mkdirSync(expectedConfigDir, { recursive: true }); @@ -347,6 +350,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app try { process.env.XDG_CONFIG_HOME = xdgConfigHome; + process.env.APPDATA = xdgConfigHome; await runPlaybackCommandWithDeps(context, { ensurePlaybackSetupReady: async () => {}, @@ -376,6 +380,11 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app } else { process.env.XDG_CONFIG_HOME = originalXdgConfigHome; } + if (originalAppData === undefined) { + delete process.env.APPDATA; + } else { + process.env.APPDATA = originalAppData; + } fs.rmSync(xdgConfigHome, { recursive: true, force: true }); } }); diff --git a/launcher/commands/stats-command.ts b/launcher/commands/stats-command.ts index 9751d6f3..b0e9acaf 100644 --- a/launcher/commands/stats-command.ts +++ b/launcher/commands/stats-command.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { runAppCommandAttached } from '../mpv.js'; import { nowMs } from '../time.js'; import { sleep } from '../util.js'; +import { shouldForwardLogLevel } from '../types.js'; import type { LauncherCommandContext } from './context.js'; type StatsCommandResponse = { @@ -156,7 +157,7 @@ export async function runStatsCommand( if (args.statsCleanupLifetime) { forwarded.push('--stats-cleanup-lifetime'); } - if (args.logLevel !== 'info') { + if (shouldForwardLogLevel(args.logLevel)) { forwarded.push('--log-level', args.logLevel); } const attachedExitPromise = resolvedDeps.runAppCommandAttached( diff --git a/launcher/config.test.ts b/launcher/config.test.ts index 2962e555..72f5ee2d 100644 --- a/launcher/config.test.ts +++ b/launcher/config.test.ts @@ -13,6 +13,7 @@ test('launcher root help lists subcommands', () => { assert.match(output, /doctor/); assert.match(output, /config/); assert.match(output, /mpv/); + assert.match(output, /logs/); assert.match(output, /dictionary\|dict/); assert.match(output, /texthooker/); assert.match(output, /app\|bin/); diff --git a/launcher/config.ts b/launcher/config.ts index 282ed69a..57a7f9a7 100644 --- a/launcher/config.ts +++ b/launcher/config.ts @@ -1,12 +1,14 @@ import { fail } from './log.js'; import type { Args, + LauncherLoggingConfig, LauncherJellyfinConfig, LauncherMpvConfig, LauncherYoutubeSubgenConfig, LogLevel, PluginRuntimeConfig, } from './types.js'; +import { normalizeLogRotation } from '../src/shared/log-files.js'; import { applyInvocationsToArgs, applyRootOptionsToArgs, @@ -52,6 +54,52 @@ export function loadLauncherMpvConfig(): LauncherMpvConfig { return parseLauncherMpvConfig(root); } +function parseLogLevelConfig(value: unknown): LogLevel | undefined { + if (typeof value !== 'string') return undefined; + const normalized = value.trim().toLowerCase(); + if ( + normalized === 'debug' || + normalized === 'info' || + normalized === 'warn' || + normalized === 'error' + ) { + return normalized; + } + return undefined; +} + +function parseLogRotationConfig(value: unknown): LauncherLoggingConfig['rotation'] { + return normalizeLogRotation(value); +} + +function parseLogFileConfig(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +export function loadLauncherLoggingConfig(): LauncherLoggingConfig { + const root = readLauncherMainConfigObject(); + if (!root) return {}; + const logging = + root.logging && typeof root.logging === 'object' && !Array.isArray(root.logging) + ? (root.logging as Record) + : null; + const files = + logging?.files && typeof logging.files === 'object' && !Array.isArray(logging.files) + ? (logging.files as Record) + : null; + return { + level: parseLogLevelConfig(logging?.level), + rotation: parseLogRotationConfig(logging?.rotation), + files: files + ? { + app: parseLogFileConfig(files.app), + launcher: parseLogFileConfig(files.launcher), + mpv: parseLogFileConfig(files.mpv), + } + : undefined, + }; +} + export function hasLauncherExternalYomitanProfileConfig(): boolean { return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null; } @@ -65,9 +113,10 @@ export function parseArgs( scriptName: string, launcherConfig: LauncherYoutubeSubgenConfig, launcherMpvConfig: LauncherMpvConfig = {}, + launcherLoggingConfig: LauncherLoggingConfig = {}, ): Args { const topLevelCommand = resolveTopLevelCommand(argv); - const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig); + const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig, launcherLoggingConfig); if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) { parsed.appPassthrough = true; diff --git a/launcher/config/args-normalizer.test.ts b/launcher/config/args-normalizer.test.ts index 116fa183..972378c6 100644 --- a/launcher/config/args-normalizer.test.ts +++ b/launcher/config/args-normalizer.test.ts @@ -51,6 +51,13 @@ test('createDefaultArgs seeds mpv profile from launcher config', () => { assert.equal(parsed.profile, 'anime'); }); +test('createDefaultArgs seeds log level from launcher logging config', () => { + const parsed = createDefaultArgs({}, {}, { level: 'debug', rotation: 14 }); + + assert.equal(parsed.logLevel, 'debug'); + assert.equal(parsed.logRotation, 14); +}); + test('applyRootOptionsToArgs appends CLI mpv profile to configured profile', () => { const parsed = createDefaultArgs({}, { profile: 'anime' }); @@ -131,6 +138,8 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => { doctorTriggered: false, doctorLogLevel: null, doctorRefreshKnownWords: false, + logsTriggered: false, + logsExport: false, texthookerTriggered: false, texthookerLogLevel: null, texthookerOpenBrowser: false, @@ -175,6 +184,8 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () => doctorTriggered: false, doctorLogLevel: null, doctorRefreshKnownWords: false, + logsTriggered: false, + logsExport: false, texthookerTriggered: false, texthookerLogLevel: null, texthookerOpenBrowser: false, @@ -212,6 +223,8 @@ test('applyInvocationsToArgs fails when config invocation has no action', () => doctorTriggered: false, doctorLogLevel: null, doctorRefreshKnownWords: false, + logsTriggered: false, + logsExport: false, texthookerTriggered: false, texthookerLogLevel: null, texthookerOpenBrowser: false, @@ -247,6 +260,8 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => { doctorTriggered: false, doctorLogLevel: null, doctorRefreshKnownWords: false, + logsTriggered: false, + logsExport: false, texthookerTriggered: true, texthookerLogLevel: null, texthookerOpenBrowser: true, diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts index 9c29eaa7..73b83acd 100644 --- a/launcher/config/args-normalizer.ts +++ b/launcher/config/args-normalizer.ts @@ -4,6 +4,7 @@ import { fail } from '../log.js'; import type { Args, Backend, + LauncherLoggingConfig, LauncherMpvConfig, LauncherYoutubeSubgenConfig, LogLevel, @@ -106,6 +107,7 @@ function parseDictionaryAnilistId(value: string): number { export function createDefaultArgs( launcherConfig: LauncherYoutubeSubgenConfig, mpvConfig: LauncherMpvConfig = {}, + loggingConfig: LauncherLoggingConfig = {}, ): Args { const configuredSecondaryLangs = uniqueNormalizedLangCodes( launcherConfig.secondarySubLanguages ?? [], @@ -162,6 +164,7 @@ export function createDefaultArgs( statsCleanupLifetime: false, doctor: false, doctorRefreshKnownWords: false, + logsExport: false, version: false, update: false, settings: false, @@ -195,7 +198,8 @@ export function createDefaultArgs( texthookerOnly: false, texthookerOpenBrowser: false, useRofi: false, - logLevel: 'info', + logLevel: loggingConfig.level ?? 'warn', + logRotation: loggingConfig.rotation ?? 7, passwordStore: '', target: '', targetKind: '', @@ -260,6 +264,10 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations } if (invocations.doctorTriggered) parsed.doctor = true; if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true; + if (invocations.logsTriggered && !invocations.logsExport) { + fail('Logs command requires -e or --export.'); + } + if (invocations.logsExport) parsed.logsExport = true; if (invocations.texthookerTriggered) parsed.texthookerOnly = true; if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true; diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts index eaad4929..bdef0567 100644 --- a/launcher/config/cli-parser-builder.ts +++ b/launcher/config/cli-parser-builder.ts @@ -41,6 +41,8 @@ export interface CliInvocations { doctorTriggered: boolean; doctorLogLevel: string | null; doctorRefreshKnownWords: boolean; + logsTriggered: boolean; + logsExport: boolean; texthookerTriggered: boolean; texthookerLogLevel: string | null; texthookerOpenBrowser: boolean; @@ -91,6 +93,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n 'config', 'settings', 'mpv', + 'logs', 'dictionary', 'dict', 'stats', @@ -158,6 +161,8 @@ export function parseCliPrograms( let statsLogLevel: string | null = null; let doctorLogLevel: string | null = null; let doctorRefreshKnownWords = false; + let logsTriggered = false; + let logsExport = false; let texthookerLogLevel: string | null = null; let texthookerOpenBrowser = false; let doctorTriggered = false; @@ -294,6 +299,15 @@ export function parseCliPrograms( doctorRefreshKnownWords = options.refreshKnownWords === true; }); + commandProgram + .command('logs') + .description('Log file helpers') + .option('-e, --export', 'Export sanitized log archive') + .action((options: Record) => { + logsTriggered = true; + logsExport = options.export === true; + }); + commandProgram .command('config') .description('Config file helpers (path|show)') @@ -388,6 +402,8 @@ export function parseCliPrograms( doctorTriggered, doctorLogLevel, doctorRefreshKnownWords, + logsTriggered, + logsExport, texthookerTriggered, texthookerLogLevel, texthookerOpenBrowser, diff --git a/launcher/config/plugin-runtime-config.ts b/launcher/config/plugin-runtime-config.ts index 5c759da2..c68badf2 100644 --- a/launcher/config/plugin-runtime-config.ts +++ b/launcher/config/plugin-runtime-config.ts @@ -40,6 +40,7 @@ function validBackendOrDefault(value: unknown, fallback: Backend): Backend { export function parsePluginRuntimeConfigFromMainConfig( root: Record | null, + logLevel: LogLevel = 'info', ): PluginRuntimeConfig { const mpvConfig = root ? parseLauncherMpvConfig(root) : {}; const texthooker = rootObject(root, 'texthooker'); @@ -48,6 +49,7 @@ export function parsePluginRuntimeConfigFromMainConfig( socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH, binaryPath: mpvConfig.subminerBinaryPath ?? '', backend: validBackendOrDefault(mpvConfig.backend, 'auto'), + logLevel, autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true), autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false), autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true), @@ -65,7 +67,7 @@ export function buildPluginRuntimeScriptOptParts( } export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig { - const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject()); + const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject(), logLevel); log( 'debug', diff --git a/launcher/jellyfin.ts b/launcher/jellyfin.ts index b7bf5fa5..d2671891 100644 --- a/launcher/jellyfin.ts +++ b/launcher/jellyfin.ts @@ -9,6 +9,7 @@ import type { JellyfinItemEntry, JellyfinGroupEntry, } from './types.js'; +import { shouldForwardLogLevel } from './types.js'; import { log, fail, getMpvLogPath } from './log.js'; import { nowMs } from './time.js'; import { commandExists, resolvePathMaybe, sleep } from './util.js'; @@ -1036,7 +1037,7 @@ export async function runJellyfinPlayMenu( fail(`MPV IPC socket not ready: ${mpvSocketPath}`); } const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`]; - if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel); + if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel); if (args.passwordStore) forwarded.push('--password-store', args.passwordStore); runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play'); } diff --git a/launcher/log.ts b/launcher/log.ts index c0cc3a28..bf4659e5 100644 --- a/launcher/log.ts +++ b/launcher/log.ts @@ -1,6 +1,14 @@ import type { LogLevel } from './types.js'; -import { DEFAULT_MPV_LOG_FILE, getDefaultLauncherLogFile } from './types.js'; -import { appendLogLine, resolveDefaultLogFilePath } from '../src/shared/log-files.js'; +import { getDefaultLauncherLogFile } from './types.js'; +import { + appendLogLine, + DEFAULT_LOG_ROTATION, + isLogFileEnabled, + normalizeLogRotation, + pruneLogDirectoryForPath, + resolveDefaultLogFilePath, + type LogRotation, +} from '../src/shared/log-files.js'; export const COLORS = { red: '\x1b[0;31m', @@ -22,25 +30,36 @@ export function shouldLog(level: LogLevel, configured: LogLevel): boolean { } export function getMpvLogPath(): string { + if (!isLogFileEnabled('mpv')) return ''; const envPath = process.env.SUBMINER_MPV_LOG?.trim(); - if (envPath) return envPath; - return DEFAULT_MPV_LOG_FILE; + const logPath = envPath || resolveDefaultLogFilePath('mpv'); + pruneLogDirectoryForPath(logPath, getLogRotation()); + return logPath; } export function getLauncherLogPath(): string { + if (!isLogFileEnabled('launcher')) return ''; const envPath = process.env.SUBMINER_LAUNCHER_LOG?.trim(); if (envPath) return envPath; return getDefaultLauncherLogFile(); } export function getAppLogPath(): string { + if (!isLogFileEnabled('app')) return ''; const envPath = process.env.SUBMINER_APP_LOG?.trim(); if (envPath) return envPath; return resolveDefaultLogFilePath('app'); } +function getLogRotation(): LogRotation { + return normalizeLogRotation(process.env.SUBMINER_LOG_ROTATION) ?? DEFAULT_LOG_ROTATION; +} + function appendTimestampedLog(logPath: string, message: string): void { - appendLogLine(logPath, `[${new Date().toISOString()}] ${message}`); + if (!logPath.trim()) return; + appendLogLine(logPath, `[${new Date().toISOString()}] ${message}`, { + rotation: getLogRotation(), + }); } export function appendToMpvLog(message: string): void { diff --git a/launcher/main.test.ts b/launcher/main.test.ts index 6350aea4..1d2e4216 100644 --- a/launcher/main.test.ts +++ b/launcher/main.test.ts @@ -124,6 +124,29 @@ test('short version flag prints installed app version without requiring app bina }); }); +test('logs export writes sanitized archive without requiring app binary', () => { + withTempDir((root) => { + const homeDir = path.join(root, 'home'); + const xdgConfigHome = path.join(root, 'xdg'); + const logsDir = + process.platform === 'win32' + ? path.join(xdgConfigHome, 'SubMiner', 'logs') + : path.join(homeDir, '.config', 'SubMiner', 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + fs.writeFileSync(path.join(logsDir, 'app-2026-W21.log'), `/home/kyle/video.mkv\n`, 'utf8'); + + const result = runLauncher(['logs', '-e'], makeTestEnv(homeDir, xdgConfigHome)); + + assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); + const zipPath = result.stdout.trim(); + assert.match(zipPath, /subminer-logs-.+\.zip$/); + assert.equal(fs.existsSync(zipPath), true); + const archive = fs.readFileSync(zipPath); + assert.equal(archive.includes(Buffer.from('/home/kyle')), false); + assert.equal(archive.includes(Buffer.from('/home/')), true); + }); +}); + test('config path prefers jsonc over json for same directory', () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); @@ -395,7 +418,7 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con }); }); -test('launcher forwards non-info log level into mpv plugin script opts', { timeout: 15000 }, () => { +test('launcher forwards non-info log level into mpv logging args', { timeout: 15000 }, () => { withTempDir((root) => { const homeDir = path.join(root, 'home'); const xdgConfigHome = path.join(root, 'xdg'); @@ -430,6 +453,11 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo autoStartSubMiner: true, pauseUntilOverlayReady: true, }, + logging: { + files: { + mpv: true, + }, + }, }), ); fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n'); @@ -468,7 +496,9 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con const result = runLauncher(['--log-level', 'debug', videoPath], env); assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`); - assert.match(fs.readFileSync(mpvArgsPath, 'utf8'), /--script-opts=.*subminer-log_level=debug/); + const mpvArgs = fs.readFileSync(mpvArgsPath, 'utf8'); + assert.match(mpvArgs, /--msg-level=all=warn,subminer=debug/); + assert.doesNotMatch(mpvArgs, /--script-opts=.*subminer-log_level=debug/); }); }); diff --git a/launcher/main.ts b/launcher/main.ts index c3a1efac..62a6060c 100644 --- a/launcher/main.ts +++ b/launcher/main.ts @@ -1,7 +1,9 @@ import path from 'node:path'; import packageJson from '../package.json'; +import { applyLogFileTogglesToEnv } from '../src/shared/log-files.js'; import { loadLauncherJellyfinConfig, + loadLauncherLoggingConfig, loadLauncherMpvConfig, loadLauncherYoutubeSubgenConfig, parseArgs, @@ -16,6 +18,7 @@ import { runConfigCommand } from './commands/config-command.js'; import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js'; import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js'; import { runDictionaryCommand } from './commands/dictionary-command.js'; +import { runLogsCommand } from './commands/logs-command.js'; import { runStatsCommand } from './commands/stats-command.js'; import { runJellyfinCommand } from './commands/jellyfin-command.js'; import { runPlaybackCommand } from './commands/playback-command.js'; @@ -61,7 +64,19 @@ async function main(): Promise { const scriptName = path.basename(scriptPath); const launcherConfig = loadLauncherYoutubeSubgenConfig(); const launcherMpvConfig = loadLauncherMpvConfig(); - const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig, launcherMpvConfig); + const launcherLoggingConfig = loadLauncherLoggingConfig(); + applyLogFileTogglesToEnv(launcherLoggingConfig.files); + process.env.SUBMINER_LOG_ROTATION = + launcherLoggingConfig.rotation !== undefined + ? String(launcherLoggingConfig.rotation) + : (process.env.SUBMINER_LOG_ROTATION ?? '7'); + const args = parseArgs( + process.argv.slice(2), + scriptName, + launcherConfig, + launcherMpvConfig, + launcherLoggingConfig, + ); if (args.version) { console.log(`SubMiner ${APP_VERSION}`); @@ -87,6 +102,10 @@ async function main(): Promise { return; } + if (runLogsCommand(context)) { + return; + } + const resolvedAppPath = ensureAppPath(context); state.appPath = resolvedAppPath; log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts index 4d1f379b..64da5698 100644 --- a/launcher/mpv.test.ts +++ b/launcher/mpv.test.ts @@ -566,6 +566,7 @@ function makeArgs(overrides: Partial = {}): Args { texthookerOpenBrowser: false, useRofi: false, logLevel: 'error', + logRotation: 7, passwordStore: '', target: '', targetKind: '', @@ -585,6 +586,7 @@ function makeArgs(overrides: Partial = {}): Args { stats: false, doctor: false, doctorRefreshKnownWords: false, + logsExport: false, version: false, settings: false, configPath: false, diff --git a/launcher/mpv.ts b/launcher/mpv.ts index 696df171..76f71cbe 100644 --- a/launcher/mpv.ts +++ b/launcher/mpv.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import net from 'node:net'; import { spawn, spawnSync } from 'node:child_process'; import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js'; +import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js'; import { isAppControlServerAvailable as checkAppControlServerAvailable, sendAppControlCommand, @@ -14,7 +15,11 @@ import { type InstalledMpvPluginDetection, } from '../src/main/runtime/first-run-setup-plugin.js'; import type { LogLevel, Backend, Args, MpvTrack, PluginRuntimeConfig } from './types.js'; -import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js'; +import { + DEFAULT_MPV_SUBMINER_ARGS, + DEFAULT_YOUTUBE_YTDL_FORMAT, + shouldForwardLogLevel, +} from './types.js'; import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js'; import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js'; import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js'; @@ -951,7 +956,7 @@ export async function startMpv( ); } mpvArgs.push(`--script-opts=${scriptOpts}`); - mpvArgs.push(`--log-file=${getMpvLogPath()}`); + mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs)); try { fs.rmSync(socketPath, { force: true }); @@ -1031,7 +1036,7 @@ export async function startOverlay( socketPath, ...extraAppArgs, ]; - if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); + if (shouldForwardLogLevel(args.logLevel)) overlayArgs.push('--log-level', args.logLevel); if (args.useTexthooker) overlayArgs.push('--texthooker'); const controlResult = await sendAppControlCommand(overlayArgs, { @@ -1176,7 +1181,7 @@ export function launchTexthookerOnly( ): never { const overlayArgs = ['--texthooker']; if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser'); - if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel); + if (shouldForwardLogLevel(args.logLevel)) overlayArgs.push('--log-level', args.logLevel); log('info', args.logLevel, 'Launching texthooker mode...'); const result = runSyncAppCommand(appPath, overlayArgs, true); @@ -1254,7 +1259,7 @@ function stopManagedOverlayApp(args: Args): void { log('info', args.logLevel, 'Stopping SubMiner overlay...'); const stopArgs = ['--stop']; - if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel); + if (shouldForwardLogLevel(args.logLevel)) stopArgs.push('--log-level', args.logLevel); const target = resolveAppSpawnTarget(state.appPath, stopArgs); const result = spawnSync(target.command, target.args, { @@ -1306,6 +1311,8 @@ function buildAppEnv( ...baseEnv, SUBMINER_APP_LOG: getAppLogPath(), SUBMINER_MPV_LOG: getMpvLogPath(), + SUBMINER_LOG_LEVEL: extraEnv.SUBMINER_LOG_LEVEL ?? baseEnv.SUBMINER_LOG_LEVEL, + SUBMINER_LOG_ROTATION: extraEnv.SUBMINER_LOG_ROTATION ?? baseEnv.SUBMINER_LOG_ROTATION, }; delete env.ELECTRON_RUN_AS_NODE; clearTransportedAppArgs(env); @@ -1326,10 +1333,13 @@ function buildAppEnv( } export function buildMpvEnv( - args: Pick, + args: Pick, baseEnv: NodeJS.ProcessEnv = process.env, ): NodeJS.ProcessEnv { - const env = buildAppEnv(baseEnv); + const env = buildAppEnv(baseEnv, { + SUBMINER_LOG_LEVEL: args.logLevel, + SUBMINER_LOG_ROTATION: String(args.logRotation), + }); if (!shouldForceX11MpvBackend(args, env)) { return env; } @@ -1586,13 +1596,13 @@ export function runAppCommandWithInheritLogged( export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void { const startArgs = ['--start']; - if (logLevel !== 'info') startArgs.push('--log-level', logLevel); + if (shouldForwardLogLevel(logLevel)) startArgs.push('--log-level', logLevel); launchAppCommandDetached(appPath, startArgs, logLevel, 'start'); } export function launchAppBackgroundDetached(appPath: string, logLevel: LogLevel): void { const startArgs = ['--start', '--background']; - if (logLevel !== 'info') startArgs.push('--log-level', logLevel); + if (shouldForwardLogLevel(logLevel)) startArgs.push('--log-level', logLevel); launchAppCommandDetached(appPath, startArgs, logLevel, 'app', { [BACKGROUND_CHILD_ENV]: '1', }); @@ -1615,6 +1625,22 @@ export function launchAppCommandDetached( `${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`, ); const appLogPath = getAppLogPath(); + if (!appLogPath) { + try { + const proc = spawn(target.command, target.args, { + stdio: 'ignore', + detached: true, + env: buildAppEnv(process.env, { ...target.env, ...extraEnv }), + }); + proc.once('error', (error) => { + log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`); + }); + proc.unref(); + } catch (error) { + log('warn', logLevel, `${label}: failed to launch detached app: ${(error as Error).message}`); + } + return; + } fs.mkdirSync(path.dirname(appLogPath), { recursive: true }); const stdoutFd = fs.openSync(appLogPath, 'a'); const stderrFd = fs.openSync(appLogPath, 'a'); @@ -1673,7 +1699,7 @@ export function launchMpvIdleDetached( runtimeScriptOpts, )}`, ); - mpvArgs.push(`--log-file=${getMpvLogPath()}`); + mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs)); mpvArgs.push(`--input-ipc-server=${socketPath}`); const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, { normalizeWindowsShellArgs: false, diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts index fc85ca89..ba8ce222 100644 --- a/launcher/parse-args.test.ts +++ b/launcher/parse-args.test.ts @@ -244,3 +244,18 @@ test('parseArgs maps doctor refresh-known-words flag', () => { assert.equal(parsed.doctor, true); assert.equal(parsed.doctorRefreshKnownWords, true); }); + +test('parseArgs maps logs export flag', () => { + const parsed = parseArgs(['logs', '-e'], 'subminer', {}); + + assert.equal(parsed.logsExport, true); +}); + +test('parseArgs requires an explicit logs action', () => { + const exit = withProcessExitIntercept(() => { + parseArgs(['logs'], 'subminer', {}); + }); + + assert.equal(exit.code, 1); + assert.match(exit.stderr, /Logs command requires -e or --export/); +}); diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts index 16254673..1a987277 100644 --- a/launcher/smoke.e2e.test.ts +++ b/launcher/smoke.e2e.test.ts @@ -23,6 +23,8 @@ type SmokeCase = { artifactsDir: string; binDir: string; xdgConfigHome: string; + appDataDir: string; + localAppDataDir: string; homeDir: string; socketDir: string; socketPath: string; @@ -61,6 +63,8 @@ function createSmokeCase(name: string): SmokeCase { const artifactsDir = path.join(root, 'artifacts'); const binDir = path.join(root, 'bin'); const xdgConfigHome = path.join(root, 'xdg'); + const appDataDir = path.join(root, 'AppData', 'Roaming'); + const localAppDataDir = path.join(root, 'AppData', 'Local'); const homeDir = path.join(root, 'home'); const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-smoke-sock-')); const socketPath = path.join(socketDir, 'subminer.sock'); @@ -73,7 +77,7 @@ function createSmokeCase(name: string): SmokeCase { fs.mkdirSync(binDir, { recursive: true }); fs.writeFileSync(videoPath, 'fake video fixture'); - const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir }); + const configDir = getDefaultConfigDir({ xdgConfigHome, appDataDir, homeDir }); fs.mkdirSync(configDir, { recursive: true }); fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } })); const setupState = createDefaultSetupState(); @@ -159,6 +163,8 @@ process.exit(0); artifactsDir, binDir, xdgConfigHome, + appDataDir, + localAppDataDir, homeDir, socketDir, socketPath, @@ -174,6 +180,8 @@ function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv { ...process.env, HOME: smokeCase.homeDir, XDG_CONFIG_HOME: smokeCase.xdgConfigHome, + APPDATA: smokeCase.appDataDir, + LOCALAPPDATA: smokeCase.localAppDataDir, SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath, SUBMINER_MPV_LOG: smokeCase.mpvOverlayLogPath, }; @@ -410,7 +418,7 @@ test( const env = makeTestEnv(smokeCase); const result = runLauncher( smokeCase, - ['--backend', 'x11', '--start-overlay', smokeCase.videoPath], + ['--backend', 'x11', '--log-level', 'info', '--start-overlay', smokeCase.videoPath], env, 'overlay-start-stop', ); diff --git a/launcher/types.ts b/launcher/types.ts index 400030da..34229bc6 100644 --- a/launcher/types.ts +++ b/launcher/types.ts @@ -1,7 +1,11 @@ import path from 'node:path'; import os from 'node:os'; import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js'; -import { resolveDefaultLogFilePath } from '../src/shared/log-files.js'; +import { + resolveDefaultLogFilePath, + type LogFileToggles, + type LogRotation, +} from '../src/shared/log-files.js'; export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js'; export const ROFI_THEME_FILE = 'subminer.rasi'; @@ -67,6 +71,9 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [ ] as const; export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; +export function shouldForwardLogLevel(level: LogLevel): boolean { + return level === 'debug' || level === 'error'; +} export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows'; export type JimakuLanguagePreference = 'ja' | 'en' | 'none'; @@ -106,6 +113,7 @@ export interface Args { texthookerOpenBrowser: boolean; useRofi: boolean; logLevel: LogLevel; + logRotation: LogRotation; passwordStore: string; target: string; targetKind: '' | 'file' | 'url'; @@ -132,6 +140,7 @@ export interface Args { dictionaryTarget?: string; doctor: boolean; doctorRefreshKnownWords: boolean; + logsExport: boolean; version: boolean; update?: boolean; settings: boolean; @@ -186,10 +195,17 @@ export interface LauncherMpvConfig { aniskipButtonKey?: string; } +export interface LauncherLoggingConfig { + level?: LogLevel; + rotation?: LogRotation; + files?: Partial; +} + export interface PluginRuntimeConfig { socketPath: string; binaryPath: string; backend: Backend; + logLevel?: LogLevel; autoStart: boolean; autoStartVisibleOverlay: boolean; autoStartPauseUntilReady: boolean; diff --git a/package.json b/package.json index f55ab79b..4b202fe4 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua", "test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts", "test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src", - "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", - "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", + "test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/settings-window-z-order.test.ts src/core/services/hyprland-window-placement.test.ts src/core/services/config-hot-reload.test.ts src/core/services/discord-presence.test.ts src/core/services/tokenizer.test.ts src/core/services/tokenizer/annotation-stage.test.ts src/core/services/tokenizer/parser-selection-stage.test.ts src/core/services/tokenizer/parser-enrichment-stage.test.ts src/core/services/subsync.test.ts src/core/services/overlay-bridge.test.ts src/core/services/overlay-manager.test.ts src/core/services/overlay-shortcut-handler.test.ts src/core/services/stats-window.test.ts src/core/services/stats-window-lifecycle.test.ts src/core/services/__tests__/stats-server.test.ts src/main/runtime/stats-server-routing.test.ts src/core/services/mining.test.ts src/core/services/anki-jimaku.test.ts src/core/services/jimaku-download-path.test.ts src/core/services/jellyfin.test.ts src/core/services/jellyfin-remote.test.ts src/core/services/immersion-tracker-service.test.ts src/core/services/overlay-runtime-init.test.ts src/core/services/app-ready.test.ts src/core/services/startup-bootstrap.test.ts src/core/services/subtitle-processing-controller.test.ts src/main/runtime/current-subtitle-snapshot.test.ts src/main/runtime/autoplay-tokenization-warm-release.test.ts src/main/runtime/autoplay-subtitle-primer.test.ts src/main/runtime/character-dictionary-manager-gate.test.ts src/core/services/anilist/anilist-update-queue.test.ts src/core/services/anilist/rate-limiter.test.ts src/core/services/jlpt-token-filter.test.ts src/core/services/subtitle-position.test.ts src/core/utils/shortcut-config.test.ts src/main/runtime/startup-mode-flags.test.ts src/main/runtime/config-settings-window.test.ts src/main/runtime/settings-window-z-order.test.ts src/main/runtime/setup-window-factory.test.ts src/main/runtime/first-run-setup-plugin.test.ts src/main/runtime/first-run-setup-service.test.ts src/main/runtime/first-run-setup-window.test.ts src/main/runtime/command-line-launcher.test.ts src/main/runtime/log-export.test.ts src/main/runtime/tray-runtime.test.ts src/main/runtime/tray-main-actions.test.ts src/main/runtime/tray-main-deps.test.ts src/main/runtime/tray-runtime-handlers.test.ts src/main/runtime/cli-command-context-main-deps.test.ts src/main/runtime/app-ready-main-deps.test.ts src/main/runtime/update/appimage-updater.test.ts src/main/runtime/update/fetch-adapter.test.ts src/main/runtime/update/release-metadata-policy.test.ts src/main/runtime/update/update-dialogs.test.ts src/main/runtime/update/support-assets.test.ts src/renderer/error-recovery.test.ts src/renderer/subtitle-render.test.ts src/renderer/subtitle-render-word-class.test.ts src/renderer/handlers/mouse.test.ts src/renderer/handlers/keyboard.test.ts src/renderer/modals/jimaku.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/main/character-dictionary-runtime/term-building.test.ts src/window-trackers/hyprland-tracker.test.ts src/window-trackers/x11-tracker.test.ts src/window-trackers/windows-helper.test.ts src/window-trackers/windows-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/setup-gate.test.ts stats/src/lib/api-client.test.ts stats/src/hooks/useExcludedWords.test.ts", + "test:core:dist": "bun test dist/preload-settings.test.js dist/settings/settings-anki-controls.test.js dist/settings/settings-model.test.js dist/settings/settings-field-layout.test.js dist/cli/args.test.js dist/cli/help.test.js dist/core/services/cli-command.test.js dist/core/services/ipc.test.js dist/core/services/anki-jimaku-ipc.test.js dist/core/services/field-grouping-overlay.test.js dist/core/services/numeric-shortcut-session.test.js dist/core/services/secondary-subtitle.test.js dist/core/services/mpv-render-metrics.test.js dist/core/services/overlay-content-measurement.test.js dist/core/services/mpv-control.test.js dist/core/services/mpv.test.js dist/core/services/runtime-options-ipc.test.js dist/core/services/runtime-config.test.js dist/core/services/yomitan-extension-paths.test.js dist/core/services/config-hot-reload.test.js dist/core/services/discord-presence.test.js dist/core/services/tokenizer.test.js dist/core/services/tokenizer/annotation-stage.test.js dist/core/services/tokenizer/parser-selection-stage.test.js dist/core/services/tokenizer/parser-enrichment-stage.test.js dist/core/services/subsync.test.js dist/core/services/overlay-bridge.test.js dist/core/services/overlay-manager.test.js dist/core/services/overlay-shortcut-handler.test.js dist/core/services/stats-window-lifecycle.test.js dist/core/services/mining.test.js dist/core/services/anki-jimaku.test.js dist/core/services/jimaku-download-path.test.js dist/core/services/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.test.js dist/core/services/overlay-runtime-init.test.js dist/core/services/app-ready.test.js dist/core/services/startup-bootstrap.test.js dist/core/services/subtitle-processing-controller.test.js dist/main/runtime/current-subtitle-snapshot.test.js dist/main/runtime/autoplay-tokenization-warm-release.test.js dist/main/runtime/autoplay-subtitle-primer.test.js dist/main/runtime/log-export.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/anilist/anilist-update-queue.test.js dist/core/services/anilist/rate-limiter.test.js dist/core/services/jlpt-token-filter.test.js dist/core/services/subtitle-position.test.js dist/renderer/error-recovery.test.js dist/renderer/subtitle-render.test.js dist/renderer/subtitle-render-word-class.test.js dist/renderer/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js", "test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js", "test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist", "test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts", diff --git a/plugin/subminer/lifecycle.lua b/plugin/subminer/lifecycle.lua index 2ddd25d5..24b58290 100644 --- a/plugin/subminer/lifecycle.lua +++ b/plugin/subminer/lifecycle.lua @@ -126,7 +126,9 @@ function M.create(ctx) subminer_log( "info", "lifecycle", - "Skipping auto-start: input-ipc-server does not match configured socket_path" + "Skipping auto-start: input-ipc-server does not match configured socket_path (" + .. process.describe_mpv_ipc_socket_match(opts.socket_path) + .. ")" ) schedule_aniskip_fetch("file-loaded", 0) return diff --git a/plugin/subminer/log.lua b/plugin/subminer/log.lua index 57edde87..a6542acd 100644 --- a/plugin/subminer/log.lua +++ b/plugin/subminer/log.lua @@ -21,7 +21,7 @@ function M.create(ctx) end local function should_log(level) - local current = normalize_log_level(opts.log_level) + local current = normalize_log_level(os.getenv("SUBMINER_LOG_LEVEL")) local target = normalize_log_level(level) return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current] end diff --git a/plugin/subminer/process.lua b/plugin/subminer/process.lua index eae53ff7..05c44a78 100644 --- a/plugin/subminer/process.lua +++ b/plugin/subminer/process.lua @@ -172,13 +172,29 @@ function M.create(ctx) return trimmed end - local function has_matching_mpv_ipc_socket(target_socket_path) + local function get_mpv_ipc_socket_match(target_socket_path) local expected_socket = normalize_socket_path(target_socket_path or opts.socket_path) local active_socket = normalize_socket_path(mp.get_property("input-ipc-server")) + return { + expected_socket = expected_socket, + active_socket = active_socket, + matching = expected_socket ~= nil and active_socket ~= nil and expected_socket == active_socket, + } + end + + local function has_matching_mpv_ipc_socket(target_socket_path) + local match = get_mpv_ipc_socket_match(target_socket_path) + return match.matching + end + + local function describe_mpv_ipc_socket_match(target_socket_path) + local match = get_mpv_ipc_socket_match(target_socket_path) + local expected_socket = match.expected_socket or "" + local active_socket = match.active_socket or "" if expected_socket == nil or active_socket == nil then - return false + return "expected=" .. expected_socket .. "; active=" .. active_socket .. "; matching=no" end - return expected_socket == active_socket + return "expected=" .. expected_socket .. "; active=" .. active_socket .. "; matching=" .. (match.matching and "yes" or "no") end local function resolve_backend(override_backend) @@ -822,6 +838,7 @@ function M.create(ctx) return { build_command_args = build_command_args, + describe_mpv_ipc_socket_match = describe_mpv_ipc_socket_match, has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket, run_control_command_async = run_control_command_async, record_visible_overlay_visibility = record_visible_overlay_visibility, diff --git a/plugin/subminer/session_bindings.lua b/plugin/subminer/session_bindings.lua index 242899f6..39fead3b 100644 --- a/plugin/subminer/session_bindings.lua +++ b/plugin/subminer/session_bindings.lua @@ -182,7 +182,35 @@ function M.create(ctx) return bindings end - local function build_cli_args(action_id, payload) + local function normalize_cli_args(cli_args) + if type(cli_args) ~= "table" then + return nil + end + + local normalized = {} + for _, arg in ipairs(cli_args) do + if type(arg) ~= "string" and type(arg) ~= "number" then + return nil + end + local value = tostring(arg) + if value == "" then + return nil + end + normalized[#normalized + 1] = value + end + + if #normalized == 0 then + return nil + end + return normalized + end + + local function build_cli_args(action_id, payload, artifact_cli_args) + local cli_args = normalize_cli_args(artifact_cli_args) + if cli_args then + return cli_args + end + if action_id == "toggleVisibleOverlay" then return { "--toggle-visible-overlay" } elseif action_id == "toggleStatsOverlay" then @@ -223,8 +251,8 @@ function M.create(ctx) return { "--open-youtube-picker" } elseif action_id == "openSessionHelp" then return { "--open-session-help" } - elseif action_id == "openCharacterDictionary" then - return { "--open-character-dictionary" } + elseif action_id == "openCharacterDictionaryManager" then + return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' } elseif action_id == "openControllerSelect" then return { "--open-controller-select" } elseif action_id == "openControllerDebug" then @@ -251,13 +279,13 @@ function M.create(ctx) return nil end - local function invoke_cli_action(action_id, payload) + local function invoke_cli_action(action_id, payload, artifact_cli_args) if not process.check_binary_available() then show_osd("Error: binary not found") return end - local cli_args = build_cli_args(action_id, payload) + local cli_args = build_cli_args(action_id, payload, artifact_cli_args) if not cli_args then subminer_log("warn", "session-bindings", "No CLI mapping for action: " .. tostring(action_id)) return @@ -312,7 +340,7 @@ function M.create(ctx) return end - invoke_cli_action(binding.actionId, binding.payload) + invoke_cli_action(binding.actionId, binding.payload, binding.cliArgs) end local function load_artifact() diff --git a/scripts/test-plugin-session-bindings.lua b/scripts/test-plugin-session-bindings.lua index c48ea689..7d31f79c 100644 --- a/scripts/test-plugin-session-bindings.lua +++ b/scripts/test-plugin-session-bindings.lua @@ -239,11 +239,21 @@ local ctx = { }, { key = { - code = "KeyA", - modifiers = { "alt", "meta" }, + code = "KeyD", + modifiers = { "ctrl" }, }, actionType = "session-action", - actionId = "openCharacterDictionary", + actionId = "openCharacterDictionaryManager", + cliArgs = { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' }, + }, + { + key = { + code = "F12", + modifiers = { "ctrl", "alt" }, + }, + actionType = "session-action", + actionId = "openFuturePanel", + cliArgs = { "--session-action", '{"actionId":"openFuturePanel"}' }, }, }, }, nil @@ -357,15 +367,40 @@ local play_next_call = recorded.async_calls[#recorded.async_calls] assert_true(play_next_call ~= nil, "play-next binding should invoke CLI action") assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag") -local character_dictionary = find_binding("Alt+Meta+a") -assert_true(character_dictionary ~= nil, "character dictionary binding should be registered") - -character_dictionary.fn() -local character_dictionary_call = recorded.async_calls[#recorded.async_calls] -assert_true(character_dictionary_call ~= nil, "character dictionary binding should invoke CLI action") +local character_dictionary_manager = find_binding("Ctrl+d") assert_true( - character_dictionary_call[2] == "--open-character-dictionary", - "character dictionary binding should pass CLI flag" + character_dictionary_manager ~= nil, + "character dictionary manager binding should be registered" +) + +character_dictionary_manager.fn() +local character_dictionary_manager_call = recorded.async_calls[#recorded.async_calls] +assert_true( + character_dictionary_manager_call ~= nil, + "character dictionary manager binding should invoke CLI action" +) +assert_true( + character_dictionary_manager_call[2] == "--session-action", + "character dictionary manager binding should use generic session action CLI flag" +) +assert_true( + character_dictionary_manager_call[3] == '{"actionId":"openCharacterDictionaryManager"}', + "character dictionary manager binding should pass generated session action payload" +) + +local future_panel = find_binding("Ctrl+Alt+F12") +assert_true(future_panel ~= nil, "artifact CLI binding should be registered without plugin mapping") + +future_panel.fn() +local future_panel_call = recorded.async_calls[#recorded.async_calls] +assert_true(future_panel_call ~= nil, "artifact CLI binding should invoke CLI action") +assert_true( + future_panel_call[2] == "--session-action", + "artifact CLI binding should pass generic session action CLI flag" +) +assert_true( + future_panel_call[3] == '{"actionId":"openFuturePanel"}', + "artifact CLI binding should pass generated session action payload" ) starter.fn() diff --git a/scripts/test-plugin-start-gate.lua b/scripts/test-plugin-start-gate.lua index 81043e2c..85f6ad5b 100644 --- a/scripts/test-plugin-start-gate.lua +++ b/scripts/test-plugin-start-gate.lua @@ -547,6 +547,15 @@ local function has_osd_message(messages, target) return false end +local function has_log_containing(logs, target) + for _, message in ipairs(logs) do + if type(message) == "string" and message:find(target, 1, true) then + return true + end + end + return false +end + local function count_osd_message(messages, target) local count = 0 for _, message in ipairs(messages) do @@ -2169,6 +2178,13 @@ do not has_property_set(recorded.property_sets, "pause", true), "pause-until-ready gate should not arm when socket_path does not match" ) + assert_true( + has_log_containing( + recorded.logs, + "Skipping auto-start: input-ipc-server does not match configured socket_path (expected=/tmp/subminer-socket; active=/tmp/other.sock; matching=no)" + ), + "socket mismatch log should include expected and active ipc sockets" + ) end do diff --git a/src/anki-connect.test.ts b/src/anki-connect.test.ts index c3988746..af997abf 100644 --- a/src/anki-connect.test.ts +++ b/src/anki-connect.test.ts @@ -1,6 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { AnkiConnectClient } from './anki-connect'; +import { setLogLevel } from './logger'; test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => { const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as { @@ -36,6 +37,7 @@ test('AnkiConnectClient includes action name in retry logs', async () => { const originalInfo = console.info; const messages: string[] = []; + setLogLevel('info'); try { console.info = (...args: unknown[]) => { messages.push(args.map((value) => String(value)).join(' ')); @@ -46,6 +48,7 @@ test('AnkiConnectClient includes action name in retry logs', async () => { assert.match(messages.join('\n'), /AnkiConnect notesInfo retry 1\/3 after 200ms delay/); } finally { console.info = originalInfo; + setLogLevel(undefined); } }); diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 243efa99..26535dbc 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -105,6 +105,8 @@ test('parseArgs captures session action forwarding flags', () => { '--shift-sub-delay-next-line', '--cycle-runtime-option', 'anki.autoUpdateNewCards:prev', + '--session-action', + '{"actionId":"openCharacterDictionaryManager"}', '--copy-subtitle-count', '3', '--mine-sentence-count=2', @@ -122,6 +124,7 @@ test('parseArgs captures session action forwarding flags', () => { assert.equal(args.shiftSubDelayNextLine, true); assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards'); assert.equal(args.cycleRuntimeOptionDirection, -1); + assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' }); assert.equal(args.copySubtitleCount, 3); assert.equal(args.mineSentenceCount, 2); assert.equal(hasExplicitCommand(args), true); @@ -282,6 +285,18 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => { assert.equal(shouldStartApp(cycleRuntimeOption), true); assert.equal(commandNeedsOverlayRuntime(cycleRuntimeOption), true); + const sessionAction = parseArgs([ + '--session-action', + '{"actionId":"cycleRuntimeOption","payload":{"runtimeOptionId":"anki.autoUpdateNewCards","direction":-1}}', + ]); + assert.deepEqual(sessionAction.sessionAction, { + actionId: 'cycleRuntimeOption', + payload: { runtimeOptionId: 'anki.autoUpdateNewCards', direction: -1 }, + }); + assert.equal(hasExplicitCommand(sessionAction), true); + assert.equal(shouldStartApp(sessionAction), true); + assert.equal(commandNeedsOverlayRuntime(sessionAction), true); + const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']); assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true); diff --git a/src/cli/args.ts b/src/cli/args.ts index ec6b6a88..5855327b 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -1,3 +1,5 @@ +import type { SessionActionDispatchRequest } from '../types/runtime'; + export interface CliArgs { background: boolean; managedPlayback: boolean; @@ -32,7 +34,6 @@ export interface CliArgs { toggleSubtitleSidebar: boolean; openRuntimeOptions: boolean; openSessionHelp: boolean; - openCharacterDictionary: boolean; openControllerSelect: boolean; openControllerDebug: boolean; openJimaku: boolean; @@ -44,6 +45,7 @@ export interface CliArgs { shiftSubDelayNextLine: boolean; cycleRuntimeOptionId?: string; cycleRuntimeOptionDirection?: 1 | -1; + sessionAction?: SessionActionDispatchRequest; copySubtitleCount?: number; mineSentenceCount?: number; anilistStatus: boolean; @@ -139,7 +141,6 @@ export function parseArgs(argv: string[]): CliArgs { toggleSubtitleSidebar: false, openRuntimeOptions: false, openSessionHelp: false, - openCharacterDictionary: false, openControllerSelect: false, openControllerDebug: false, openJimaku: false, @@ -212,6 +213,31 @@ export function parseArgs(argv: string[]): CliArgs { return null; }; + const parseSessionAction = (value: string | undefined): SessionActionDispatchRequest | null => { + if (!value) return null; + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const actionId = (parsed as { actionId?: unknown }).actionId; + if (typeof actionId !== 'string' || actionId.length === 0) return null; + const payload = (parsed as { payload?: unknown }).payload; + if ( + payload !== undefined && + (!payload || typeof payload !== 'object' || Array.isArray(payload)) + ) { + return null; + } + return payload === undefined + ? { actionId: actionId as SessionActionDispatchRequest['actionId'] } + : { + actionId: actionId as SessionActionDispatchRequest['actionId'], + payload: payload as SessionActionDispatchRequest['payload'], + }; + } catch { + return null; + } + }; + for (let i = 0; i < argv.length; i += 1) { const arg = argv[i]; if (!arg || !arg.startsWith('--')) continue; @@ -261,7 +287,6 @@ export function parseArgs(argv: string[]): CliArgs { else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true; else if (arg === '--open-runtime-options') args.openRuntimeOptions = true; else if (arg === '--open-session-help') args.openSessionHelp = true; - else if (arg === '--open-character-dictionary') args.openCharacterDictionary = true; else if (arg === '--open-controller-select') args.openControllerSelect = true; else if (arg === '--open-controller-debug') args.openControllerDebug = true; else if (arg === '--open-jimaku') args.openJimaku = true; @@ -283,6 +308,12 @@ export function parseArgs(argv: string[]): CliArgs { args.cycleRuntimeOptionId = parsed.id; args.cycleRuntimeOptionDirection = parsed.direction; } + } else if (arg.startsWith('--session-action=')) { + const parsed = parseSessionAction(arg.slice('--session-action='.length)); + if (parsed) args.sessionAction = parsed; + } else if (arg === '--session-action') { + const parsed = parseSessionAction(readValue(argv[i + 1])); + if (parsed) args.sessionAction = parsed; } else if (arg.startsWith('--copy-subtitle-count=')) { const value = Number(arg.split('=', 2)[1]); if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value; @@ -516,7 +547,6 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.toggleSubtitleSidebar || args.openRuntimeOptions || args.openSessionHelp || - args.openCharacterDictionary || args.openControllerSelect || args.openControllerDebug || args.openJimaku || @@ -527,6 +557,7 @@ export function hasExplicitCommand(args: CliArgs): boolean { args.shiftSubDelayPrevLine || args.shiftSubDelayNextLine || args.cycleRuntimeOptionId !== undefined || + args.sessionAction !== undefined || args.copySubtitleCount !== undefined || args.mineSentenceCount !== undefined || args.anilistStatus || @@ -591,7 +622,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.toggleSubtitleSidebar && !args.openRuntimeOptions && !args.openSessionHelp && - !args.openCharacterDictionary && !args.openControllerSelect && !args.openControllerDebug && !args.openJimaku && @@ -602,6 +632,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean { !args.shiftSubDelayPrevLine && !args.shiftSubDelayNextLine && args.cycleRuntimeOptionId === undefined && + args.sessionAction === undefined && args.copySubtitleCount === undefined && args.mineSentenceCount === undefined && !args.anilistStatus && @@ -657,7 +688,6 @@ export function shouldStartApp(args: CliArgs): boolean { args.toggleSubtitleSidebar || args.openRuntimeOptions || args.openSessionHelp || - args.openCharacterDictionary || args.openControllerSelect || args.openControllerDebug || args.openJimaku || @@ -668,6 +698,7 @@ export function shouldStartApp(args: CliArgs): boolean { args.shiftSubDelayPrevLine || args.shiftSubDelayNextLine || args.cycleRuntimeOptionId !== undefined || + args.sessionAction !== undefined || args.copySubtitleCount !== undefined || args.mineSentenceCount !== undefined || args.dictionary || @@ -717,7 +748,6 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean { !args.toggleSubtitleSidebar && !args.openRuntimeOptions && !args.openSessionHelp && - !args.openCharacterDictionary && !args.openControllerSelect && !args.openControllerDebug && !args.openJimaku && @@ -728,6 +758,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean { !args.shiftSubDelayPrevLine && !args.shiftSubDelayNextLine && args.cycleRuntimeOptionId === undefined && + args.sessionAction === undefined && args.copySubtitleCount === undefined && args.mineSentenceCount === undefined && !args.anilistStatus && @@ -782,7 +813,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.markAudioCard || args.openRuntimeOptions || args.openSessionHelp || - args.openCharacterDictionary || args.openControllerSelect || args.openControllerDebug || args.openJimaku || @@ -793,6 +823,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean { args.shiftSubDelayPrevLine || args.shiftSubDelayNextLine || args.cycleRuntimeOptionId !== undefined || + args.sessionAction !== undefined || args.copySubtitleCount !== undefined || args.mineSentenceCount !== undefined ); diff --git a/src/cli/help.ts b/src/cli/help.ts index 13d6cfc3..46d8e69c 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -43,7 +43,6 @@ ${B}Mining${R} --toggle-subtitle-sidebar Toggle subtitle sidebar panel --open-runtime-options Open runtime options palette --open-session-help Open session help modal - --open-character-dictionary Open character dictionary management modal --open-controller-select Open controller select modal --open-controller-debug Open controller debug modal diff --git a/src/config/config.test.ts b/src/config/config.test.ts index fcade58e..96e9483e 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -63,7 +63,6 @@ test('loads defaults when config is missing', () => { assert.deepEqual(config.ankiConnect.tags, ['SubMiner']); assert.equal(config.ankiConnect.media.audioPadding, 0); assert.equal(config.anilist.enabled, false); - assert.equal(config.anilist.characterDictionary.enabled, false); assert.equal(config.subtitleStyle.nameMatchImagesEnabled, false); assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168); assert.equal(config.anilist.characterDictionary.maxLoaded, 3); @@ -96,7 +95,6 @@ test('loads defaults when config is missing', () => { assert.equal(config.startupWarmups.subtitleDictionaries, true); assert.equal(config.startupWarmups.jellyfinRemoteSession, false); assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A'); - assert.equal('openCharacterDictionary' in config.shortcuts, false); assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D'); assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash'); assert.equal(config.discordPresence.enabled, true); @@ -825,7 +823,6 @@ test('parses anilist.characterDictionary config with clamping and enum validatio const config = service.getConfig(); const warnings = service.getWarnings(); - assert.equal(config.anilist.characterDictionary.enabled, true); assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1); assert.equal(config.anilist.characterDictionary.maxLoaded, 20); assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete'); @@ -1462,6 +1459,50 @@ test('accepts valid logging.level', () => { assert.equal(config.logging.level, 'warn'); }); +test('accepts valid logging.rotation', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "logging": { + "rotation": 14 + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.equal(config.logging.rotation, 14); +}); + +test('accepts valid logging file toggles', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "logging": { + "files": { + "app": false, + "launcher": true, + "mpv": true + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + + assert.deepEqual(config.logging.files, { + app: false, + launcher: true, + mpv: true, + }); +}); + test('falls back for invalid logging.level and reports warning', () => { const dir = makeTempDir(); fs.writeFileSync( @@ -1482,6 +1523,68 @@ test('falls back for invalid logging.level and reports warning', () => { assert.ok(warnings.some((warning) => warning.path === 'logging.level')); }); +test('falls back for invalid logging.rotation and reports warning', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "logging": { + "rotation": 0 + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.logging.rotation, DEFAULT_CONFIG.logging.rotation); + assert.ok(warnings.some((warning) => warning.path === 'logging.rotation')); +}); + +test('falls back for invalid logging file toggles and reports warning', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "logging": { + "files": { + "mpv": "yes" + } + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.equal(config.logging.files.mpv, DEFAULT_CONFIG.logging.files.mpv); + assert.ok(warnings.some((warning) => warning.path === 'logging.files.mpv')); +}); + +test('falls back for invalid logging files object and reports warning', () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, 'config.jsonc'), + `{ + "logging": { + "files": false + } + }`, + 'utf-8', + ); + + const service = new ConfigService(dir); + const config = service.getConfig(); + const warnings = service.getWarnings(); + + assert.deepEqual(config.logging.files, DEFAULT_CONFIG.logging.files); + assert.ok(warnings.some((warning) => warning.path === 'logging.files')); +}); + test('warns and ignores unknown top-level config keys', () => { const dir = makeTempDir(); fs.writeFileSync( @@ -2518,6 +2621,7 @@ test('template generator includes known keys', () => { assert.doesNotMatch(output, /"clientVersion":/); assert.doesNotMatch(output, /"youtubeSubgen":/); assert.match(output, /"characterDictionary":\s*\{/); + assert.doesNotMatch(output, /"characterDictionary":\s*\{\s*"enabled":/); assert.match(output, /"preserveLineBreaks": false/); assert.match(output, /"knownWords"\s*:\s*\{/); assert.match(output, /"knownWordColor": "#a6da95"/); @@ -2527,7 +2631,7 @@ test('template generator includes known keys', () => { assert.match(output, /auto-generated from src\/config\/definitions.ts/); assert.match( output, - /"level": "info",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/, + /"level": "warn",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/, ); assert.match( output, diff --git a/src/config/definitions/defaults-core.ts b/src/config/definitions/defaults-core.ts index 4a5e596a..dcc0c730 100644 --- a/src/config/definitions/defaults-core.ts +++ b/src/config/definitions/defaults-core.ts @@ -28,7 +28,13 @@ export const CORE_DEFAULT_CONFIG: Pick< port: 6678, }, logging: { - level: 'info', + level: 'warn', + rotation: 7, + files: { + app: true, + launcher: true, + mpv: false, + }, }, texthooker: { launchAtStartup: false, diff --git a/src/config/definitions/defaults-integrations.ts b/src/config/definitions/defaults-integrations.ts index 3a4cc8f0..4e46a255 100644 --- a/src/config/definitions/defaults-integrations.ts +++ b/src/config/definitions/defaults-integrations.ts @@ -110,7 +110,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick< enabled: false, accessToken: '', characterDictionary: { - enabled: false, refreshTtlHours: 168, maxLoaded: 3, evictionPolicy: 'delete', diff --git a/src/config/definitions/domain-registry.test.ts b/src/config/definitions/domain-registry.test.ts index 792cac8e..c381d7eb 100644 --- a/src/config/definitions/domain-registry.test.ts +++ b/src/config/definitions/domain-registry.test.ts @@ -92,6 +92,7 @@ test('config option registry includes critical paths and has unique entries', () for (const requiredPath of [ 'logging.level', + 'logging.files.mpv', 'annotationWebsocket.enabled', 'controller.enabled', 'controller.scrollPixelsPerSecond', @@ -101,7 +102,7 @@ test('config option registry includes critical paths and has unique entries', () 'subtitleStyle.enableJlpt', 'subtitleStyle.autoPauseVideoOnYomitanPopup', 'ankiConnect.enabled', - 'anilist.characterDictionary.enabled', + 'subtitleStyle.nameMatchEnabled', 'anilist.characterDictionary.collapsibleSections.description', 'mpv.executablePath', 'mpv.launchMode', diff --git a/src/config/definitions/options-core.ts b/src/config/definitions/options-core.ts index 636bbcb2..a85d60c0 100644 --- a/src/config/definitions/options-core.ts +++ b/src/config/definitions/options-core.ts @@ -83,6 +83,30 @@ export function buildCoreConfigOptionRegistry( defaultValue: defaultConfig.logging.level, description: 'Minimum log level for runtime logging.', }, + { + path: 'logging.rotation', + kind: 'number', + defaultValue: defaultConfig.logging.rotation, + description: 'Number of days of app, launcher, and mpv logs to retain.', + }, + { + path: 'logging.files.app', + kind: 'boolean', + defaultValue: defaultConfig.logging.files.app, + description: 'Write SubMiner app runtime logs.', + }, + { + path: 'logging.files.launcher', + kind: 'boolean', + defaultValue: defaultConfig.logging.files.launcher, + description: 'Write launcher command logs.', + }, + { + path: 'logging.files.mpv', + kind: 'boolean', + defaultValue: defaultConfig.logging.files.mpv, + description: 'Write mpv player logs. Enable temporarily when debugging mpv/plugin startup.', + }, { path: 'youtube.primarySubLanguages', kind: 'string', diff --git a/src/config/definitions/options-integrations.ts b/src/config/definitions/options-integrations.ts index 1d68a5cb..080b7b89 100644 --- a/src/config/definitions/options-integrations.ts +++ b/src/config/definitions/options-integrations.ts @@ -392,13 +392,6 @@ export function buildIntegrationConfigOptionRegistry( description: 'Optional explicit AniList access token override; leave empty to use locally stored token from setup.', }, - { - path: 'anilist.characterDictionary.enabled', - kind: 'boolean', - defaultValue: defaultConfig.anilist.characterDictionary.enabled, - description: - 'Enable automatic Yomitan character dictionary sync for currently watched AniList media.', - }, { path: 'anilist.characterDictionary.refreshTtlHours', kind: 'number', @@ -426,7 +419,7 @@ export function buildIntegrationConfigOptionRegistry( kind: 'enum', enumValues: ['all', 'active'], defaultValue: defaultConfig.anilist.characterDictionary.profileScope, - description: 'Yomitan profile scope for dictionary enable/disable updates.', + description: 'Yomitan profile scope for character dictionary settings updates.', }, { path: 'anilist.characterDictionary.collapsibleSections.description', diff --git a/src/config/definitions/options-subtitle.ts b/src/config/definitions/options-subtitle.ts index 7ad787d2..ff81a41a 100644 --- a/src/config/definitions/options-subtitle.ts +++ b/src/config/definitions/options-subtitle.ts @@ -74,7 +74,7 @@ export function buildSubtitleConfigOptionRegistry( kind: 'boolean', defaultValue: defaultConfig.subtitleStyle.nameMatchEnabled, description: - 'Enable subtitle token coloring for matches from the SubMiner character dictionary.', + 'Enable character dictionary sync and subtitle token coloring for character-name matches.', }, { path: 'subtitleStyle.nameMatchImagesEnabled', diff --git a/src/config/definitions/template-sections.ts b/src/config/definitions/template-sections.ts index 32fad4a9..3c312aff 100644 --- a/src/config/definitions/template-sections.ts +++ b/src/config/definitions/template-sections.ts @@ -33,7 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [ { title: 'Logging', description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'], - notes: ['Hot-reload: logging.level applies live while SubMiner is running.'], + notes: ['Hot-reload: logging.level and logging.files apply live while SubMiner is running.'], key: 'logging', }, { diff --git a/src/config/resolve/core-domains.ts b/src/config/resolve/core-domains.ts index 2b259e12..5231583b 100644 --- a/src/config/resolve/core-domains.ts +++ b/src/config/resolve/core-domains.ts @@ -100,6 +100,36 @@ export function applyCoreDomainConfig(context: ResolveContext): void { 'Expected debug, info, warn, or error.', ); } + + const logRotation = src.logging.rotation; + if (typeof logRotation === 'number' && Number.isInteger(logRotation) && logRotation > 0) { + resolved.logging.rotation = logRotation; + } else if (src.logging.rotation !== undefined) { + warn( + 'logging.rotation', + src.logging.rotation, + resolved.logging.rotation, + 'Expected a positive whole number of days.', + ); + } + + if (isObject(src.logging.files)) { + for (const key of ['app', 'launcher', 'mpv'] as const) { + const enabled = asBoolean(src.logging.files[key]); + if (enabled !== undefined) { + resolved.logging.files[key] = enabled; + } else if (src.logging.files[key] !== undefined) { + warn( + `logging.files.${key}`, + src.logging.files[key], + resolved.logging.files[key], + 'Expected boolean.', + ); + } + } + } else if (src.logging.files !== undefined) { + warn('logging.files', src.logging.files, resolved.logging.files, 'Expected object.'); + } } applyControllerConfig(context); diff --git a/src/config/resolve/integrations.ts b/src/config/resolve/integrations.ts index b4b03751..6324eaee 100644 --- a/src/config/resolve/integrations.ts +++ b/src/config/resolve/integrations.ts @@ -81,18 +81,6 @@ export function applyIntegrationConfig(context: ResolveContext): void { if (isObject(src.anilist.characterDictionary)) { const characterDictionary = src.anilist.characterDictionary; - const dictionaryEnabled = asBoolean(characterDictionary.enabled); - if (dictionaryEnabled !== undefined) { - resolved.anilist.characterDictionary.enabled = dictionaryEnabled; - } else if (characterDictionary.enabled !== undefined) { - warn( - 'anilist.characterDictionary.enabled', - characterDictionary.enabled, - resolved.anilist.characterDictionary.enabled, - 'Expected boolean.', - ); - } - const refreshTtlHours = asNumber(characterDictionary.refreshTtlHours); if (refreshTtlHours !== undefined) { const normalized = Math.min(24 * 365, Math.max(1, Math.floor(refreshTtlHours))); diff --git a/src/config/resolve/jellyfin.test.ts b/src/config/resolve/jellyfin.test.ts index 13f34d52..d8949be8 100644 --- a/src/config/resolve/jellyfin.test.ts +++ b/src/config/resolve/jellyfin.test.ts @@ -97,7 +97,6 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate const { context, warnings } = createResolveContext({ anilist: { characterDictionary: { - enabled: true, refreshTtlHours: 0, maxLoaded: 99, evictionPolicy: 'purge' as never, @@ -113,7 +112,6 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate applyIntegrationConfig(context); - assert.equal(context.resolved.anilist.characterDictionary.enabled, true); assert.equal(context.resolved.anilist.characterDictionary.refreshTtlHours, 1); assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20); assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete'); diff --git a/src/config/settings/registry.test.ts b/src/config/settings/registry.test.ts index 09e66122..720eb19b 100644 --- a/src/config/settings/registry.test.ts +++ b/src/config/settings/registry.test.ts @@ -55,10 +55,6 @@ test('settings registry moves AniSkip button key into input shortcuts and hot re }); test('settings registry exposes character dictionary panel shortcuts dynamically', () => { - assert.equal( - fields.some((candidate) => candidate.configPath === 'shortcuts.openCharacterDictionary'), - false, - ); assert.equal( field('shortcuts.openCharacterDictionaryManager').label, 'Open Character Dictionary Manager', @@ -69,7 +65,6 @@ test('settings registry exposes character dictionary panel shortcuts dynamically test('settings registry hides removed modal-only fields', () => { for (const path of [ 'shortcuts.multiCopyTimeoutMs', - 'shortcuts.openCharacterDictionary', 'anilist.characterDictionary.profileScope', 'jellyfin.directPlayContainers', ]) { @@ -265,7 +260,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => { ]) { assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`); } - assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary'); + assert.equal(paths.has('anilist.characterDictionary.enabled'), false); }); test('settings registry marks safe live config paths as hot-reloadable', () => { @@ -274,6 +269,10 @@ test('settings registry marks safe live config paths as hot-reloadable', () => { 'stats.toggleKey', 'stats.markWatchedKey', 'logging.level', + 'logging.rotation', + 'logging.files.app', + 'logging.files.launcher', + 'logging.files.mpv', 'youtube.primarySubLanguages', 'jimaku.apiBaseUrl', 'jimaku.languagePreference', diff --git a/src/config/settings/registry.ts b/src/config/settings/registry.ts index 65fb12df..a819bb4c 100644 --- a/src/config/settings/registry.ts +++ b/src/config/settings/registry.ts @@ -63,6 +63,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [ 'controller.preferredGamepadLabel', 'controller.profiles', 'youtubeSubgen.primarySubLanguages', + 'anilist.characterDictionary.enabled', 'anilist.characterDictionary.refreshTtlHours', 'anilist.characterDictionary.evictionPolicy', 'anilist.characterDictionary.profileScope', @@ -184,6 +185,11 @@ const PATH_ORDER = new Map( 'mpv.launchMode', 'mpv.executablePath', 'mpv.aniskipButtonKey', + 'logging.level', + 'logging.rotation', + 'logging.files.app', + 'logging.files.launcher', + 'logging.files.mpv', ].map((path, index) => [path, index]), ); @@ -667,6 +673,8 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior { path === 'stats.toggleKey' || path === 'stats.markWatchedKey' || path === 'logging.level' || + path === 'logging.rotation' || + pathStartsWith(path, 'logging.files') || path === 'youtube.primarySubLanguages' || pathStartsWith(path, 'jimaku') || pathStartsWith(path, 'subsync') diff --git a/src/core/services/app-lifecycle.test.ts b/src/core/services/app-lifecycle.test.ts index 8d6df14c..a833baf5 100644 --- a/src/core/services/app-lifecycle.test.ts +++ b/src/core/services/app-lifecycle.test.ts @@ -41,7 +41,6 @@ function makeArgs(overrides: Partial = {}): CliArgs { openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, - openCharacterDictionary: false, replayCurrentSubtitle: false, playNextSubtitle: false, shiftSubDelayPrevLine: false, diff --git a/src/core/services/cli-command.test.ts b/src/core/services/cli-command.test.ts index 7951e41d..f1d8e316 100644 --- a/src/core/services/cli-command.test.ts +++ b/src/core/services/cli-command.test.ts @@ -41,7 +41,6 @@ function makeArgs(overrides: Partial = {}): CliArgs { refreshKnownWords: false, openRuntimeOptions: false, openSessionHelp: false, - openCharacterDictionary: false, openControllerSelect: false, openControllerDebug: false, openJimaku: false, @@ -785,6 +784,30 @@ test('handleCliCommand dispatches cycle-runtime-option session action', async () }); }); +test('handleCliCommand dispatches generic session action payloads', async () => { + let request: unknown = null; + const { deps } = createDeps({ + dispatchSessionAction: async (nextRequest) => { + request = nextRequest; + }, + }); + + handleCliCommand( + makeArgs({ + sessionAction: { + actionId: 'openCharacterDictionaryManager', + }, + }), + 'initial', + deps, + ); + await new Promise((resolve) => setImmediate(resolve)); + + assert.deepEqual(request, { + actionId: 'openCharacterDictionaryManager', + }); +}); + test('handleCliCommand dispatches mark-watched session action', async () => { let request: unknown = null; const { deps } = createDeps({ @@ -801,22 +824,6 @@ test('handleCliCommand dispatches mark-watched session action', async () => { }); }); -test('handleCliCommand opens character dictionary manager from CLI flag', async () => { - let request: unknown = null; - const { deps } = createDeps({ - dispatchSessionAction: async (nextRequest) => { - request = nextRequest; - }, - }); - - handleCliCommand(makeArgs({ openCharacterDictionary: true }), 'initial', deps); - await new Promise((resolve) => setImmediate(resolve)); - - assert.deepEqual(request, { - actionId: 'openCharacterDictionaryManager', - }); -}); - test('handleCliCommand logs AniList status details', () => { const { deps, calls } = createDeps(); handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps); diff --git a/src/core/services/cli-command.ts b/src/core/services/cli-command.ts index a24449cc..3dad5ec7 100644 --- a/src/core/services/cli-command.ts +++ b/src/core/services/cli-command.ts @@ -384,7 +384,13 @@ export function handleCliCommand( deps.log(`Starting MPV IPC connection on socket: ${socketPath}`); } - if (args.toggle || args.toggleVisibleOverlay) { + if (args.sessionAction) { + dispatchCliSessionAction( + args.sessionAction, + `sessionAction:${args.sessionAction.actionId}`, + 'Session action failed', + ); + } else if (args.toggle || args.toggleVisibleOverlay) { deps.toggleVisibleOverlay(); } else if (args.togglePrimarySubtitleBar) { deps.togglePrimarySubtitleBar(); @@ -490,12 +496,6 @@ export function handleCliCommand( 'openSessionHelp', 'Open session help failed', ); - } else if (args.openCharacterDictionary) { - dispatchCliSessionAction( - { actionId: 'openCharacterDictionaryManager' }, - 'openCharacterDictionaryManager', - 'Open character dictionary failed', - ); } else if (args.openControllerSelect) { dispatchCliSessionAction( { actionId: 'openControllerSelect' }, diff --git a/src/core/services/config-hot-reload.test.ts b/src/core/services/config-hot-reload.test.ts index 728211fd..97da8f00 100644 --- a/src/core/services/config-hot-reload.test.ts +++ b/src/core/services/config-hot-reload.test.ts @@ -25,6 +25,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada next.stats.toggleKey = 'F8'; next.stats.markWatchedKey = 'F9'; next.logging.level = 'debug'; + next.logging.rotation = 14; + next.logging.files.mpv = true; next.youtube.primarySubLanguages = ['ja', 'en']; next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1; next.subsync.replace = !prev.subsync.replace; @@ -56,6 +58,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada 'mpv.aniskipButtonKey', 'stats.markWatchedKey', 'logging.level', + 'logging.rotation', + 'logging.files', 'youtube.primarySubLanguages', 'jimaku.maxEntryResults', 'subsync.replace', diff --git a/src/core/services/config-hot-reload.ts b/src/core/services/config-hot-reload.ts index 46ae6357..5c05d869 100644 --- a/src/core/services/config-hot-reload.ts +++ b/src/core/services/config-hot-reload.ts @@ -61,6 +61,8 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [ 'stats.toggleKey', 'stats.markWatchedKey', 'logging.level', + 'logging.rotation', + 'logging.files', 'youtube.primarySubLanguages', 'jimaku', 'subsync', diff --git a/src/core/services/mpv.test.ts b/src/core/services/mpv.test.ts index eb0acce6..b665725e 100644 --- a/src/core/services/mpv.test.ts +++ b/src/core/services/mpv.test.ts @@ -23,6 +23,37 @@ function makeDeps(overrides: Partial = {}): MpvIpcClie }; } +function captureWarnLogs(run: () => void): string[] { + const originalWarn = console.warn; + const originalLogLevel = process.env.SUBMINER_LOG_LEVEL; + const originalAppLog = process.env.SUBMINER_APP_LOG; + const messages: string[] = []; + + console.warn = (...args: unknown[]) => { + messages.push(args.map(String).join(' ')); + }; + process.env.SUBMINER_LOG_LEVEL = 'warn'; + process.env.SUBMINER_APP_LOG = process.platform === 'win32' ? 'NUL' : '/dev/null'; + + try { + run(); + } finally { + console.warn = originalWarn; + if (originalLogLevel === undefined) { + delete process.env.SUBMINER_LOG_LEVEL; + } else { + process.env.SUBMINER_LOG_LEVEL = originalLogLevel; + } + if (originalAppLog === undefined) { + delete process.env.SUBMINER_APP_LOG; + } else { + process.env.SUBMINER_APP_LOG = originalAppLog; + } + } + + return messages; +} + function invokeHandleMessage(client: MpvIpcClient, msg: unknown): Promise { return (client as unknown as { handleMessage: (msg: unknown) => Promise }).handleMessage( msg, @@ -401,6 +432,51 @@ test('MpvIpcClient onClose requests app quit for managed playback', () => { assert.equal(quitRequests, 1); }); +test('MpvIpcClient only warns once for repeated post-disconnect socket failures', () => { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + (client as any).send = () => true; + (client as any).scheduleReconnect = () => {}; + + const callbacks = (client as any).transport.callbacks; + callbacks.onConnect(); + + const messages = captureWarnLogs(() => { + callbacks.onClose(); + for (let index = 0; index < 3; index += 1) { + const error = Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), { + code: 'ENOENT', + }); + callbacks.onError(error); + callbacks.onClose(); + } + }); + + assert.equal(messages.filter((message) => message.includes('MPV IPC socket closed')).length, 1); + assert.equal(messages.filter((message) => message.includes('MPV IPC socket error')).length, 1); +}); + +test('MpvIpcClient warns again after MPV reconnects and disconnects later', () => { + const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); + (client as any).send = () => true; + (client as any).scheduleReconnect = () => {}; + + const callbacks = (client as any).transport.callbacks; + callbacks.onConnect(); + + const messages = captureWarnLogs(() => { + callbacks.onClose(); + callbacks.onError(Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), { code: 'ENOENT' })); + callbacks.onClose(); + callbacks.onConnect(); + callbacks.onClose(); + callbacks.onError(Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), { code: 'ENOENT' })); + callbacks.onClose(); + }); + + assert.equal(messages.filter((message) => message.includes('MPV IPC socket closed')).length, 2); + assert.equal(messages.filter((message) => message.includes('MPV IPC socket error')).length, 2); +}); + test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => { const commands: unknown[] = []; const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps()); diff --git a/src/core/services/mpv.ts b/src/core/services/mpv.ts index eb08d16b..35ac1a0e 100644 --- a/src/core/services/mpv.ts +++ b/src/core/services/mpv.ts @@ -136,6 +136,7 @@ type MpvIpcClientEventName = keyof MpvIpcClientEventMap; export class MpvIpcClient implements MpvClient { private deps: MpvIpcClientProtocolDeps; private transport: MpvSocketTransport; + private socketPath: string; public socket: ReturnType = null; private eventBus = new EventEmitter(); private buffer = ''; @@ -144,6 +145,7 @@ export class MpvIpcClient implements MpvClient { private reconnectAttempt = 0; private firstConnection = true; private hasConnectedOnce = false; + private socketErrorWarnedForDisconnect = false; public currentVideoPath = ''; public currentMediaTitle: string | null = null; public currentTimePos = 0; @@ -180,23 +182,30 @@ export class MpvIpcClient implements MpvClient { constructor(socketPath: string, deps: MpvIpcClientDeps) { this.deps = deps; + this.socketPath = socketPath; this.transport = new MpvSocketTransport({ socketPath, onConnect: () => { - logger.debug('Connected to MPV socket'); this.connected = true; this.connecting = false; this.socket = this.transport.getSocket(); this.reconnectAttempt = 0; this.hasConnectedOnce = true; + this.socketErrorWarnedForDisconnect = false; + const resolvedConfig = this.deps.getResolvedConfig(); + logger.info('MPV IPC socket connected', { + socketPath: this.socketPath, + autoStartOverlay: this.deps.autoStartOverlay, + configAutoStartOverlay: resolvedConfig.auto_start_overlay === true, + }); this.setSecondarySubVisibility(false); subscribeToMpvProperties(this.send.bind(this)); requestMpvInitialState(this.send.bind(this)); this.emit('connection-change', { connected: true }); const shouldAutoStart = - this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true; + this.deps.autoStartOverlay || resolvedConfig.auto_start_overlay === true; if (this.firstConnection && shouldAutoStart) { logger.debug('Auto-starting overlay, hiding mpv subtitles'); setTimeout(() => { @@ -211,18 +220,30 @@ export class MpvIpcClient implements MpvClient { this.processBuffer(); }, onError: (err: Error) => { - logger.debug('MPV socket error:', err.message); + this.logSocketError(err); this.failPendingRequests(); }, onClose: () => { - logger.debug('MPV socket closed'); + const wasConnected = this.connected; + const shouldQuitOnMpvShutdown = this.deps.shouldQuitOnMpvShutdown?.() === true; + if (wasConnected) { + logger.warn('MPV IPC socket closed', { + socketPath: this.socketPath, + shouldQuitOnMpvShutdown, + }); + } else { + logger.debug('MPV IPC socket closed before first connection', { + socketPath: this.socketPath, + reconnectAttempt: this.reconnectAttempt, + }); + } this.connected = false; this.connecting = false; this.socket = null; this.playbackPaused = null; this.emit('connection-change', { connected: false }); this.failPendingRequests(); - if (this.deps.shouldQuitOnMpvShutdown?.() === true) { + if (shouldQuitOnMpvShutdown) { this.deps.requestAppQuit?.(); return; } @@ -261,6 +282,13 @@ export class MpvIpcClient implements MpvClient { } setSocketPath(socketPath: string): void { + if (socketPath !== this.socketPath) { + logger.info('MPV IPC socket path updated', { + previousSocketPath: this.socketPath, + socketPath, + }); + } + this.socketPath = socketPath; this.transport.setSocketPath(socketPath); } @@ -299,7 +327,9 @@ export class MpvIpcClient implements MpvClient { getReconnectTimer: () => this.deps.getReconnectTimer(), setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer), onReconnectAttempt: (attempt, delay) => { - logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`); + logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`, { + socketPath: this.socketPath, + }); }, connect: () => { this.connect(); @@ -307,6 +337,39 @@ export class MpvIpcClient implements MpvClient { }); } + private shouldLogPreConnectionFailure(): boolean { + const nextAttempt = this.reconnectAttempt + 1; + return nextAttempt <= 3 || nextAttempt % 10 === 0; + } + + private logSocketError(err: Error): void { + const errorWithCode = err as Error & { code?: unknown }; + const details = { + socketPath: this.socketPath, + reconnectAttempt: this.reconnectAttempt, + hasConnectedOnce: this.hasConnectedOnce, + message: err.message, + code: typeof errorWithCode.code === 'string' ? errorWithCode.code : undefined, + }; + + if (!this.hasConnectedOnce) { + if (this.shouldLogPreConnectionFailure()) { + logger.warn('MPV IPC socket error', details); + return; + } + logger.debug('MPV IPC socket error', details); + return; + } + + if (!this.socketErrorWarnedForDisconnect) { + this.socketErrorWarnedForDisconnect = true; + logger.warn('MPV IPC socket error', details); + return; + } + + logger.debug('MPV IPC socket error', details); + } + private processBuffer(): void { const parsed = splitMpvMessagesFromBuffer( this.buffer, diff --git a/src/core/services/overlay-shortcut-handler.test.ts b/src/core/services/overlay-shortcut-handler.test.ts index e7756315..1766a516 100644 --- a/src/core/services/overlay-shortcut-handler.test.ts +++ b/src/core/services/overlay-shortcut-handler.test.ts @@ -46,9 +46,6 @@ function createDeps(overrides: Partial = {}) { openRuntimeOptions: () => { calls.push('openRuntimeOptions'); }, - openCharacterDictionary: () => { - calls.push('openCharacterDictionary'); - }, openCharacterDictionaryManager: () => { calls.push('openCharacterDictionaryManager'); }, @@ -163,7 +160,6 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions', }, { openRuntimeOptions: () => handled.push('openRuntimeOptions'), - openCharacterDictionary: () => handled.push('openCharacterDictionary'), openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openJimaku: () => handled.push('openJimaku'), markAudioCard: () => handled.push('markAudioCard'), @@ -197,7 +193,6 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re (_input, accelerator) => accelerator === 'Ctrl+M', { openRuntimeOptions: () => handled.push('openRuntimeOptions'), - openCharacterDictionary: () => handled.push('openCharacterDictionary'), openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openJimaku: () => handled.push('openJimaku'), markAudioCard: () => handled.push('markAudioCard'), @@ -218,7 +213,6 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re (_input, accelerator) => accelerator === 'Ctrl+N', { openRuntimeOptions: () => handled.push('openRuntimeOptions'), - openCharacterDictionary: () => handled.push('openCharacterDictionary'), openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'), openJimaku: () => handled.push('openJimaku'), markAudioCard: () => handled.push('markAudioCard'), @@ -256,7 +250,6 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s }, { openRuntimeOptions: () => {}, - openCharacterDictionary: () => {}, openCharacterDictionaryManager: () => {}, openJimaku: () => {}, markAudioCard: () => {}, @@ -293,7 +286,6 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut', }, { openRuntimeOptions: () => {}, - openCharacterDictionary: () => {}, openCharacterDictionaryManager: () => {}, openJimaku: () => {}, markAudioCard: () => {}, @@ -322,9 +314,6 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', () openRuntimeOptions: () => { called = true; }, - openCharacterDictionary: () => { - called = true; - }, openCharacterDictionaryManager: () => { called = true; }, @@ -410,7 +399,6 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured', mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, - openCharacterDictionary: () => {}, openCharacterDictionaryManager: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, @@ -438,7 +426,6 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, - openCharacterDictionary: () => {}, openCharacterDictionaryManager: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, diff --git a/src/core/services/overlay-shortcut-handler.ts b/src/core/services/overlay-shortcut-handler.ts index f0556ad1..4a2dc16b 100644 --- a/src/core/services/overlay-shortcut-handler.ts +++ b/src/core/services/overlay-shortcut-handler.ts @@ -6,7 +6,6 @@ const logger = createLogger('main:overlay-shortcut-handler'); export interface OverlayShortcutFallbackHandlers { openRuntimeOptions: () => void; - openCharacterDictionary: () => void; openCharacterDictionaryManager: () => void; openJimaku: () => void; markAudioCard: () => void; @@ -23,7 +22,6 @@ export interface OverlayShortcutFallbackHandlers { export interface OverlayShortcutRuntimeDeps { showMpvOsd: (text: string) => void; openRuntimeOptions: () => void; - openCharacterDictionary: () => void; openCharacterDictionaryManager: () => void; openJimaku: () => void; markAudioCard: () => Promise; @@ -99,9 +97,6 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim openRuntimeOptions: () => { deps.openRuntimeOptions(); }, - openCharacterDictionary: () => { - deps.openCharacterDictionary(); - }, openCharacterDictionaryManager: () => { deps.openCharacterDictionaryManager(); }, @@ -112,7 +107,6 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim const fallbackHandlers: OverlayShortcutFallbackHandlers = { openRuntimeOptions: overlayHandlers.openRuntimeOptions, - openCharacterDictionary: overlayHandlers.openCharacterDictionary, openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager, openJimaku: overlayHandlers.openJimaku, markAudioCard: overlayHandlers.markAudioCard, diff --git a/src/core/services/overlay-shortcut.test.ts b/src/core/services/overlay-shortcut.test.ts index 27e47b15..62770bf3 100644 --- a/src/core/services/overlay-shortcut.test.ts +++ b/src/core/services/overlay-shortcut.test.ts @@ -43,7 +43,6 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured' mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, - openCharacterDictionary: () => {}, openCharacterDictionaryManager: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, @@ -64,7 +63,6 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent' mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, - openCharacterDictionary: () => {}, openCharacterDictionaryManager: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, @@ -87,7 +85,6 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active mineSentenceMultiple: () => {}, toggleSecondarySub: () => {}, markAudioCard: () => {}, - openCharacterDictionary: () => {}, openCharacterDictionaryManager: () => {}, openRuntimeOptions: () => {}, openJimaku: () => {}, diff --git a/src/core/services/overlay-shortcut.ts b/src/core/services/overlay-shortcut.ts index be466211..05da4433 100644 --- a/src/core/services/overlay-shortcut.ts +++ b/src/core/services/overlay-shortcut.ts @@ -10,7 +10,6 @@ export interface OverlayShortcutHandlers { mineSentenceMultiple: (timeoutMs: number) => void; toggleSecondarySub: () => void; markAudioCard: () => void; - openCharacterDictionary: () => void; openCharacterDictionaryManager: () => void; openRuntimeOptions: () => void; openJimaku: () => void; diff --git a/src/core/services/session-actions.test.ts b/src/core/services/session-actions.test.ts index 6d78364b..89f93a11 100644 --- a/src/core/services/session-actions.test.ts +++ b/src/core/services/session-actions.test.ts @@ -34,7 +34,6 @@ function createDeps(overrides: Partial = {}) { }, openRuntimeOptionsPalette: () => calls.push('runtime-options'), openSessionHelp: () => calls.push('session-help'), - openCharacterDictionary: () => calls.push('character-dictionary'), openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'), openControllerSelect: () => calls.push('controller-select'), openControllerDebug: () => calls.push('controller-debug'), diff --git a/src/core/services/session-actions.ts b/src/core/services/session-actions.ts index ae901917..14552764 100644 --- a/src/core/services/session-actions.ts +++ b/src/core/services/session-actions.ts @@ -18,7 +18,6 @@ export interface SessionActionExecutorDeps { markActiveVideoWatched: () => Promise; openRuntimeOptionsPalette: () => void; openSessionHelp: () => void; - openCharacterDictionary: () => void; openCharacterDictionaryManager: () => void; openControllerSelect: () => void; openControllerDebug: () => void; @@ -97,9 +96,6 @@ export async function dispatchSessionAction( case 'openSessionHelp': deps.openSessionHelp(); return; - case 'openCharacterDictionary': - deps.openCharacterDictionaryManager(); - return; case 'openCharacterDictionaryManager': deps.openCharacterDictionaryManager(); return; diff --git a/src/core/services/session-bindings.test.ts b/src/core/services/session-bindings.test.ts index 2c4cd184..04f3f734 100644 --- a/src/core/services/session-bindings.test.ts +++ b/src/core/services/session-bindings.test.ts @@ -4,7 +4,7 @@ import type { Keybinding } from '../../types'; import type { ConfiguredShortcuts } from '../utils/shortcut-config'; import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions'; import { resolveConfiguredShortcuts } from '../utils/shortcut-config'; -import { compileSessionBindings } from './session-bindings'; +import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './session-bindings'; function createShortcuts(overrides: Partial = {}): ConfiguredShortcuts { return { @@ -220,10 +220,7 @@ test('compileSessionBindings keeps only the character dictionary manager bound b const characterDictionaryBindings = result.bindings.flatMap((binding) => { if (binding.actionType !== 'session-action') return []; - if ( - binding.actionId !== 'openCharacterDictionary' && - binding.actionId !== 'openCharacterDictionaryManager' - ) { + if (binding.actionId !== 'openCharacterDictionaryManager') { return []; } return [ @@ -471,3 +468,51 @@ test('compileSessionBindings wires every configured shortcut key into the shared shortcutKeys.map((key) => `shortcuts.${key}`).sort(), ); }); + +test('buildPluginSessionBindingsArtifact emits CLI args for plugin-bound session actions', () => { + const result = compileSessionBindings({ + shortcuts: createShortcuts({ + openCharacterDictionaryManager: 'Ctrl+D', + }), + keybindings: [ + createKeybinding('Ctrl+Alt+KeyR', [ + `${SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX}anki.autoUpdateNewCards:prev`, + ]), + ], + platform: 'linux', + }); + + const artifact = buildPluginSessionBindingsArtifact({ + bindings: result.bindings, + warnings: result.warnings, + numericSelectionTimeoutMs: 2500, + now: new Date('2026-05-26T00:00:00.000Z'), + }); + const byActionId = new Map( + artifact.bindings.flatMap((binding) => + binding.actionType === 'session-action' ? [[binding.actionId, binding]] : [], + ), + ); + const compiledManagerBinding = result.bindings.find( + (binding) => + binding.actionType === 'session-action' && + binding.actionId === 'openCharacterDictionaryManager', + ); + + assert.equal(compiledManagerBinding && 'cliArgs' in compiledManagerBinding, false); + const managerCliArgs = byActionId.get('openCharacterDictionaryManager')?.cliArgs; + const cycleCliArgs = byActionId.get('cycleRuntimeOption')?.cliArgs; + + assert.equal(managerCliArgs?.[0], '--session-action'); + assert.deepEqual(JSON.parse(managerCliArgs?.[1] ?? ''), { + actionId: 'openCharacterDictionaryManager', + }); + assert.equal(cycleCliArgs?.[0], '--session-action'); + assert.deepEqual(JSON.parse(cycleCliArgs?.[1] ?? ''), { + actionId: 'cycleRuntimeOption', + payload: { + runtimeOptionId: 'anki.autoUpdateNewCards', + direction: -1, + }, + }); +}); diff --git a/src/core/services/session-bindings.ts b/src/core/services/session-bindings.ts index ab156e99..e72524ba 100644 --- a/src/core/services/session-bindings.ts +++ b/src/core/services/session-bindings.ts @@ -4,6 +4,7 @@ import type { CompiledMpvCommandBinding, CompiledSessionActionBinding, CompiledSessionBinding, + PluginSessionBinding, PluginSessionBindingsArtifact, SessionActionId, SessionBindingWarning, @@ -344,6 +345,22 @@ function getBindingFingerprint(binding: CompiledSessionBinding): string { return `session:${binding.actionId}:${JSON.stringify(binding.payload ?? null)}`; } +function buildSessionActionCliArgs(binding: CompiledSessionActionBinding): string[] { + const request = + binding.payload === undefined + ? { actionId: binding.actionId } + : { actionId: binding.actionId, payload: binding.payload }; + return ['--session-action', JSON.stringify(request)]; +} + +function toPluginSessionBinding(binding: CompiledSessionBinding): PluginSessionBinding { + if (binding.actionType !== 'session-action') { + return binding; + } + + return { ...binding, cliArgs: buildSessionActionCliArgs(binding) }; +} + export function compileSessionBindings(input: CompileSessionBindingsInput): { bindings: CompiledSessionBinding[]; warnings: SessionBindingWarning[]; @@ -516,7 +533,7 @@ export function buildPluginSessionBindingsArtifact(input: { version: 1, generatedAt: (input.now ?? new Date()).toISOString(), numericSelectionTimeoutMs: input.numericSelectionTimeoutMs, - bindings: input.bindings, + bindings: input.bindings.map(toPluginSessionBinding), warnings: input.warnings, }; } diff --git a/src/core/services/startup-bootstrap.test.ts b/src/core/services/startup-bootstrap.test.ts index afd2599a..6de356fd 100644 --- a/src/core/services/startup-bootstrap.test.ts +++ b/src/core/services/startup-bootstrap.test.ts @@ -41,7 +41,6 @@ function makeArgs(overrides: Partial = {}): CliArgs { openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, - openCharacterDictionary: false, replayCurrentSubtitle: false, playNextSubtitle: false, shiftSubDelayPrevLine: false, diff --git a/src/core/services/startup.ts b/src/core/services/startup.ts index f4e26adc..8f8c5b95 100644 --- a/src/core/services/startup.ts +++ b/src/core/services/startup.ts @@ -29,6 +29,7 @@ export interface StartupBootstrapRuntimeDeps { argv: string[]; parseArgs: (argv: string[]) => CliArgs; setLogLevel: (level: string, source: LogLevelSource) => void; + setLogRotation?: (rotation: number) => void; forceX11Backend: (args: CliArgs) => void; enforceUnsupportedWaylandMode: (args: CliArgs) => void; getDefaultSocketPath: () => string; @@ -95,6 +96,12 @@ interface AppReadyConfigLike { }; logging?: { level?: 'debug' | 'info' | 'warn' | 'error'; + rotation?: number; + files?: { + app?: boolean; + launcher?: boolean; + mpv?: boolean; + }; }; } @@ -115,6 +122,10 @@ export interface AppReadyRuntimeDeps { getConfigWarnings: () => ConfigValidationWarning[]; logConfigWarning: (warning: ConfigValidationWarning) => void; setLogLevel: (level: string, source: LogLevelSource) => void; + setLogRotation?: (rotation: number) => void; + setLogFileToggles?: ( + files: { app?: boolean; launcher?: boolean; mpv?: boolean } | undefined, + ) => void; initRuntimeOptionsManager: () => void; setSecondarySubMode: (mode: SecondarySubMode) => void; defaultSecondarySubMode: SecondarySubMode; @@ -263,6 +274,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise = {}): TokenizerServiceDeps { @@ -1865,6 +1866,7 @@ test('tokenizeSubtitle uses Yomitan parser result when available and drops no-he test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled', async () => { const infoLogs: string[] = []; const originalInfo = console.info; + setLogLevel('info'); console.info = (...args: unknown[]) => { infoLogs.push(args.map((value) => String(value)).join(' ')); }; @@ -1912,6 +1914,7 @@ test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled ); } finally { console.info = originalInfo; + setLogLevel(undefined); } assert.ok(infoLogs.some((line) => line.includes('Selected Yomitan token groups'))); diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts index 48fc147e..688442a3 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.test.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.test.ts @@ -601,6 +601,47 @@ test('requestYomitanScanTokens prefers parseText tokenization over termsFind fra assert.ok(scripts.some((script) => script.includes('termsFind'))); }); +test('requestYomitanScanTokens warns when active Yomitan profile has no dictionaries', async () => { + const warnings: Array<{ message: string; details: unknown }> = []; + const deps = createDeps(async (script) => { + if (script.includes('optionsGetFull')) { + return { + profileCurrent: 0, + profiles: [ + { + options: { + scanning: { length: 40 }, + dictionaries: [], + }, + }, + ], + }; + } + if (script.includes('parseText')) { + return []; + } + if (script.includes('termsFind')) { + return []; + } + return null; + }); + + await requestYomitanScanTokens('字幕', deps, { + error: () => undefined, + warn: (message, details) => warnings.push({ message, details }), + }); + + assert.equal(warnings.length, 1); + assert.match(warnings[0]!.message, /no enabled dictionaries/); + assert.deepEqual(warnings[0]!.details, { + profileIndex: 0, + scanLength: 40, + dictionaryCount: 0, + dictionaries: [], + omittedDictionaryCount: 0, + }); +}); + test('requestYomitanScanTokens keeps scanner metadata when parse spans agree', async () => { const deps = createDeps(async (script) => { if (script.includes('optionsGetFull')) { diff --git a/src/core/services/tokenizer/yomitan-parser-runtime.ts b/src/core/services/tokenizer/yomitan-parser-runtime.ts index f4aecbec..cbc8a753 100644 --- a/src/core/services/tokenizer/yomitan-parser-runtime.ts +++ b/src/core/services/tokenizer/yomitan-parser-runtime.ts @@ -7,6 +7,7 @@ import { selectYomitanParseTokens } from './parser-selection-stage'; interface LoggerLike { error: (message: string, ...args: unknown[]) => void; info?: (message: string, ...args: unknown[]) => void; + warn?: (message: string, ...args: unknown[]) => void; } interface YomitanParserRuntimeDeps { @@ -72,6 +73,7 @@ export interface YomitanAddNoteResult { const DEFAULT_YOMITAN_SCAN_LENGTH = 40; const yomitanProfileMetadataByWindow = new WeakMap(); +const yomitanProfileDiagnosticsLoggedByWindow = new WeakSet(); const yomitanFrequencyCacheByWindow = new WeakMap< BrowserWindow, Map @@ -532,6 +534,7 @@ async function requestYomitanProfileMetadata( return null; } yomitanProfileMetadataByWindow.set(parserWindow, metadata); + logYomitanProfileDiagnostics(parserWindow, metadata, logger); return metadata; } catch (err) { logger.error('Yomitan parser metadata request failed:', (err as Error).message); @@ -539,6 +542,37 @@ async function requestYomitanProfileMetadata( } } +function logYomitanProfileDiagnostics( + parserWindow: BrowserWindow, + metadata: YomitanProfileMetadata, + logger: LoggerLike, +): void { + if (yomitanProfileDiagnosticsLoggedByWindow.has(parserWindow)) { + return; + } + yomitanProfileDiagnosticsLoggedByWindow.add(parserWindow); + + const visibleDictionaries = metadata.dictionaries.slice(0, 8); + const details = { + profileIndex: metadata.profileIndex, + scanLength: metadata.scanLength, + dictionaryCount: metadata.dictionaries.length, + dictionaries: visibleDictionaries, + omittedDictionaryCount: Math.max(0, metadata.dictionaries.length - visibleDictionaries.length), + }; + + if (metadata.dictionaries.length === 0) { + const logWarning = logger.warn ?? logger.info; + logWarning?.( + 'Yomitan active profile has no enabled dictionaries; lookup popups may not show definitions.', + details, + ); + return; + } + + logger.info?.('Yomitan active profile dictionaries loaded.', details); +} + async function ensureYomitanParserWindow( deps: YomitanParserRuntimeDeps, logger: LoggerLike, diff --git a/src/core/services/yomitan-extension-copy.ts b/src/core/services/yomitan-extension-copy.ts index 259e8089..aade8025 100644 --- a/src/core/services/yomitan-extension-copy.ts +++ b/src/core/services/yomitan-extension-copy.ts @@ -14,6 +14,10 @@ type ExtensionCopyResult = { copied: boolean; }; +type ExtensionCopyOptions = { + platform?: NodeJS.Platform; +}; + const asyncExtensionCopyInFlight = new Map>(); function readManifestVersion(manifestPath: string): string | null { @@ -142,8 +146,12 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string) return sourceHash === null || targetHash === null || sourceHash !== targetHash; } -export function ensureExtensionCopy(sourceDir: string, userDataPath: string): ExtensionCopyResult { - if (process.platform === 'win32') { +export function ensureExtensionCopy( + sourceDir: string, + userDataPath: string, + options?: ExtensionCopyOptions, +): ExtensionCopyResult { + if ((options?.platform ?? process.platform) === 'win32') { return { targetDir: sourceDir, copied: false }; } @@ -167,8 +175,9 @@ export function ensureExtensionCopy(sourceDir: string, userDataPath: string): Ex export async function ensureExtensionCopyAsync( sourceDir: string, userDataPath: string, + options?: ExtensionCopyOptions, ): Promise { - if (process.platform === 'win32') { + if ((options?.platform ?? process.platform) === 'win32') { return { targetDir: sourceDir, copied: false }; } diff --git a/src/core/services/yomitan-extension-loader.test.ts b/src/core/services/yomitan-extension-loader.test.ts index c8bd0e8b..6313e947 100644 --- a/src/core/services/yomitan-extension-loader.test.ts +++ b/src/core/services/yomitan-extension-loader.test.ts @@ -135,7 +135,7 @@ test('ensureExtensionCopy refreshes copied extension when display files change', 'old display code', ); - const result = ensureExtensionCopy(sourceDir, userDataRoot); + const result = ensureExtensionCopy(sourceDir, userDataRoot, { platform: 'linux' }); assert.equal(result.targetDir, targetDir); assert.equal(result.copied, true); @@ -170,7 +170,9 @@ test('ensureExtensionCopyAsync refreshes copied extension without completing syn ); let completed = false; - const resultPromise = ensureExtensionCopyAsync(sourceDir, userDataRoot).then((result) => { + const resultPromise = ensureExtensionCopyAsync(sourceDir, userDataRoot, { + platform: 'linux', + }).then((result) => { completed = true; return result; }); @@ -233,9 +235,9 @@ test('ensureExtensionCopyAsync shares an in-flight refresh for the same copied e }); try { - const first = ensureExtensionCopyAsync(sourceDir, userDataRoot); + const first = ensureExtensionCopyAsync(sourceDir, userDataRoot, { platform: 'linux' }); await firstCopyStartedPromise; - const second = ensureExtensionCopyAsync(sourceDir, userDataRoot); + const second = ensureExtensionCopyAsync(sourceDir, userDataRoot, { platform: 'linux' }); releaseFirstCopy(); const results = await Promise.all([first, second]); diff --git a/src/core/services/yomitan-extension-loader.ts b/src/core/services/yomitan-extension-loader.ts index dc364e74..864792de 100644 --- a/src/core/services/yomitan-extension-loader.ts +++ b/src/core/services/yomitan-extension-loader.ts @@ -142,6 +142,10 @@ export async function loadYomitanExtension( } targetSession = session.fromPath(resolvedProfilePath); + logger.info('Loading Yomitan extension from external profile', { + profilePath: resolvedProfilePath, + extensionPath: extPath, + }); } else { const searchPaths = getYomitanExtensionSearchPaths({ explicitPath: deps.extensionPath, @@ -174,6 +178,10 @@ export async function loadYomitanExtension( logger.debug(`Copied yomitan extension to ${extensionCopy.targetDir}`); } extPath = extensionCopy.targetDir; + logger.info('Loading bundled Yomitan extension', { + extensionPath: extPath, + copied: extensionCopy.copied, + }); } clearParserState(); @@ -191,6 +199,12 @@ export async function loadYomitanExtension( }), ); deps.setYomitanExtension(extension); + logger.info('Yomitan extension loaded', { + extensionId: extension.id, + extensionName: extension.name, + extensionPath: extPath, + externalProfile: externalProfilePath.length > 0, + }); return extension; } catch (err) { logger.error('Failed to load Yomitan extension:', (err as Error).message); diff --git a/src/generate-config-example.test.ts b/src/generate-config-example.test.ts index a44eef19..217840e4 100644 --- a/src/generate-config-example.test.ts +++ b/src/generate-config-example.test.ts @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import test from 'node:test'; import { + generateConfigExampleTemplate, resolveConfigExampleOutputPaths, writeConfigExampleArtifacts, } from './generate-config-example'; @@ -80,3 +81,9 @@ test('writeConfigExampleArtifacts creates parent directories for resolved output fs.rmSync(workspace, { recursive: true, force: true }); } }); + +test('generateConfigExampleTemplate uses the canonical example socket path', () => { + const template = generateConfigExampleTemplate(); + + assert.match(template, /"socketPath": "\\\\\\\\.\\\\pipe\\\\subminer-socket"/); +}); diff --git a/src/generate-config-example.ts b/src/generate-config-example.ts index bf820302..f9a9878e 100644 --- a/src/generate-config-example.ts +++ b/src/generate-config-example.ts @@ -1,6 +1,15 @@ import * as fs from 'fs'; import * as path from 'path'; -import { DEFAULT_CONFIG, generateConfigTemplate } from './config'; +import { DEFAULT_CONFIG, deepCloneConfig, generateConfigTemplate } from './config'; +import { getDefaultMpvSocketPath } from './shared/mpv-socket-path'; + +const CONFIG_EXAMPLE_PLATFORM: NodeJS.Platform = 'win32'; + +export function generateConfigExampleTemplate(): string { + const config = deepCloneConfig(DEFAULT_CONFIG); + config.mpv.socketPath = getDefaultMpvSocketPath(CONFIG_EXAMPLE_PLATFORM); + return generateConfigTemplate(config); +} type ConfigExampleFsDeps = { existsSync?: (candidate: string) => boolean; @@ -54,7 +63,7 @@ export function writeConfigExampleArtifacts( } function main(): void { - const template = generateConfigTemplate(DEFAULT_CONFIG); + const template = generateConfigExampleTemplate(); writeConfigExampleArtifacts(template); } diff --git a/src/logger.test.ts b/src/logger.test.ts index 57eee62b..c01e7690 100644 --- a/src/logger.test.ts +++ b/src/logger.test.ts @@ -1,9 +1,10 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import path from 'node:path'; -import { resolveDefaultLogFilePath } from './logger'; +import { resolveDefaultLogFilePath, setLogRotation } from './logger'; test('resolveDefaultLogFilePath uses APPDATA on windows', () => { + const today = new Date().toISOString().slice(0, 10); const resolved = resolveDefaultLogFilePath({ platform: 'win32', homeDir: 'C:\\Users\\tester', @@ -17,13 +18,14 @@ test('resolveDefaultLogFilePath uses APPDATA on windows', () => { 'C:\\Users\\tester\\AppData\\Roaming', 'SubMiner', 'logs', - `app-${new Date().toISOString().slice(0, 10)}.log`, + `app-${today}.log`, ), ), ); }); test('resolveDefaultLogFilePath uses .config on linux', () => { + const today = new Date().toISOString().slice(0, 10); const resolved = resolveDefaultLogFilePath({ platform: 'linux', homeDir: '/home/tester', @@ -36,7 +38,37 @@ test('resolveDefaultLogFilePath uses .config on linux', () => { '.config', 'SubMiner', 'logs', - `app-${new Date().toISOString().slice(0, 10)}.log`, + `app-${today}.log`, ), ); }); + +test('setLogRotation accepts numeric retention days', () => { + const previous = process.env.SUBMINER_LOG_ROTATION; + const today = new Date().toISOString().slice(0, 10); + setLogRotation(14); + try { + const resolved = resolveDefaultLogFilePath({ + platform: 'linux', + homeDir: '/home/tester', + }); + + assert.equal( + resolved, + path.join( + '/home/tester', + '.config', + 'SubMiner', + 'logs', + `app-${today}.log`, + ), + ); + assert.equal(process.env.SUBMINER_LOG_ROTATION, '14'); + } finally { + if (previous == null) { + delete process.env.SUBMINER_LOG_ROTATION; + } else { + process.env.SUBMINER_LOG_ROTATION = previous; + } + } +}); diff --git a/src/logger.ts b/src/logger.ts index 7c299cb1..4af0be96 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,5 +1,11 @@ import { appendLogLine, + applyLogFileTogglesToEnv, + DEFAULT_LOG_ROTATION, + isLogFileEnabled, + normalizeLogRotation, + type LogFileToggles, + type LogRotation, resolveDefaultLogFilePath as resolveSharedDefaultLogFilePath, } from './shared/log-files'; @@ -24,10 +30,11 @@ const LEVEL_PRIORITY: Record = { error: 40, }; -const DEFAULT_LOG_LEVEL: LogLevel = 'info'; +const DEFAULT_LOG_LEVEL: LogLevel = 'warn'; let cliLogLevel: LogLevel | undefined; let configLogLevel: LogLevel | undefined; +let configLogRotation: LogRotation = DEFAULT_LOG_ROTATION; function pad(value: number): string { return String(value).padStart(2, '0'); @@ -76,6 +83,15 @@ export function setLogLevel(level: string | undefined, source: LogLevelSource = } } +export function setLogRotation(rotation: unknown): void { + configLogRotation = normalizeLogRotation(rotation) ?? DEFAULT_LOG_ROTATION; + process.env.SUBMINER_LOG_ROTATION = String(configLogRotation); +} + +export function setLogFileToggles(files: Partial | undefined): void { + applyLogFileTogglesToEnv(files); +} + function normalizeError(error: Error): { message: string; stack?: string } { return { message: error.message, @@ -124,12 +140,16 @@ export function resolveDefaultLogFilePath(options?: { platform?: NodeJS.Platform; homeDir?: string; appDataDir?: string; + rotation?: unknown; }): string { return resolveSharedDefaultLogFilePath('app', options); } function appendToLogFile(line: string): void { - appendLogLine(resolveLogFilePath(), line); + if (!isLogFileEnabled('app')) { + return; + } + appendLogLine(resolveLogFilePath(), line, { rotation: configLogRotation }); } function emit(level: LogLevel, scope: string, message: string, meta: unknown[]): void { diff --git a/src/main-entry-launch-config.ts b/src/main-entry-launch-config.ts index 3a7a66db..cec9959d 100644 --- a/src/main-entry-launch-config.ts +++ b/src/main-entry-launch-config.ts @@ -2,21 +2,28 @@ import path from 'node:path'; import { loadRawConfigStrict } from './config/load'; import { resolveConfig } from './config/resolve'; import type { MpvLaunchMode, ResolvedConfig } from './types/config'; +import type { LogFileToggles, LogRotation } from './shared/log-files'; +import type { SharedLogLevel } from './shared/mpv-logging-args'; import type { SubminerPluginRuntimeScriptOptConfig } from './shared/subminer-plugin-script-opts'; export interface ConfiguredWindowsMpvLaunch { executablePath: string; launchMode: MpvLaunchMode; + logLevel: SharedLogLevel; + logRotation: LogRotation; + logFiles: LogFileToggles; pluginRuntimeConfig: SubminerPluginRuntimeScriptOptConfig; } export function buildWindowsMpvPluginRuntimeConfig( - config: Pick, + config: Pick, ): SubminerPluginRuntimeScriptOptConfig { return { socketPath: config.mpv.socketPath, binaryPath: config.mpv.subminerBinaryPath, backend: config.mpv.backend, + logLevel: config.logging.level, + logRotation: config.logging.rotation, autoStart: config.mpv.autoStartSubMiner, autoStartVisibleOverlay: config.auto_start_overlay, autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady, @@ -38,6 +45,9 @@ export function readConfiguredWindowsMpvLaunch(configDir: string): ConfiguredWin return { executablePath: resolved.mpv.executablePath, launchMode: resolved.mpv.launchMode, + logLevel: resolved.logging.level, + logRotation: resolved.logging.rotation, + logFiles: resolved.logging.files, pluginRuntimeConfig: buildWindowsMpvPluginRuntimeConfig(resolved), }; } diff --git a/src/main-entry-runtime.test.ts b/src/main-entry-runtime.test.ts index e2c51939..317df69a 100644 --- a/src/main-entry-runtime.test.ts +++ b/src/main-entry-runtime.test.ts @@ -313,10 +313,15 @@ test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script assert.equal(launch.executablePath, DEFAULT_CONFIG.mpv.executablePath); assert.equal(launch.launchMode, DEFAULT_CONFIG.mpv.launchMode); + assert.equal(launch.logLevel, DEFAULT_CONFIG.logging.level); + assert.equal(launch.logRotation, DEFAULT_CONFIG.logging.rotation); + assert.deepEqual(launch.logFiles, DEFAULT_CONFIG.logging.files); assert.deepEqual(launch.pluginRuntimeConfig, { socketPath: DEFAULT_CONFIG.mpv.socketPath, binaryPath: DEFAULT_CONFIG.mpv.subminerBinaryPath, backend: DEFAULT_CONFIG.mpv.backend, + logLevel: DEFAULT_CONFIG.logging.level, + logRotation: DEFAULT_CONFIG.logging.rotation, autoStart: DEFAULT_CONFIG.mpv.autoStartSubMiner, autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay, autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady, @@ -339,6 +344,13 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script texthooker: { launchAtStartup: true, }, + logging: { + level: 'debug', + rotation: 14, + files: { + mpv: true, + }, + }, mpv: { executablePath: ' C:\\tools\\mpv.exe ', launchMode: 'maximized', @@ -357,10 +369,15 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script assert.equal(launch.executablePath, 'C:\\tools\\mpv.exe'); assert.equal(launch.launchMode, 'maximized'); + assert.equal(launch.logLevel, 'debug'); + assert.equal(launch.logRotation, 14); + assert.equal(launch.logFiles.mpv, true); assert.deepEqual(launch.pluginRuntimeConfig, { socketPath: '\\\\.\\pipe\\custom-subminer-socket', binaryPath: 'C:\\SubMiner\\Custom.exe', backend: 'windows', + logLevel: 'debug', + logRotation: 14, autoStart: false, autoStartVisibleOverlay: false, autoStartPauseUntilReady: false, diff --git a/src/main-entry.ts b/src/main-entry.ts index cead3f46..2753f1a2 100644 --- a/src/main-entry.ts +++ b/src/main-entry.ts @@ -31,9 +31,30 @@ import { import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch'; import { runStatsDaemonControlFromProcess } from './stats-daemon-entry'; import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error'; +import { buildMpvLoggingArgs } from './shared/mpv-logging-args'; +import { + applyLogFileTogglesToEnv, + isLogFileEnabled, + appendLogLine, + pruneLogDirectoryForPath, + resolveDefaultLogFilePath, + type LogRotation, +} from './shared/log-files'; const DEFAULT_TEXTHOOKER_PORT = 5174; +function appendWindowsMpvLaunchLog(message: string, logRotation?: LogRotation): void { + if (!isLogFileEnabled('app')) { + return; + } + const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19); + appendLogLine( + process.env.SUBMINER_APP_LOG?.trim() || resolveDefaultLogFilePath('app'), + `[subminer] - ${timestamp} - INFO - [main:windows-mpv-launch] ${message}`, + { rotation: logRotation }, + ); +} + function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void { if (sanitizedEnv.NODE_NO_WARNINGS) { process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS; @@ -216,6 +237,14 @@ async function runEntryProcess(): Promise { applySanitizedEnv(sanitizedEnv); await app.whenReady(); const configuredMpvLaunch = readConfiguredWindowsMpvLaunch(userDataPath); + const extraArgs = normalizeLaunchMpvExtraArgs(process.argv); + applyLogFileTogglesToEnv(configuredMpvLaunch.logFiles); + const mpvLogPath = isLogFileEnabled('mpv') + ? process.env.SUBMINER_MPV_LOG?.trim() || resolveDefaultLogFilePath('mpv') + : ''; + if (mpvLogPath) { + pruneLogDirectoryForPath(mpvLogPath, configuredMpvLaunch.logRotation); + } const result = await launchWindowsMpv( normalizeLaunchMpvTargets(process.argv), createWindowsMpvLaunchDeps({ @@ -223,8 +252,9 @@ async function runEntryProcess(): Promise { showError: (title, content) => { dialog.showErrorBox(title, content); }, + logInfo: (message) => appendWindowsMpvLaunchLog(message, configuredMpvLaunch.logRotation), }), - normalizeLaunchMpvExtraArgs(process.argv), + [...extraArgs, ...buildMpvLoggingArgs(configuredMpvLaunch.logLevel, mpvLogPath, extraArgs)], process.execPath, resolveBundledWindowsMpvPluginEntrypoint(), configuredMpvLaunch.executablePath, diff --git a/src/main.ts b/src/main.ts index a7a8d0e5..3d02a9c0 100644 --- a/src/main.ts +++ b/src/main.ts @@ -123,10 +123,16 @@ import { RuntimeOptionsManager } from './runtime-options'; import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils'; import { createLogger, + setLogFileToggles, setLogLevel, - resolveDefaultLogFilePath, + setLogRotation, type LogLevelSource, } from './logger'; +import { + isLogFileEnabled, + pruneLogDirectoryForPath, + resolveDefaultLogFilePath, +} from './shared/log-files'; import { createFatalErrorReporter } from './main/fatal-error'; import { createWindowTracker as createWindowTrackerCore } from './window-trackers'; import { @@ -148,6 +154,7 @@ import { } from './cli/args'; import { printHelp } from './cli/help'; import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts'; +import { buildMpvLoggingArgs } from './shared/mpv-logging-args'; import { AnkiConnectClient } from './anki-connect'; import { getStartupModeFlags, @@ -258,6 +265,7 @@ import { createMpvOsdRuntimeHandlers, createCycleSecondarySubModeRuntimeHandler, } from './main/runtime/domains/mpv'; +import { buildSubtitleTrackDiagnostics } from './main/runtime/mpv-track-diagnostics'; import { createBuildCopyCurrentSubtitleMainDepsHandler, createBuildHandleMineSentenceDigitMainDepsHandler, @@ -430,6 +438,7 @@ import { shouldQuitOnMpvShutdownForTrayState, shouldQuitOnWindowAllClosedForTrayState, } from './main/runtime/startup-tray-policy'; +import { exportLogsArchive } from './main/runtime/log-export'; import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup'; import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps'; import { @@ -500,10 +509,7 @@ import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './mai import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open'; import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open'; import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open'; -import { - openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime, - openCharacterDictionaryModal as openCharacterDictionaryModalRuntime, -} from './main/runtime/character-dictionary-open'; +import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open'; import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open'; import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open'; import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc'; @@ -532,6 +538,7 @@ import { } from './main/runtime/character-dictionary-auto-sync'; import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion'; import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications'; +import { openCharacterDictionaryManagerWithConfigGate } from './main/runtime/character-dictionary-manager-gate'; import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate'; import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot'; import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io'; @@ -641,7 +648,7 @@ if (process.platform === 'linux') { app.setName('SubMiner'); const DEFAULT_TEXTHOOKER_PORT = 5174; -const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({ +const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath('mpv', { platform: process.platform, homeDir: os.homedir(), appDataDir: process.env.APPDATA, @@ -723,7 +730,7 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug') const texthookerService = new Texthooker(() => { const config = getResolvedConfig(); const characterDictionaryEnabled = - config.anilist.characterDictionary.enabled && + config.subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled(); const knownWordColoringEnabled = getRuntimeBooleanOption( 'subtitle.annotation.knownWords.highlightEnabled', @@ -1295,17 +1302,24 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ await ensureYoutubePlaybackRuntimeReady(); }, resolveYoutubePlaybackUrl: (url, format) => resolveYoutubePlaybackUrl(url, format), - launchWindowsMpv: (playbackUrl, args) => - launchWindowsMpv( + launchWindowsMpv: (playbackUrl, args) => { + const config = getResolvedConfig(); + setLogFileToggles(config.logging.files); + const mpvLogPath = isLogFileEnabled('mpv') ? resolveDefaultLogFilePath('mpv') : ''; + if (mpvLogPath) { + pruneLogDirectoryForPath(mpvLogPath, config.logging.rotation); + } + const mpvArgs = [...args, ...buildMpvLoggingArgs(config.logging.level, mpvLogPath, args)]; + return launchWindowsMpv( [playbackUrl], createWindowsMpvLaunchDeps({ showError: (title, content) => dialog.showErrorBox(title, content), }), - [...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`], + mpvArgs, process.execPath, resolveBundledMpvRuntimePluginEntrypoint(), - getResolvedConfig().mpv.executablePath, - getResolvedConfig().mpv.launchMode, + config.mpv.executablePath, + config.mpv.launchMode, { detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin, notifyInstalledPluginDetected: logInstalledMpvPluginDetected, @@ -1313,7 +1327,8 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({ promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection), }, getMpvPluginRuntimeConfig(), - ), + ); + }, waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs), prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request), runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request), @@ -1705,6 +1720,7 @@ const subtitleProcessingController = createSubtitleProcessingController( let subtitlePrefetchService: SubtitlePrefetchService | null = null; let subtitlePrefetchRefreshTimer: ReturnType | null = null; let lastObservedTimePos = 0; +let lastObservedPrimarySubtitleTrackId: number | null = null; let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null = null; const SEEK_THRESHOLD_SECONDS = 3; @@ -1913,9 +1929,6 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService( openRuntimeOptionsPalette: () => { openRuntimeOptionsPalette(); }, - openCharacterDictionary: () => { - openCharacterDictionaryOverlay(); - }, openCharacterDictionaryManager: () => { openCharacterDictionaryManagerOverlay(); }, @@ -2003,6 +2016,12 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp setLogLevel: (level) => { setLogLevel(level, 'config'); }, + setLogRotation: (rotation) => { + setLogRotation(rotation); + }, + setLogFileToggles: (files) => { + setLogFileToggles(files); + }, }, ); const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler( @@ -2217,7 +2236,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt const config = getResolvedConfig().anilist.characterDictionary; return { enabled: - config.enabled && + getResolvedConfig().subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled() && !isYoutubePlaybackActiveNow(), maxLoaded: config.maxLoaded, @@ -2931,20 +2950,21 @@ function openSessionHelpOverlay(): void { ); } -function openCharacterDictionaryOverlay(): void { - openOverlayHostedModalWithOsd( - openCharacterDictionaryModalRuntime, - 'Character dictionary overlay unavailable.', - 'Failed to open character dictionary overlay.', - ); -} - function openCharacterDictionaryManagerOverlay(): void { - openOverlayHostedModalWithOsd( - openCharacterDictionaryManagerModalRuntime, - 'Character dictionary manager unavailable.', - 'Failed to open character dictionary manager.', - ); + openCharacterDictionaryManagerWithConfigGate({ + isCharacterDictionaryEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, + getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType, + openManager: () => { + openOverlayHostedModalWithOsd( + openCharacterDictionaryManagerModalRuntime, + 'Character dictionary manager unavailable.', + 'Failed to open character dictionary manager.', + ); + }, + showOsd: (message) => showMpvOsd(message), + showDesktopNotification: (title, options) => showDesktopNotification(title, options), + logWarn: (message, error) => logger.warn(message, error), + }); } function openControllerSelectOverlay(): void { @@ -3054,7 +3074,7 @@ const { mpvExecutablePath: getResolvedConfig().mpv.executablePath, }), getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(), - defaultMpvLogPath: DEFAULT_MPV_LOG_PATH, + getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''), defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS, removeSocketPath: (socketPath) => { fs.rmSync(socketPath, { force: true }); @@ -4312,6 +4332,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({ getConfigWarnings: () => configService.getWarnings(), logConfigWarning: (warning) => appLogger.logConfigWarning(warning), setLogLevel: (level: string, source: LogLevelSource) => setLogLevel(level, source), + setLogRotation: (rotation: number) => setLogRotation(rotation), + setLogFileToggles: (files) => setLogFileToggles(files), initRuntimeOptionsManager: () => { appState.runtimeOptionsManager = new RuntimeOptionsManager( () => configService.getConfig().ankiConnect, @@ -4638,7 +4660,11 @@ const { markJellyfinRemotePlaybackLoadedState(activeJellyfinRemotePlayback, path); }, scheduleCharacterDictionarySync: () => { - if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) { + if ( + !getResolvedConfig().subtitleStyle.nameMatchEnabled || + !yomitanProfilePolicy.isCharacterDictionaryEnabled() || + isYoutubePlaybackActiveNow() + ) { return; } characterDictionaryAutoSyncRuntime.scheduleSync(); @@ -4674,10 +4700,23 @@ const { ); }, onSubtitleTrackChange: (sid) => { + lastObservedPrimarySubtitleTrackId = sid; + logger.info('[mpv-subtitles] primary subtitle track changed', { sid }); scheduleSubtitlePrefetchRefresh(); youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid); }, onSubtitleTrackListChange: (trackList) => { + const diagnostics = buildSubtitleTrackDiagnostics( + lastObservedPrimarySubtitleTrackId, + trackList, + ); + if (!diagnostics.trackListReadable) { + logger.warn('[mpv-subtitles] mpv reported an unreadable subtitle track list', diagnostics); + } else if (diagnostics.subtitleTrackCount === 0) { + logger.warn('[mpv-subtitles] mpv reported no subtitle tracks', diagnostics); + } else { + logger.info('[mpv-subtitles] subtitle track list updated', diagnostics); + } managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList); scheduleSubtitlePrefetchRefresh(); youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList); @@ -4763,7 +4802,7 @@ const { getResolvedConfig().subtitleStyle.enableJlpt, ), getCharacterDictionaryEnabled: () => - getResolvedConfig().anilist.characterDictionary.enabled && + getResolvedConfig().subtitleStyle.nameMatchEnabled && yomitanProfilePolicy.isCharacterDictionaryEnabled() && !isYoutubePlaybackActiveNow(), getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled, @@ -5131,6 +5170,53 @@ function openYomitanSettings(): boolean { return true; } +function describeUnknownError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function exportLogsFromTray(): Promise { + try { + await flushMpvLog(); + } catch (error) { + logger.warn('Failed to flush mpv log before exporting logs from tray.', error); + } + + try { + const result = exportLogsArchive({ + platform: process.platform, + homeDir: os.homedir(), + appDataDir: app.getPath('appData'), + }); + logger.info( + `Exported ${result.exportedFiles.length} sanitized log file(s) to ${result.zipPath}`, + ); + void dialog + .showMessageBox({ + type: 'info', + title: 'SubMiner logs exported', + message: 'SubMiner log export created.', + detail: result.zipPath, + buttons: ['OK', 'Show in Folder'], + defaultId: 0, + cancelId: 0, + }) + .then((response) => { + if (response.response === 1) { + shell.showItemInFolder(result.zipPath); + } + }); + } catch (error) { + const message = describeUnknownError(error); + logger.warn('Failed to export logs from tray.', error); + void dialog.showMessageBox({ + type: 'error', + title: 'SubMiner log export failed', + message: 'Could not export SubMiner logs.', + detail: message, + }); + } +} + const { getConfiguredShortcuts, registerGlobalShortcuts, @@ -5241,7 +5327,7 @@ function refreshCurrentSessionBindings(): void { const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({ appendToMpvLogMainDeps: { - logPath: DEFAULT_MPV_LOG_PATH, + getLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''), dirname: (targetPath) => path.dirname(targetPath), mkdir: async (targetPath, options) => { await fs.promises.mkdir(targetPath, options); @@ -5757,7 +5843,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(), openJimaku: () => openJimakuOverlay(), openSessionHelp: () => openSessionHelpOverlay(), - openCharacterDictionary: () => openCharacterDictionaryOverlay(), openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(), openControllerSelect: () => openControllerSelectOverlay(), openControllerDebug: () => openControllerDebugOverlay(), @@ -6436,6 +6521,9 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } = showWindowsMpvLauncherSetup: () => process.platform === 'win32', openYomitanSettings: () => openYomitanSettings(), openConfigSettingsWindow: () => openConfigSettingsWindow(), + exportLogs: () => { + void exportLogsFromTray(); + }, openJellyfinSetupWindow: () => openJellyfinSetupWindow(), isJellyfinConfigured: () => isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()), diff --git a/src/main/app-lifecycle.ts b/src/main/app-lifecycle.ts index 29b24823..008366bb 100644 --- a/src/main/app-lifecycle.ts +++ b/src/main/app-lifecycle.ts @@ -40,6 +40,8 @@ export interface AppReadyRuntimeDepsFactoryInput { startTexthooker: AppReadyRuntimeDeps['startTexthooker']; log: AppReadyRuntimeDeps['log']; setLogLevel: AppReadyRuntimeDeps['setLogLevel']; + setLogRotation?: AppReadyRuntimeDeps['setLogRotation']; + setLogFileToggles?: AppReadyRuntimeDeps['setLogFileToggles']; createMecabTokenizerAndCheck: AppReadyRuntimeDeps['createMecabTokenizerAndCheck']; createSubtitleTimingTracker: AppReadyRuntimeDeps['createSubtitleTimingTracker']; createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker']; @@ -107,6 +109,8 @@ export function createAppReadyRuntimeDeps( startTexthooker: params.startTexthooker, log: params.log, setLogLevel: params.setLogLevel, + setLogRotation: params.setLogRotation, + setLogFileToggles: params.setLogFileToggles, createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck, createSubtitleTimingTracker: params.createSubtitleTimingTracker, createImmersionTracker: params.createImmersionTracker, diff --git a/src/main/character-dictionary-runtime/zip.ts b/src/main/character-dictionary-runtime/zip.ts index 9ec09904..a4b92458 100644 --- a/src/main/character-dictionary-runtime/zip.ts +++ b/src/main/character-dictionary-runtime/zip.ts @@ -1,24 +1,8 @@ -import * as fs from 'fs'; import * as path from 'path'; +import { writeStoredZip } from '../../shared/stored-zip'; import { ensureDir } from './fs-utils'; import type { CharacterDictionarySnapshotImage, CharacterDictionaryTermEntry } from './types'; -type ZipEntry = { - name: string; - crc32: number; - size: number; - localHeaderOffset: number; -}; - -function writeUint32LE(buffer: Buffer, value: number, offset: number): number { - const normalized = value >>> 0; - buffer[offset] = normalized & 0xff; - buffer[offset + 1] = (normalized >>> 8) & 0xff; - buffer[offset + 2] = (normalized >>> 16) & 0xff; - buffer[offset + 3] = (normalized >>> 24) & 0xff; - return offset + 4; -} - export function buildDictionaryTitle(mediaId: number): string { return `SubMiner Character Dictionary (AniList ${mediaId})`; } @@ -47,169 +31,6 @@ function createTagBank(): Array<[string, string, number, string, number]> { ]; } -const CRC32_TABLE = (() => { - const table = new Uint32Array(256); - for (let i = 0; i < 256; i += 1) { - let crc = i; - for (let j = 0; j < 8; j += 1) { - crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; - } - table[i] = crc >>> 0; - } - return table; -})(); - -function crc32(data: Buffer): number { - let crc = 0xffffffff; - for (const byte of data) { - crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); - } - return (crc ^ 0xffffffff) >>> 0; -} - -function createLocalFileHeader(fileName: Buffer, fileCrc32: number, fileSize: number): Buffer { - const local = Buffer.alloc(30 + fileName.length); - let cursor = 0; - writeUint32LE(local, 0x04034b50, cursor); - cursor += 4; - local.writeUInt16LE(20, cursor); - cursor += 2; - local.writeUInt16LE(0, cursor); - cursor += 2; - local.writeUInt16LE(0, cursor); - cursor += 2; - local.writeUInt16LE(0, cursor); - cursor += 2; - local.writeUInt16LE(0, cursor); - cursor += 2; - writeUint32LE(local, fileCrc32, cursor); - cursor += 4; - writeUint32LE(local, fileSize, cursor); - cursor += 4; - writeUint32LE(local, fileSize, cursor); - cursor += 4; - local.writeUInt16LE(fileName.length, cursor); - cursor += 2; - local.writeUInt16LE(0, cursor); - cursor += 2; - fileName.copy(local, cursor); - return local; -} - -function createCentralDirectoryHeader(entry: ZipEntry): Buffer { - const fileName = Buffer.from(entry.name, 'utf8'); - const central = Buffer.alloc(46 + fileName.length); - let cursor = 0; - writeUint32LE(central, 0x02014b50, cursor); - cursor += 4; - central.writeUInt16LE(20, cursor); - cursor += 2; - central.writeUInt16LE(20, cursor); - cursor += 2; - central.writeUInt16LE(0, cursor); - cursor += 2; - central.writeUInt16LE(0, cursor); - cursor += 2; - central.writeUInt16LE(0, cursor); - cursor += 2; - central.writeUInt16LE(0, cursor); - cursor += 2; - writeUint32LE(central, entry.crc32, cursor); - cursor += 4; - writeUint32LE(central, entry.size, cursor); - cursor += 4; - writeUint32LE(central, entry.size, cursor); - cursor += 4; - central.writeUInt16LE(fileName.length, cursor); - cursor += 2; - central.writeUInt16LE(0, cursor); - cursor += 2; - central.writeUInt16LE(0, cursor); - cursor += 2; - central.writeUInt16LE(0, cursor); - cursor += 2; - central.writeUInt16LE(0, cursor); - cursor += 2; - writeUint32LE(central, 0, cursor); - cursor += 4; - writeUint32LE(central, entry.localHeaderOffset, cursor); - cursor += 4; - fileName.copy(central, cursor); - return central; -} - -function createEndOfCentralDirectory( - entriesLength: number, - centralSize: number, - centralStart: number, -): Buffer { - const end = Buffer.alloc(22); - let cursor = 0; - writeUint32LE(end, 0x06054b50, cursor); - cursor += 4; - end.writeUInt16LE(0, cursor); - cursor += 2; - end.writeUInt16LE(0, cursor); - cursor += 2; - end.writeUInt16LE(entriesLength, cursor); - cursor += 2; - end.writeUInt16LE(entriesLength, cursor); - cursor += 2; - writeUint32LE(end, centralSize, cursor); - cursor += 4; - writeUint32LE(end, centralStart, cursor); - cursor += 4; - end.writeUInt16LE(0, cursor); - return end; -} - -function writeBuffer(fd: number, buffer: Buffer): void { - let written = 0; - while (written < buffer.length) { - written += fs.writeSync(fd, buffer, written, buffer.length - written); - } -} - -function writeStoredZip(outputPath: string, files: Iterable<{ name: string; data: Buffer }>): void { - const entries: ZipEntry[] = []; - let offset = 0; - const fd = fs.openSync(outputPath, 'w'); - - try { - for (const file of files) { - const fileName = Buffer.from(file.name, 'utf8'); - const fileSize = file.data.length; - const fileCrc32 = crc32(file.data); - const localHeader = createLocalFileHeader(fileName, fileCrc32, fileSize); - writeBuffer(fd, localHeader); - writeBuffer(fd, file.data); - entries.push({ - name: file.name, - crc32: fileCrc32, - size: fileSize, - localHeaderOffset: offset, - }); - offset += localHeader.length + fileSize; - } - - const centralStart = offset; - for (const entry of entries) { - const centralHeader = createCentralDirectoryHeader(entry); - writeBuffer(fd, centralHeader); - offset += centralHeader.length; - } - - const centralSize = offset - centralStart; - writeBuffer(fd, createEndOfCentralDirectory(entries.length, centralSize, centralStart)); - } catch (error) { - fs.closeSync(fd); - fs.rmSync(outputPath, { force: true }); - throw error; - } - - fs.closeSync(fd); -} - export function buildDictionaryZip( outputPath: string, dictionaryTitle: string, diff --git a/src/main/overlay-shortcuts-runtime.ts b/src/main/overlay-shortcuts-runtime.ts index 52ac5aa1..9c999dd0 100644 --- a/src/main/overlay-shortcuts-runtime.ts +++ b/src/main/overlay-shortcuts-runtime.ts @@ -19,7 +19,6 @@ export interface OverlayShortcutRuntimeServiceInput { isOverlayShortcutContextActive?: () => boolean; showMpvOsd: (text: string) => void; openRuntimeOptionsPalette: () => void; - openCharacterDictionary: () => void; openCharacterDictionaryManager: () => void; openJimaku: () => void; markAudioCard: () => Promise; @@ -51,9 +50,6 @@ export function createOverlayShortcutsRuntimeService( openRuntimeOptions: () => { input.openRuntimeOptionsPalette(); }, - openCharacterDictionary: () => { - input.openCharacterDictionary(); - }, openCharacterDictionaryManager: () => { input.openCharacterDictionaryManager(); }, diff --git a/src/main/runtime/app-ready-main-deps.ts b/src/main/runtime/app-ready-main-deps.ts index 5fd1ab66..7559fee9 100644 --- a/src/main/runtime/app-ready-main-deps.ts +++ b/src/main/runtime/app-ready-main-deps.ts @@ -22,6 +22,8 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD startTexthooker: deps.startTexthooker, log: deps.log, setLogLevel: deps.setLogLevel, + setLogRotation: deps.setLogRotation, + setLogFileToggles: deps.setLogFileToggles, createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck, createSubtitleTimingTracker: deps.createSubtitleTimingTracker, createImmersionTracker: deps.createImmersionTracker, diff --git a/src/main/runtime/character-dictionary-manager-gate.test.ts b/src/main/runtime/character-dictionary-manager-gate.test.ts new file mode 100644 index 00000000..42aa099c --- /dev/null +++ b/src/main/runtime/character-dictionary-manager-gate.test.ts @@ -0,0 +1,55 @@ +import { strict as assert } from 'node:assert'; +import { test } from 'node:test'; +import { + CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE, + openCharacterDictionaryManagerWithConfigGate, + type CharacterDictionaryManagerNotificationType, +} from './character-dictionary-manager-gate'; + +function makeDeps(options: { + enabled?: boolean; + notificationType?: CharacterDictionaryManagerNotificationType; +}) { + const calls: string[] = []; + return { + calls, + deps: { + isCharacterDictionaryEnabled: () => options.enabled ?? false, + getNotificationType: () => options.notificationType ?? 'osd', + openManager: () => calls.push('open'), + showOsd: (message: string) => calls.push(`osd:${message}`), + showDesktopNotification: (title: string, opts: { body: string }) => + calls.push(`system:${title}:${opts.body}`), + logWarn: (message: string) => calls.push(`warn:${message}`), + }, + }; +} + +test('opens character dictionary manager when character dictionary is enabled', () => { + const { calls, deps } = makeDeps({ enabled: true, notificationType: 'both' }); + + openCharacterDictionaryManagerWithConfigGate(deps); + + assert.deepEqual(calls, ['open']); +}); + +test('routes disabled manager notification to configured surfaces', () => { + for (const [type, expected] of [ + ['osd', [`osd:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`]], + ['system', [`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`]], + [ + 'both', + [ + `osd:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`, + `system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`, + ], + ], + ['none', []], + ] as const) { + const { calls, deps } = makeDeps({ enabled: false, notificationType: type }); + + openCharacterDictionaryManagerWithConfigGate(deps); + + assert.deepEqual(calls, expected); + } +}); diff --git a/src/main/runtime/character-dictionary-manager-gate.ts b/src/main/runtime/character-dictionary-manager-gate.ts new file mode 100644 index 00000000..361f8970 --- /dev/null +++ b/src/main/runtime/character-dictionary-manager-gate.ts @@ -0,0 +1,39 @@ +export type CharacterDictionaryManagerNotificationType = 'osd' | 'system' | 'both' | 'none'; + +export const CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE = + 'Enable Name Match in Settings to use the character dictionary manager.'; + +export interface CharacterDictionaryManagerGateDeps { + isCharacterDictionaryEnabled: () => boolean; + getNotificationType: () => CharacterDictionaryManagerNotificationType; + openManager: () => void; + showOsd: (message: string) => void; + showDesktopNotification: (title: string, options: { body: string }) => void; + logWarn?: (message: string, error?: unknown) => void; +} + +function notifyManagerDisabled(deps: CharacterDictionaryManagerGateDeps): void { + const type = deps.getNotificationType(); + if (type === 'osd' || type === 'both') { + deps.showOsd(CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE); + } + if (type === 'system' || type === 'both') { + try { + deps.showDesktopNotification('SubMiner', { + body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE, + }); + } catch (error) { + deps.logWarn?.('Unable to show character dictionary manager notification.', error); + } + } +} + +export function openCharacterDictionaryManagerWithConfigGate( + deps: CharacterDictionaryManagerGateDeps, +): void { + if (deps.isCharacterDictionaryEnabled()) { + deps.openManager(); + return; + } + notifyManagerDisabled(deps); +} diff --git a/src/main/runtime/character-dictionary-open.ts b/src/main/runtime/character-dictionary-open.ts index e0c2007d..8c15d755 100644 --- a/src/main/runtime/character-dictionary-open.ts +++ b/src/main/runtime/character-dictionary-open.ts @@ -53,17 +53,6 @@ type OpenCharacterDictionaryModalDeps = Omit< 'channel' | 'retryWarning' >; -export async function openCharacterDictionaryModal( - deps: OpenCharacterDictionaryModalDeps, -): Promise { - return await openCharacterDictionaryModalChannel({ - ...deps, - channel: IPC_CHANNELS.event.characterDictionaryOpen, - retryWarning: - 'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.', - }); -} - export async function openCharacterDictionaryManagerModal( deps: OpenCharacterDictionaryModalDeps, ): Promise { diff --git a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts index 3304e755..f2d67816 100644 --- a/src/main/runtime/composers/jellyfin-runtime-composer.test.ts +++ b/src/main/runtime/composers/jellyfin-runtime-composer.test.ts @@ -27,7 +27,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers' getLaunchMode: () => 'normal', platform: 'linux', execPath: process.execPath, - defaultMpvLogPath: '/tmp/test-mpv.log', + getDefaultMpvLogPath: () => '/tmp/test-mpv.log', defaultMpvArgs: [], removeSocketPath: () => {}, spawnMpv: () => ({ unref: () => {} }) as never, diff --git a/src/main/runtime/config-hot-reload-handlers.test.ts b/src/main/runtime/config-hot-reload-handlers.test.ts index cf7af824..0bcf8abd 100644 --- a/src/main/runtime/config-hot-reload-handlers.test.ts +++ b/src/main/runtime/config-hot-reload-handlers.test.ts @@ -73,6 +73,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom'; config.ankiConnect.isKiku.fieldGrouping = 'manual'; config.logging.level = 'debug'; + config.logging.rotation = 14; + config.logging.files.mpv = true; const calls: string[] = []; const ankiPatches: unknown[] = []; @@ -90,6 +92,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log refreshSubtitlePrefetch: () => calls.push('refresh:prefetch'), refreshCurrentSubtitle: () => calls.push('refresh:subtitle'), setLogLevel: (level) => calls.push(`log:${level}`), + setLogRotation: (rotation) => calls.push(`rotation:${rotation}`), + setLogFileToggles: (files) => calls.push(`files:${files.mpv}`), }); applyHotReload( @@ -109,6 +113,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log 'ankiConnect.isLapis.sentenceCardModel', 'ankiConnect.isKiku.fieldGrouping', 'logging.level', + 'logging.rotation', + 'logging.files.mpv', ], restartRequiredFields: [], }, @@ -135,6 +141,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log assert.ok(calls.includes('refresh:prefetch')); assert.ok(calls.includes('refresh:subtitle')); assert.ok(calls.includes('log:debug')); + assert.ok(calls.includes('rotation:14')); + assert.ok(calls.includes('files:true')); assert.ok(calls.includes('broadcast:config:hot-reload')); }); diff --git a/src/main/runtime/config-hot-reload-handlers.ts b/src/main/runtime/config-hot-reload-handlers.ts index 1d30313b..c3aef6ca 100644 --- a/src/main/runtime/config-hot-reload-handlers.ts +++ b/src/main/runtime/config-hot-reload-handlers.ts @@ -20,6 +20,8 @@ type ConfigHotReloadAppliedDeps = { refreshSubtitlePrefetch?: () => void; refreshCurrentSubtitle?: () => void; setLogLevel?: (level: ResolvedConfig['logging']['level']) => void; + setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void; + setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void; }; type ConfigHotReloadMessageDeps = { @@ -158,6 +160,12 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied if (diff.hotReloadFields.includes('logging.level')) { deps.setLogLevel?.(config.logging.level); } + if (diff.hotReloadFields.includes('logging.rotation')) { + deps.setLogRotation?.(config.logging.rotation); + } + if (hasAnyHotReloadField(diff, ['logging.files'])) { + deps.setLogFileToggles?.(config.logging.files); + } if (diff.hotReloadFields.length > 0) { deps.broadcastToOverlayWindows('config:hot-reload', payload); diff --git a/src/main/runtime/config-hot-reload-main-deps.ts b/src/main/runtime/config-hot-reload-main-deps.ts index 0a2cd719..e38803c3 100644 --- a/src/main/runtime/config-hot-reload-main-deps.ts +++ b/src/main/runtime/config-hot-reload-main-deps.ts @@ -75,6 +75,8 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { refreshSubtitlePrefetch?: () => void; refreshCurrentSubtitle?: () => void; setLogLevel?: (level: ResolvedConfig['logging']['level']) => void; + setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void; + setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void; }) { return () => ({ setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) => @@ -93,6 +95,10 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: { refreshSubtitlePrefetch: () => deps.refreshSubtitlePrefetch?.(), refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(), setLogLevel: (level: ResolvedConfig['logging']['level']) => deps.setLogLevel?.(level), + setLogRotation: (rotation: ResolvedConfig['logging']['rotation']) => + deps.setLogRotation?.(rotation), + setLogFileToggles: (files: ResolvedConfig['logging']['files']) => + deps.setLogFileToggles?.(files), }); } diff --git a/src/main/runtime/first-run-setup-plugin.test.ts b/src/main/runtime/first-run-setup-plugin.test.ts index 602701e9..a393a9e1 100644 --- a/src/main/runtime/first-run-setup-plugin.test.ts +++ b/src/main/runtime/first-run-setup-plugin.test.ts @@ -111,15 +111,15 @@ test('detectInstalledFirstRunPlugin ignores legacy loader file', () => { test('detectInstalledFirstRunPluginCandidates returns all legacy autoload entries without script opts', () => { withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const xdgConfigHome = path.join(root, 'xdg'); + const homeDir = path.posix.join(root, 'home'); + const xdgConfigHome = path.posix.join(root, 'xdg'); const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome); const directoryInstall = installPaths.pluginDir; - const legacyScript = path.join(installPaths.scriptsDir, 'subminer.lua'); - const legacyLoader = path.join(installPaths.scriptsDir, 'subminer-loader.lua'); + const legacyScript = path.posix.join(installPaths.scriptsDir, 'subminer.lua'); + const legacyLoader = path.posix.join(installPaths.scriptsDir, 'subminer-loader.lua'); fs.mkdirSync(directoryInstall, { recursive: true }); - fs.writeFileSync(path.join(directoryInstall, 'main.lua'), '-- plugin'); + fs.writeFileSync(path.posix.join(directoryInstall, 'main.lua'), '-- plugin'); fs.writeFileSync(legacyScript, '-- legacy plugin'); fs.writeFileSync(legacyLoader, '-- legacy loader'); fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true }); @@ -203,9 +203,15 @@ test('detectInstalledMpvPlugin prefers Windows portable plugin and parses versio test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without version', () => { withTempDir((root) => { - const homeDir = path.join(root, 'home'); - const legacyPath = path.join(homeDir, '.config', 'mpv', 'scripts', 'subminer-loader.lua'); - fs.mkdirSync(path.dirname(legacyPath), { recursive: true }); + const homeDir = path.posix.join(root, 'home'); + const legacyPath = path.posix.join( + homeDir, + '.config', + 'mpv', + 'scripts', + 'subminer-loader.lua', + ); + fs.mkdirSync(path.posix.dirname(legacyPath), { recursive: true }); fs.writeFileSync(legacyPath, '-- legacy'); const detection = detectInstalledMpvPlugin({ diff --git a/src/main/runtime/first-run-setup-service.test.ts b/src/main/runtime/first-run-setup-service.test.ts index 3a13e253..b71b03cc 100644 --- a/src/main/runtime/first-run-setup-service.test.ts +++ b/src/main/runtime/first-run-setup-service.test.ts @@ -56,7 +56,6 @@ function makeArgs(overrides: Partial = {}): CliArgs { openJimaku: false, openYoutubePicker: false, openPlaylistBrowser: false, - openCharacterDictionary: false, replayCurrentSubtitle: false, playNextSubtitle: false, shiftSubDelayPrevLine: false, diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts index cf2cfffc..e7101b9e 100644 --- a/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.test.ts @@ -44,7 +44,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => { source: null, message: null, }), - defaultMpvLogPath: '/tmp/mpv.log', + getDefaultMpvLogPath: () => '/tmp/mpv.log', defaultMpvArgs: ['--no-config'], removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`), spawnMpv: (args) => { @@ -61,7 +61,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => { assert.equal(deps.execPath, '/tmp/subminer'); assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua'); assert.equal(deps.getInstalledPluginDetection?.().installed, false); - assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log'); + assert.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log'); assert.deepEqual(deps.defaultMpvArgs, ['--no-config']); deps.removeSocketPath('/tmp/mpv.sock'); deps.spawnMpv(['--idle=yes']); diff --git a/src/main/runtime/jellyfin-remote-connection-main-deps.ts b/src/main/runtime/jellyfin-remote-connection-main-deps.ts index 4ec78d83..e5faa5cf 100644 --- a/src/main/runtime/jellyfin-remote-connection-main-deps.ts +++ b/src/main/runtime/jellyfin-remote-connection-main-deps.ts @@ -23,7 +23,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler( getRuntimePluginEntrypoint: deps.getRuntimePluginEntrypoint, getInstalledPluginDetection: deps.getInstalledPluginDetection, getPluginRuntimeConfig: deps.getPluginRuntimeConfig, - defaultMpvLogPath: deps.defaultMpvLogPath, + getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(), defaultMpvArgs: deps.defaultMpvArgs, removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath), spawnMpv: (args: string[]) => deps.spawnMpv(args), diff --git a/src/main/runtime/jellyfin-remote-connection.test.ts b/src/main/runtime/jellyfin-remote-connection.test.ts index 6e36eeb0..3ac69970 100644 --- a/src/main/runtime/jellyfin-remote-connection.test.ts +++ b/src/main/runtime/jellyfin-remote-connection.test.ts @@ -36,7 +36,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', ( execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner', getRuntimePluginEntrypoint: () => '/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua', - defaultMpvLogPath: '/tmp/mp.log', + getDefaultMpvLogPath: () => ' /tmp/mp.log ', defaultMpvArgs: ['--sid=auto'], removeSocketPath: () => {}, spawnMpv: (args) => { @@ -59,6 +59,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', ( '--script=/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua', ), ); + assert.ok(spawnedArgs[0]!.includes('--log-file=/tmp/mp.log')); assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock'))); assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback'))); }); @@ -81,7 +82,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf aniskipEnabled: true, aniskipButtonKey: 'F8', }), - defaultMpvLogPath: '/tmp/mp.log', + getDefaultMpvLogPath: () => '/tmp/mp.log', defaultMpvArgs: ['--sid=auto'], removeSocketPath: () => {}, spawnMpv: (args) => { @@ -123,7 +124,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when in source: 'default-config', message: null, }), - defaultMpvLogPath: '/tmp/mp.log', + getDefaultMpvLogPath: () => '/tmp/mp.log', defaultMpvArgs: ['--sid=auto'], removeSocketPath: () => {}, spawnMpv: (args) => { diff --git a/src/main/runtime/jellyfin-remote-connection.ts b/src/main/runtime/jellyfin-remote-connection.ts index 90e193f7..67162aa0 100644 --- a/src/main/runtime/jellyfin-remote-connection.ts +++ b/src/main/runtime/jellyfin-remote-connection.ts @@ -48,7 +48,7 @@ export type LaunchMpvForJellyfinDeps = { getRuntimePluginEntrypoint?: () => string | null | undefined; getInstalledPluginDetection?: () => InstalledMpvPluginDetection; getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig; - defaultMpvLogPath: string; + getDefaultMpvLogPath: () => string; defaultMpvArgs: readonly string[]; removeSocketPath: (socketPath: string) => void; spawnMpv: (args: string[]) => SpawnedProcessLike; @@ -85,13 +85,14 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor if (installedPlugin?.installed && installedPlugin.path) { deps.logInfo(`Using installed mpv plugin for Jellyfin playback: ${installedPlugin.path}`); } + const defaultMpvLogPath = deps.getDefaultMpvLogPath().trim(); const mpvArgs = [ ...deps.defaultMpvArgs, ...buildMpvLaunchModeArgs(deps.getLaunchMode()), ...(runtimePluginEntrypoint ? [`--script=${runtimePluginEntrypoint}`] : []), '--idle=yes', scriptOpts, - `--log-file=${deps.defaultMpvLogPath}`, + ...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []), `--input-ipc-server=${socketPath}`, ]; const proc = deps.spawnMpv(mpvArgs); diff --git a/src/main/runtime/log-export.test.ts b/src/main/runtime/log-export.test.ts new file mode 100644 index 00000000..854b9456 --- /dev/null +++ b/src/main/runtime/log-export.test.ts @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { writeStoredZip } from '../../shared/stored-zip'; +import { exportLogsArchive, maskUsernamesInLogText } from './log-export'; + +function makeTempDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-log-export-')); +} + +function cleanupDir(dirPath: string): void { + fs.rmSync(dirPath, { recursive: true, force: true }); +} + +function writeLog(logsDir: string, name: string, content: string, mtime: string): string { + const logPath = path.join(logsDir, name); + fs.writeFileSync(logPath, content, 'utf8'); + const date = new Date(mtime); + fs.utimesSync(logPath, date, date); + return logPath; +} + +function readStoredZipEntries(zipPath: string): Map { + const archive = fs.readFileSync(zipPath); + const entries = new Map(); + let cursor = 0; + + while (cursor + 4 <= archive.length) { + const signature = archive.readUInt32LE(cursor); + if (signature === 0x02014b50 || signature === 0x06054b50) { + break; + } + assert.equal(signature, 0x04034b50); + + const compressedSize = archive.readUInt32LE(cursor + 18); + const fileNameLength = archive.readUInt16LE(cursor + 26); + 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 data = archive.subarray(dataStart, dataStart + compressedSize); + entries.set(fileName, Buffer.from(data)); + cursor = dataStart + compressedSize; + } + + return entries; +} + +test('maskUsernamesInLogText redacts linux macOS and Windows home paths', () => { + const masked = maskUsernamesInLogText( + [ + '/home/kyle/.config/SubMiner', + '/Users/kyle/Library/Application Support/SubMiner', + 'C:\\Users\\kyle\\AppData\\Roaming\\SubMiner', + 'C:\\\\Users\\\\kyle\\\\AppData\\\\Roaming\\\\SubMiner', + ].join('\n'), + ); + + assert.match(masked, /\/home\/\/\.config/); + assert.match(masked, /\/Users\/\/Library/); + assert.match(masked, /C:\\Users\\\\AppData/); + assert.match(masked, /C:\\\\Users\\\\\\\\AppData/); + assert.doesNotMatch(masked, /kyle/); +}); + +test('exportLogsArchive exports current-day logs and masks usernames', () => { + const root = makeTempDir(); + const logsDir = path.join(root, 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + + try { + const currentLog = writeLog( + logsDir, + 'app-2026-W21.log', + 'opened /home/kyle/video.mkv and C:\\Users\\kyle\\AppData\\Roaming\\SubMiner\n', + '2026-05-26T12:00:00.000Z', + ); + writeLog(logsDir, 'launcher-2026-W20.log', 'old /Users/kyle/Library\n', '2026-05-20T12:00:00Z'); + + const result = exportLogsArchive({ + logsDir, + outputDir: root, + now: new Date('2026-05-26T16:00:00.000Z'), + }); + + assert.equal(result.mode, 'current-day'); + assert.deepEqual(result.exportedFiles, [currentLog]); + + const entries = readStoredZipEntries(result.zipPath); + assert.deepEqual([...entries.keys()], ['logs/app-2026-W21.log']); + const content = entries.get('logs/app-2026-W21.log')!.toString('utf8'); + assert.match(content, /\/home\/\/video\.mkv/); + assert.match(content, /C:\\Users\\\\AppData/); + assert.doesNotMatch(content, /kyle/); + } finally { + cleanupDir(root); + } +}); + +test('writeStoredZip rejects names outside ZIP32 limits', () => { + const dir = makeTempDir(); + const outputPath = path.join(dir, 'logs.zip'); + + try { + assert.throws( + () => + writeStoredZip(outputPath, [ + { + name: `${'a'.repeat(0x10000)}.log`, + data: Buffer.from('log\n', 'utf8'), + }, + ]), + /ZIP entry name too long/, + ); + assert.equal(fs.existsSync(outputPath), false); + } finally { + cleanupDir(dir); + } +}); + +test('exportLogsArchive ignores older dated logs when current-day dated logs exist', () => { + const root = makeTempDir(); + const logsDir = path.join(root, 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + + try { + const currentLog = writeLog( + logsDir, + 'app-2026-05-25.log', + 'current day\n', + '2026-05-25T18:00:00Z', + ); + writeLog(logsDir, 'app-2026-05-24.log', 'previous day touched today\n', '2026-05-25T18:00:00Z'); + + const result = exportLogsArchive({ + logsDir, + outputDir: root, + now: new Date('2026-05-25T20:00:00.000Z'), + }); + + assert.equal(result.mode, 'current-day'); + assert.deepEqual(result.exportedFiles, [currentLog]); + + const entries = readStoredZipEntries(result.zipPath); + assert.deepEqual([...entries.keys()], ['logs/app-2026-05-25.log']); + } finally { + cleanupDir(root); + } +}); + +test('exportLogsArchive falls back to newest log per kind', () => { + const root = makeTempDir(); + const logsDir = path.join(root, 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + + try { + writeLog(logsDir, 'app-2026-W18.log', 'older app\n', '2026-05-01T12:00:00Z'); + const appLog = writeLog(logsDir, 'app-2026-W19.log', 'newer app\n', '2026-05-12T12:00:00Z'); + const mpvLog = writeLog(logsDir, 'mpv-2026-W17.log', 'latest mpv\n', '2026-05-10T12:00:00Z'); + const launcherLog = writeLog( + logsDir, + 'launcher-2026-W16.log', + 'latest launcher\n', + '2026-05-09T12:00:00Z', + ); + + const result = exportLogsArchive({ + logsDir, + outputDir: root, + now: new Date('2026-05-26T16:00:00.000Z'), + }); + + assert.equal(result.mode, 'most-recent'); + assert.deepEqual(result.exportedFiles.sort(), [appLog, launcherLog, mpvLog].sort()); + + const entries = readStoredZipEntries(result.zipPath); + assert.deepEqual([...entries.keys()].sort(), [ + 'logs/app-2026-W19.log', + 'logs/launcher-2026-W16.log', + 'logs/mpv-2026-W17.log', + ]); + } finally { + cleanupDir(root); + } +}); + +test('exportLogsArchive fails when no logs exist', () => { + const root = makeTempDir(); + const logsDir = path.join(root, 'logs'); + fs.mkdirSync(logsDir, { recursive: true }); + + try { + assert.throws( + () => exportLogsArchive({ logsDir, outputDir: root }), + /No SubMiner log files found/, + ); + } finally { + cleanupDir(root); + } +}); diff --git a/src/main/runtime/log-export.ts b/src/main/runtime/log-export.ts new file mode 100644 index 00000000..b1c06e14 --- /dev/null +++ b/src/main/runtime/log-export.ts @@ -0,0 +1,184 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { resolveLogBaseDir } from '../../shared/log-files'; +import { writeStoredZip } from '../../shared/stored-zip'; + +type LogCandidate = { + path: string; + name: string; + kind: string; + mtimeMs: number; + mtimeDateKey: string; + fileDateKey: string | null; +}; + +export type ExportLogsResult = { + zipPath: string; + exportedFiles: string[]; + mode: 'current-day' | 'most-recent'; +}; + +export type ExportLogsOptions = { + platform?: NodeJS.Platform; + homeDir?: string; + appDataDir?: string; + logsDir?: string; + outputDir?: string; + now?: Date; +}; + +const REDACTED_USER = ''; + +function pad(value: number): string { + return String(value).padStart(2, '0'); +} + +function localDateKey(date: Date): string { + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} + +function filenameDateKey(fileName: string): string | null { + return fileName.match(/\d{4}-\d{2}-\d{2}/)?.[0] ?? null; +} + +function fileKind(fileName: string): string { + const match = fileName.match(/^([A-Za-z0-9_-]+)-/); + return match?.[1] ?? fileName; +} + +function zipTimestamp(date: Date): string { + return `${localDateKey(date)}-${pad(date.getHours())}${pad(date.getMinutes())}${pad( + date.getSeconds(), + )}`; +} + +function resolveLogsDir(options: ExportLogsOptions): string { + if (options.logsDir) return options.logsDir; + return path.join( + resolveLogBaseDir({ + platform: options.platform, + homeDir: options.homeDir, + appDataDir: options.appDataDir, + }), + 'logs', + ); +} + +function buildCandidate(logsDir: string, entry: string): LogCandidate | null { + if (!entry.endsWith('.log')) return null; + + const candidatePath = path.join(logsDir, entry); + let stats: fs.Stats; + try { + stats = fs.statSync(candidatePath); + } catch { + return null; + } + if (!stats.isFile()) return null; + + return { + path: candidatePath, + name: entry, + kind: fileKind(entry), + mtimeMs: stats.mtimeMs, + mtimeDateKey: localDateKey(stats.mtime), + fileDateKey: filenameDateKey(entry), + }; +} + +function listLogCandidates(logsDir: string): LogCandidate[] { + let entries: string[]; + try { + entries = fs.readdirSync(logsDir); + } catch { + return []; + } + + return entries + .map((entry) => buildCandidate(logsDir, entry)) + .filter((entry): entry is LogCandidate => entry !== null) + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function selectMostRecentPerKind(candidates: LogCandidate[]): LogCandidate[] { + const byKind = new Map(); + for (const candidate of [...candidates].sort( + (left, right) => candidateFreshnessMs(right) - candidateFreshnessMs(left), + )) { + if (!byKind.has(candidate.kind)) { + byKind.set(candidate.kind, candidate); + } + } + return [...byKind.values()].sort((left, right) => left.name.localeCompare(right.name)); +} + +function candidateFreshnessMs(candidate: LogCandidate): number { + if (candidate.fileDateKey) { + return Date.parse(`${candidate.fileDateKey}T23:59:59.999Z`); + } + return candidate.mtimeMs; +} + +function selectLogCandidates( + candidates: LogCandidate[], + now: Date, +): { mode: ExportLogsResult['mode']; selected: LogCandidate[] } { + const today = localDateKey(now); + const currentDated = candidates.filter((candidate) => candidate.fileDateKey === today); + if (currentDated.length > 0) { + return { mode: 'current-day', selected: currentDated }; + } + + const currentUndated = candidates.filter( + (candidate) => candidate.fileDateKey === null && candidate.mtimeDateKey === today, + ); + if (currentUndated.length > 0) { + return { mode: 'current-day', selected: currentUndated }; + } + return { mode: 'most-recent', selected: selectMostRecentPerKind(candidates) }; +} + +export function maskUsernamesInLogText(text: string): string { + return text + .replace(/(\/(?:home|Users)\/)([^/\r\n]+)(?=\/|$)/g, `$1${REDACTED_USER}`) + .replace(/([A-Za-z]:[\\/]+Users[\\/]+)([^\\/:\r\n]+)(?=[\\/]|$)/g, `$1${REDACTED_USER}`); +} + +export function exportLogsArchive(options: ExportLogsOptions = {}): ExportLogsResult { + const now = options.now ?? new Date(); + const logsDir = resolveLogsDir(options); + const outputDir = options.outputDir ?? logsDir; + const candidates = listLogCandidates(logsDir); + const { mode, selected } = selectLogCandidates(candidates, now); + + if (selected.length === 0) { + throw new Error(`No SubMiner log files found in ${logsDir}`); + } + + fs.mkdirSync(outputDir, { recursive: true }); + const zipPath = path.join(outputDir, `subminer-logs-${zipTimestamp(now)}.zip`); + + writeStoredZip( + zipPath, + selected.map((candidate) => ({ + name: `logs/${candidate.name}`, + data: Buffer.from(maskUsernamesInLogText(fs.readFileSync(candidate.path, 'utf8')), 'utf8'), + })), + ); + + return { + zipPath, + exportedFiles: selected.map((candidate) => candidate.path), + mode, + }; +} + +export function exportLogsArchiveForCurrentUser(now: Date = new Date()): ExportLogsResult { + return exportLogsArchive({ + platform: process.platform, + homeDir: os.homedir(), + appDataDir: process.env.APPDATA, + now, + }); +} diff --git a/src/main/runtime/mpv-osd-log-main-deps.test.ts b/src/main/runtime/mpv-osd-log-main-deps.test.ts index a8b630a4..52e3d426 100644 --- a/src/main/runtime/mpv-osd-log-main-deps.test.ts +++ b/src/main/runtime/mpv-osd-log-main-deps.test.ts @@ -8,7 +8,7 @@ import { test('append to mpv log main deps map filesystem functions and log path', async () => { const calls: string[] = []; const deps = createBuildAppendToMpvLogMainDepsHandler({ - logPath: '/tmp/mpv.log', + getLogPath: () => '/tmp/mpv.log', dirname: (targetPath) => { calls.push(`dirname:${targetPath}`); return '/tmp'; @@ -22,7 +22,7 @@ test('append to mpv log main deps map filesystem functions and log path', async now: () => new Date('2026-02-20T00:00:00.000Z'), })(); - assert.equal(deps.logPath, '/tmp/mpv.log'); + assert.equal(deps.getLogPath(), '/tmp/mpv.log'); assert.equal(deps.dirname('/tmp/mpv.log'), '/tmp'); await deps.mkdir('/tmp', { recursive: true }); await deps.appendFile('/tmp/mpv.log', 'line', { encoding: 'utf8' }); diff --git a/src/main/runtime/mpv-osd-log-main-deps.ts b/src/main/runtime/mpv-osd-log-main-deps.ts index c3816d0b..b7ac773b 100644 --- a/src/main/runtime/mpv-osd-log-main-deps.ts +++ b/src/main/runtime/mpv-osd-log-main-deps.ts @@ -5,7 +5,7 @@ type ShowMpvOsdMainDeps = Parameters[0]; export function createBuildAppendToMpvLogMainDepsHandler(deps: AppendToMpvLogMainDeps) { return (): AppendToMpvLogMainDeps => ({ - logPath: deps.logPath, + getLogPath: () => deps.getLogPath(), dirname: (targetPath: string) => deps.dirname(targetPath), mkdir: (targetPath: string, options: { recursive: boolean }) => deps.mkdir(targetPath, options), appendFile: (targetPath: string, data: string, options: { encoding: 'utf8' }) => diff --git a/src/main/runtime/mpv-osd-log.test.ts b/src/main/runtime/mpv-osd-log.test.ts index 7653a6f7..e999b4e9 100644 --- a/src/main/runtime/mpv-osd-log.test.ts +++ b/src/main/runtime/mpv-osd-log.test.ts @@ -5,7 +5,7 @@ import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd- test('append mpv log writes timestamped message', () => { const writes: string[] = []; const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({ - logPath: '/tmp/subminer/mpv.log', + getLogPath: () => '/tmp/subminer/mpv.log', dirname: (targetPath: string) => { writes.push(`dirname:${targetPath}`); return '/tmp/subminer'; @@ -29,10 +29,31 @@ test('append mpv log writes timestamped message', () => { }); }); +test('append mpv log observes path changes', async () => { + const writes: string[] = []; + let logPath = ''; + const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({ + getLogPath: () => logPath, + dirname: () => '/tmp/subminer', + mkdir: async () => {}, + appendFile: async (targetPath: string, data: string) => { + writes.push(`${targetPath}:${data.trimEnd()}`); + }, + now: () => new Date('2026-02-20T00:00:00.000Z'), + }); + + appendToMpvLog('disabled'); + logPath = '/tmp/subminer/mpv.log'; + appendToMpvLog('enabled'); + await flushMpvLog(); + + assert.deepEqual(writes, ['/tmp/subminer/mpv.log:[2026-02-20T00:00:00.000Z] enabled']); +}); + test('append mpv log queues multiple messages and flush waits for pending write', async () => { const writes: string[] = []; const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({ - logPath: '/tmp/subminer/mpv.log', + getLogPath: () => '/tmp/subminer/mpv.log', dirname: () => '/tmp/subminer', mkdir: async () => { writes.push('mkdir'); @@ -76,7 +97,7 @@ test('append mpv log queues multiple messages and flush waits for pending write' test('append mpv log swallows async filesystem errors', async () => { const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({ - logPath: '/tmp/subminer/mpv.log', + getLogPath: () => '/tmp/subminer/mpv.log', dirname: () => '/tmp/subminer', mkdir: async () => { throw new Error('disk error'); diff --git a/src/main/runtime/mpv-osd-log.ts b/src/main/runtime/mpv-osd-log.ts index 040e7126..344ab394 100644 --- a/src/main/runtime/mpv-osd-log.ts +++ b/src/main/runtime/mpv-osd-log.ts @@ -1,7 +1,7 @@ import type { MpvRuntimeClientLike } from '../../core/services/mpv'; export function createAppendToMpvLogHandler(deps: { - logPath: string; + getLogPath: () => string; dirname: (targetPath: string) => string; mkdir: (targetPath: string, options: { recursive: boolean }) => Promise; appendFile: (targetPath: string, data: string, options: { encoding: 'utf8' }) => Promise; @@ -13,9 +13,13 @@ export function createAppendToMpvLogHandler(deps: { const drainPendingLines = async (): Promise => { while (pendingLines.length > 0) { const chunk = pendingLines.splice(0, pendingLines.length).join(''); + const logPath = deps.getLogPath(); + if (!logPath.trim()) { + continue; + } try { - await deps.mkdir(deps.dirname(deps.logPath), { recursive: true }); - await deps.appendFile(deps.logPath, chunk, { encoding: 'utf8' }); + await deps.mkdir(deps.dirname(logPath), { recursive: true }); + await deps.appendFile(logPath, chunk, { encoding: 'utf8' }); } catch { // best-effort logging } @@ -35,6 +39,9 @@ export function createAppendToMpvLogHandler(deps: { }; const appendToMpvLog = (message: string): void => { + if (!deps.getLogPath().trim()) { + return; + } pendingLines.push(`[${deps.now().toISOString()}] ${message}\n`); void scheduleDrain(); }; diff --git a/src/main/runtime/mpv-osd-runtime-handlers.test.ts b/src/main/runtime/mpv-osd-runtime-handlers.test.ts index a9877f70..df7bce50 100644 --- a/src/main/runtime/mpv-osd-runtime-handlers.test.ts +++ b/src/main/runtime/mpv-osd-runtime-handlers.test.ts @@ -6,7 +6,7 @@ test('mpv osd runtime handlers compose append and osd logging flow', async () => const calls: string[] = []; const runtime = createMpvOsdRuntimeHandlers({ appendToMpvLogMainDeps: { - logPath: '/tmp/subminer/mpv.log', + getLogPath: () => '/tmp/subminer/mpv.log', dirname: () => '/tmp/subminer', mkdir: async () => {}, appendFile: async (_targetPath: string, data: string) => { diff --git a/src/main/runtime/mpv-track-diagnostics.test.ts b/src/main/runtime/mpv-track-diagnostics.test.ts new file mode 100644 index 00000000..abdd68f8 --- /dev/null +++ b/src/main/runtime/mpv-track-diagnostics.test.ts @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { buildSubtitleTrackDiagnostics } from './mpv-track-diagnostics'; + +test('buildSubtitleTrackDiagnostics summarizes subtitle tracks without dumping track list', () => { + const diagnostics = buildSubtitleTrackDiagnostics(3, [ + { type: 'video', id: 1, selected: true }, + { type: 'sub', id: 3, lang: 'ja', selected: true, external: false, codec: 'ass' }, + { type: 'sub', id: '4', title: 'English', external: true, codec: 'srt' }, + { type: 'audio', id: 2, lang: 'jpn' }, + ]); + + assert.deepEqual(diagnostics, { + trackListReadable: true, + trackCount: 4, + subtitleTrackCount: 2, + activePrimarySid: 3, + selectedSubtitleIds: [3], + externalSubtitleCount: 1, + internalSubtitleCount: 1, + languages: ['ja'], + selectedSubtitleLabels: ['internal#3:ja'], + }); +}); + +test('buildSubtitleTrackDiagnostics marks unreadable track list', () => { + assert.deepEqual(buildSubtitleTrackDiagnostics(null, null), { + trackListReadable: false, + trackCount: 0, + subtitleTrackCount: 0, + activePrimarySid: null, + selectedSubtitleIds: [], + externalSubtitleCount: 0, + internalSubtitleCount: 0, + languages: [], + selectedSubtitleLabels: [], + }); +}); diff --git a/src/main/runtime/mpv-track-diagnostics.ts b/src/main/runtime/mpv-track-diagnostics.ts new file mode 100644 index 00000000..2fab906a --- /dev/null +++ b/src/main/runtime/mpv-track-diagnostics.ts @@ -0,0 +1,113 @@ +type MpvTrackDiagnosticEntry = { + id: number | null; + type: string | null; + selected: boolean; + external: boolean; + lang: string | null; + title: string | null; + codec: string | null; +}; + +export type SubtitleTrackDiagnostics = { + trackListReadable: boolean; + trackCount: number; + subtitleTrackCount: number; + activePrimarySid: number | null; + selectedSubtitleIds: number[]; + externalSubtitleCount: number; + internalSubtitleCount: number; + languages: string[]; + selectedSubtitleLabels: string[]; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value && typeof value === 'object'); +} + +function parseTrackId(value: unknown): number | null { + if (typeof value === 'number' && Number.isInteger(value)) { + return value; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed.length || trimmed === 'no' || trimmed === 'auto') { + return null; + } + const parsed = Number(trimmed); + if (Number.isInteger(parsed)) { + return parsed; + } + } + return null; +} + +function readString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function normalizeTrack(track: unknown): MpvTrackDiagnosticEntry | null { + if (!isRecord(track)) { + return null; + } + + return { + id: parseTrackId(track.id), + type: readString(track.type), + selected: track.selected === true, + external: track.external === true, + lang: readString(track.lang), + title: readString(track.title), + codec: readString(track.codec), + }; +} + +function formatSubtitleTrackLabel(track: MpvTrackDiagnosticEntry): string { + const id = track.id === null ? '?' : String(track.id); + const source = track.external ? 'external' : 'internal'; + const label = track.lang ?? track.title ?? track.codec ?? 'unknown'; + return `${source}#${id}:${label}`; +} + +export function buildSubtitleTrackDiagnostics( + activePrimarySid: number | null, + trackList: unknown[] | null, +): SubtitleTrackDiagnostics { + if (!Array.isArray(trackList)) { + return { + trackListReadable: false, + trackCount: 0, + subtitleTrackCount: 0, + activePrimarySid, + selectedSubtitleIds: [], + externalSubtitleCount: 0, + internalSubtitleCount: 0, + languages: [], + selectedSubtitleLabels: [], + }; + } + + const normalizedTracks = trackList.map(normalizeTrack).filter((track) => track !== null); + const subtitleTracks = normalizedTracks.filter((track) => track.type === 'sub'); + const selectedSubtitleTracks = subtitleTracks.filter((track) => track.selected); + const languages = Array.from( + new Set( + subtitleTracks + .map((track) => track.lang) + .filter((language): language is string => language !== null), + ), + ).sort((left, right) => left.localeCompare(right)); + + return { + trackListReadable: true, + trackCount: normalizedTracks.length, + subtitleTrackCount: subtitleTracks.length, + activePrimarySid, + selectedSubtitleIds: selectedSubtitleTracks + .map((track) => track.id) + .filter((id): id is number => id !== null), + externalSubtitleCount: subtitleTracks.filter((track) => track.external).length, + internalSubtitleCount: subtitleTracks.filter((track) => !track.external).length, + languages, + selectedSubtitleLabels: selectedSubtitleTracks.map(formatSubtitleTrackLabel), + }; +} diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts index c46b6f6c..ff33777b 100644 --- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.test.ts @@ -16,7 +16,6 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call isOverlayShortcutContextActive: () => false, showMpvOsd: (text) => calls.push(`osd:${text}`), openRuntimeOptionsPalette: () => calls.push('runtime-options'), - openCharacterDictionary: () => calls.push('character-dictionary'), openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'), openJimaku: () => calls.push('jimaku'), markAudioCard: async () => { @@ -49,7 +48,6 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call assert.equal(shortcutsRegistered, true); deps.showMpvOsd('x'); deps.openRuntimeOptionsPalette(); - deps.openCharacterDictionary(); deps.openCharacterDictionaryManager(); deps.openJimaku(); await deps.markAudioCard(); @@ -67,7 +65,6 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call 'registered:true', 'osd:x', 'runtime-options', - 'character-dictionary', 'character-dictionary-manager', 'jimaku', 'mark-audio', diff --git a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts index 2cd10262..726326c2 100644 --- a/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts +++ b/src/main/runtime/overlay-shortcuts-runtime-main-deps.ts @@ -11,7 +11,6 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler( isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true, showMpvOsd: (text: string) => deps.showMpvOsd(text), openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(), - openCharacterDictionary: () => deps.openCharacterDictionary(), openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(), openJimaku: () => deps.openJimaku(), markAudioCard: () => deps.markAudioCard(), diff --git a/src/main/runtime/subtitle-tokenization-main-deps.test.ts b/src/main/runtime/subtitle-tokenization-main-deps.test.ts index 24b19b09..52455932 100644 --- a/src/main/runtime/subtitle-tokenization-main-deps.test.ts +++ b/src/main/runtime/subtitle-tokenization-main-deps.test.ts @@ -64,7 +64,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () => assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']); }); -test('tokenizer deps builder disables name matching when character dictionary is disabled', () => { +test('tokenizer deps builder disables name matching when character dictionary runtime is disabled', () => { const deps = createBuildTokenizerDepsMainHandler({ getYomitanExt: () => null, getYomitanParserWindow: () => null, diff --git a/src/main/runtime/tray-main-actions.test.ts b/src/main/runtime/tray-main-actions.test.ts index ab374918..083a0de1 100644 --- a/src/main/runtime/tray-main-actions.test.ts +++ b/src/main/runtime/tray-main-actions.test.ts @@ -50,6 +50,7 @@ test('build tray template handler wires actions and init guards', () => { handlers.openWindowsMpvLauncherSetup(); handlers.openYomitanSettings(); handlers.openConfigSettings(); + handlers.exportLogs(); handlers.openJellyfinSetup(); handlers.toggleJellyfinDiscovery(true); handlers.openAnilistSetup(); @@ -70,6 +71,7 @@ test('build tray template handler wires actions and init guards', () => { showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), + exportLogs: () => calls.push('export-logs'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, isJellyfinDiscoveryActive: () => false, @@ -94,6 +96,7 @@ test('build tray template handler wires actions and init guards', () => { 'setup-forced', 'yomitan', 'configuration', + 'export-logs', 'jellyfin', 'jellyfin-discovery:true', 'anilist', @@ -121,6 +124,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => { showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), + exportLogs: () => calls.push('export-logs'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => false, isJellyfinDiscoveryActive: () => false, diff --git a/src/main/runtime/tray-main-actions.ts b/src/main/runtime/tray-main-actions.ts index 5f45a88f..413a9de8 100644 --- a/src/main/runtime/tray-main-actions.ts +++ b/src/main/runtime/tray-main-actions.ts @@ -47,6 +47,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; openConfigSettings: () => void; + exportLogs: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; @@ -65,6 +66,7 @@ export function createBuildTrayMenuTemplateHandler(deps: { showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; + exportLogs: () => void; openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; isJellyfinDiscoveryActive: () => boolean; @@ -101,6 +103,9 @@ export function createBuildTrayMenuTemplateHandler(deps: { openConfigSettings: () => { deps.openConfigSettingsWindow(); }, + exportLogs: () => { + deps.exportLogs(); + }, openJellyfinSetup: () => { deps.openJellyfinSetupWindow(); }, diff --git a/src/main/runtime/tray-main-deps.test.ts b/src/main/runtime/tray-main-deps.test.ts index a996fcc4..5632313f 100644 --- a/src/main/runtime/tray-main-deps.test.ts +++ b/src/main/runtime/tray-main-deps.test.ts @@ -32,6 +32,7 @@ test('tray main deps builders return mapped handlers', () => { showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettingsWindow: () => calls.push('configuration'), + exportLogs: () => calls.push('export-logs'), openJellyfinSetupWindow: () => calls.push('jellyfin'), isJellyfinConfigured: () => true, isJellyfinDiscoveryActive: () => false, @@ -56,6 +57,7 @@ test('tray main deps builders return mapped handlers', () => { showWindowsMpvLauncherSetup: true, openYomitanSettings: () => calls.push('open-yomitan'), openConfigSettings: () => calls.push('open-configuration'), + exportLogs: () => calls.push('open-export-logs'), openJellyfinSetup: () => calls.push('open-jellyfin'), showJellyfinDiscovery: true, jellyfinDiscoveryActive: false, diff --git a/src/main/runtime/tray-main-deps.ts b/src/main/runtime/tray-main-deps.ts index 4d4c43c8..2d21afb9 100644 --- a/src/main/runtime/tray-main-deps.ts +++ b/src/main/runtime/tray-main-deps.ts @@ -37,6 +37,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; openConfigSettings: () => void; + exportLogs: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; @@ -55,6 +56,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showWindowsMpvLauncherSetup: () => boolean; openYomitanSettings: () => void; openConfigSettingsWindow: () => void; + exportLogs: () => void; openJellyfinSetupWindow: () => void; isJellyfinConfigured: () => boolean; isJellyfinDiscoveryActive: () => boolean; @@ -77,6 +79,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler(deps: { showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup, openYomitanSettings: deps.openYomitanSettings, openConfigSettingsWindow: deps.openConfigSettingsWindow, + exportLogs: deps.exportLogs, openJellyfinSetupWindow: deps.openJellyfinSetupWindow, isJellyfinConfigured: deps.isJellyfinConfigured, isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive, diff --git a/src/main/runtime/tray-runtime-handlers.test.ts b/src/main/runtime/tray-runtime-handlers.test.ts index db02249f..f1de38ba 100644 --- a/src/main/runtime/tray-runtime-handlers.test.ts +++ b/src/main/runtime/tray-runtime-handlers.test.ts @@ -32,6 +32,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () => showWindowsMpvLauncherSetup: () => true, openYomitanSettings: () => {}, openConfigSettingsWindow: () => {}, + exportLogs: () => {}, openJellyfinSetupWindow: () => {}, isJellyfinConfigured: () => false, isJellyfinDiscoveryActive: () => false, diff --git a/src/main/runtime/tray-runtime.test.ts b/src/main/runtime/tray-runtime.test.ts index 42099088..c104d065 100644 --- a/src/main/runtime/tray-runtime.test.ts +++ b/src/main/runtime/tray-runtime.test.ts @@ -38,6 +38,7 @@ test('tray menu template contains expected entries and handlers', () => { showWindowsMpvLauncherSetup: true, openYomitanSettings: () => calls.push('yomitan'), openConfigSettings: () => calls.push('configuration'), + exportLogs: () => calls.push('export-logs'), openJellyfinSetup: () => calls.push('jellyfin'), showJellyfinDiscovery: true, jellyfinDiscoveryActive: false, @@ -47,7 +48,7 @@ test('tray menu template contains expected entries and handlers', () => { quitApp: () => calls.push('quit'), }); - assert.equal(template.length, 12); + assert.equal(template.length, 13); assert.equal( template.some((entry) => entry.label === 'Open Runtime Options'), false, @@ -66,14 +67,17 @@ test('tray menu template contains expected entries and handlers', () => { assert.equal(template[1]!.label, 'Open Texthooker'); template[1]!.click?.(); assert.equal(template[5]!.label, 'Open SubMiner Settings'); - assert.equal(template[9]!.label, 'Check for Updates'); - template[9]!.click?.(); - template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); - template[11]!.click?.(); + assert.equal(template[6]!.label, 'Export Logs'); + template[6]!.click?.(); + assert.equal(template[10]!.label, 'Check for Updates'); + template[10]!.click?.(); + template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad'); + template[12]!.click?.(); assert.deepEqual(calls, [ 'jellyfin-discovery:true', 'help', 'texthooker', + 'export-logs', 'updates', 'separator', 'quit', @@ -91,6 +95,7 @@ test('tray menu template omits first-run setup entry when setup is complete', () showWindowsMpvLauncherSetup: false, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, + exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: false, jellyfinDiscoveryActive: false, @@ -118,6 +123,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled showWindowsMpvLauncherSetup: false, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, + exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: false, jellyfinDiscoveryActive: false, @@ -143,6 +149,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => { showWindowsMpvLauncherSetup: false, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, + exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: true, jellyfinDiscoveryActive: true, @@ -169,6 +176,7 @@ test('tray menu template renders a visible linux discovery check mark when activ showWindowsMpvLauncherSetup: false, openYomitanSettings: () => undefined, openConfigSettings: () => undefined, + exportLogs: () => undefined, openJellyfinSetup: () => undefined, showJellyfinDiscovery: true, jellyfinDiscoveryActive: true, diff --git a/src/main/runtime/tray-runtime.ts b/src/main/runtime/tray-runtime.ts index 0ed98056..7308cbb2 100644 --- a/src/main/runtime/tray-runtime.ts +++ b/src/main/runtime/tray-runtime.ts @@ -40,6 +40,7 @@ export type TrayMenuActionHandlers = { showWindowsMpvLauncherSetup: boolean; openYomitanSettings: () => void; openConfigSettings: () => void; + exportLogs: () => void; openJellyfinSetup: () => void; showJellyfinDiscovery: boolean; jellyfinDiscoveryActive: boolean; @@ -102,6 +103,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers): label: 'Open SubMiner Settings', click: handlers.openConfigSettings, }, + { + label: 'Export Logs', + click: handlers.exportLogs, + }, { label: 'Configure Jellyfin', click: handlers.openJellyfinSetup, diff --git a/src/main/runtime/update/appimage-updater.ts b/src/main/runtime/update/appimage-updater.ts index 60a43d17..337efc4e 100644 --- a/src/main/runtime/update/appimage-updater.ts +++ b/src/main/runtime/update/appimage-updater.ts @@ -139,9 +139,10 @@ export async function updateAppImageFromRelease(options: { }; } - const tempPath = path.join( - path.dirname(options.appImagePath), - `.${path.basename(options.appImagePath)}.update`, + const appImagePathApi = options.appImagePath.startsWith('/') ? path.posix : path; + const tempPath = appImagePathApi.join( + appImagePathApi.dirname(options.appImagePath), + `.${appImagePathApi.basename(options.appImagePath)}.update`, ); try { await fsDeps.writeFile(tempPath, data); diff --git a/src/main/runtime/update/support-assets.test.ts b/src/main/runtime/update/support-assets.test.ts index 24a30492..df9da57e 100644 --- a/src/main/runtime/update/support-assets.test.ts +++ b/src/main/runtime/update/support-assets.test.ts @@ -63,7 +63,7 @@ test('buildProtectedSupportAssetsCommand cleans up temporary extraction director test('updateSupportAssetsFromRelease updates only the Linux rofi theme', async () => { const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-')); - const dataDir = path.join(xdgDataHome, 'SubMiner'); + const dataDir = path.posix.join(xdgDataHome, 'SubMiner'); fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true }); fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true }); fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n'); diff --git a/src/main/runtime/update/support-assets.ts b/src/main/runtime/update/support-assets.ts index da7dba1e..bb462a54 100644 --- a/src/main/runtime/update/support-assets.ts +++ b/src/main/runtime/update/support-assets.ts @@ -30,8 +30,12 @@ export function detectSupportAssetDataDirs(options: { xdgDataHome?: string; }): string[] { if (options.platform === 'linux') { - const xdgDataHome = options.xdgDataHome || path.join(options.homeDir, '.local/share'); - return [path.join(xdgDataHome, 'SubMiner'), '/usr/local/share/SubMiner', '/usr/share/SubMiner']; + const xdgDataHome = options.xdgDataHome || path.posix.join(options.homeDir, '.local/share'); + return [ + path.posix.join(xdgDataHome, 'SubMiner'), + '/usr/local/share/SubMiner', + '/usr/share/SubMiner', + ]; } return []; } diff --git a/src/main/runtime/windows-mpv-launch.test.ts b/src/main/runtime/windows-mpv-launch.test.ts index c2998411..75129b40 100644 --- a/src/main/runtime/windows-mpv-launch.test.ts +++ b/src/main/runtime/windows-mpv-launch.test.ts @@ -269,11 +269,13 @@ test('launchWindowsMpv reports missing mpv path', async () => { test('launchWindowsMpv spawns detached mpv with targets', async () => { const calls: string[] = []; + const logs: string[] = []; const result = await launchWindowsMpv( ['C:\\video.mkv'], createDeps({ getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined), fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe', + logInfo: (message) => logs.push(message), spawnDetached: async (command, args) => { calls.push(command); calls.push(args.join('|')); @@ -290,6 +292,59 @@ test('launchWindowsMpv spawns detached mpv with targets', async () => { 'C:\\mpv\\mpv.exe', '--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--sub-visibility=no|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv', ]); + assert.match(logs[0] ?? '', /mpvPath=C:\\mpv\\mpv\.exe/); + assert.match(logs[0] ?? '', /inputIpcServer=\\\\\.\\pipe\\subminer-socket/); + assert.match( + logs[0] ?? '', + /bundledPlugin=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main\.lua/, + ); + assert.match(logs[0] ?? '', /installedPlugin=none/); +}); + +test('launchWindowsMpv forwards runtime logging config to mpv and plugin', async () => { + const calls: string[] = []; + const result = await launchWindowsMpv( + ['C:\\video.mkv'], + createDeps({ + getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined), + fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe', + spawnDetached: async (command, args, env) => { + calls.push(command); + calls.push(args.join('|')); + calls.push(env?.SUBMINER_LOG_LEVEL ?? ''); + calls.push(env?.SUBMINER_LOG_ROTATION ?? ''); + }, + }), + ['--log-file=C:\\Users\\tester\\AppData\\Roaming\\SubMiner\\logs\\mpv-2026-05-26.log'], + 'C:\\SubMiner\\SubMiner.exe', + 'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua', + '', + 'normal', + undefined, + { + socketPath: '\\\\.\\pipe\\subminer-socket', + binaryPath: '', + backend: 'windows', + logLevel: 'debug', + logRotation: 0, + autoStart: true, + autoStartVisibleOverlay: false, + autoStartPauseUntilReady: true, + texthookerEnabled: false, + aniskipEnabled: true, + aniskipButtonKey: 'TAB', + }, + ); + + assert.equal(result.ok, true); + assert.match(calls[1] ?? '', /--msg-level=all=warn,subminer=debug/); + assert.doesNotMatch(calls[1] ?? '', /subminer-log_level=debug/); + assert.match( + calls[1] ?? '', + /--log-file=C:\\Users\\tester\\AppData\\Roaming\\SubMiner\\logs\\mpv-2026-05-26\.log/, + ); + assert.equal(calls[2], 'debug'); + assert.equal(calls[3], '0'); }); test('launchWindowsMpv skips bundled script when installed plugin is detected', async () => { diff --git a/src/main/runtime/windows-mpv-launch.ts b/src/main/runtime/windows-mpv-launch.ts index 95552008..48cef34b 100644 --- a/src/main/runtime/windows-mpv-launch.ts +++ b/src/main/runtime/windows-mpv-launch.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; import { spawn, spawnSync } from 'node:child_process'; +import { isLogFileEnabled } from '../../shared/log-files'; import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode'; +import { buildMpvMsgLevel } from '../../shared/mpv-logging-args'; import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts'; import type { MpvLaunchMode } from '../../types/config'; import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts'; @@ -10,8 +12,9 @@ export interface WindowsMpvLaunchDeps { getEnv: (name: string) => string | undefined; runWhere: () => { status: number | null; stdout: string; error?: Error }; fileExists: (candidate: string) => boolean; - spawnDetached: (command: string, args: string[]) => Promise; + spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise; showError: (title: string, content: string) => void; + logInfo?: (message: string) => void; } export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid'; @@ -126,6 +129,13 @@ export function buildWindowsMpvLaunchArgs( : shouldPassSubminerScriptOpts ? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`] : []; + const logLevel = pluginRuntimeConfig?.logLevel; + const hasMsgLevel = readExtraArgValue(extraArgs, '--msg-level') !== undefined; + const hasLogFile = readExtraArgValue(extraArgs, '--log-file') !== undefined; + const mpvLogLevelArg = + logLevel && !hasMsgLevel && (isLogFileEnabled('mpv') || hasLogFile) + ? `--msg-level=${buildMpvMsgLevel(logLevel)}` + : null; if (!pluginRuntimeConfig && hasBinaryPath) { scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`); } @@ -147,6 +157,7 @@ export function buildWindowsMpvLaunchArgs( '--secondary-sub-visibility=no', ...(scriptOpts ? [scriptOpts] : []), ...buildMpvLaunchModeArgs(launchMode), + ...(mpvLogLevelArg ? [mpvLogLevelArg] : []), ...extraArgs, ...targets, ]; @@ -197,17 +208,39 @@ export async function launchWindowsMpv( if (installedPlugin?.installed && !installedPluginPrompted) { runtimePluginPolicy?.notifyInstalledPluginDetected?.(installedPlugin); } - await deps.spawnDetached( - mpvPath, - buildWindowsMpvLaunchArgs( - targets, - extraArgs, - binaryPath, - runtimePluginEntrypointPath, - launchMode, - pluginRuntimeConfig, - ), + const hasLogLevel = pluginRuntimeConfig?.logLevel !== undefined; + const hasLogRotation = pluginRuntimeConfig?.logRotation !== undefined; + const launchEnv = + hasLogLevel || hasLogRotation + ? { + ...(hasLogLevel + ? { SUBMINER_LOG_LEVEL: pluginRuntimeConfig.logLevel } + : {}), + ...(hasLogRotation + ? { SUBMINER_LOG_ROTATION: String(pluginRuntimeConfig.logRotation) } + : {}), + } + : undefined; + const launchArgs = buildWindowsMpvLaunchArgs( + targets, + extraArgs, + binaryPath, + runtimePluginEntrypointPath, + launchMode, + pluginRuntimeConfig, ); + const inputIpcServer = + readExtraArgValue(launchArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET; + deps.logInfo?.( + [ + `Launching mpv: mpvPath=${mpvPath}`, + `inputIpcServer=${inputIpcServer}`, + `bundledPlugin=${runtimePluginEntrypointPath ?? 'not injected'}`, + `installedPlugin=${installedPlugin?.installed ? (installedPlugin.path ?? 'unknown') : 'none'}`, + `targets=${targets.length}`, + ].join('; '), + ); + await deps.spawnDetached(mpvPath, launchArgs, launchEnv); return { ok: true, mpvPath }; } catch (error) { const message = error instanceof Error ? error.message : String(error); @@ -220,6 +253,7 @@ export function createWindowsMpvLaunchDeps(options: { getEnv?: (name: string) => string | undefined; fileExists?: (candidate: string) => boolean; showError: (title: string, content: string) => void; + logInfo?: (message: string) => void; }): WindowsMpvLaunchDeps { return { getEnv: options.getEnv ?? ((name) => process.env[name]), @@ -235,13 +269,15 @@ export function createWindowsMpvLaunchDeps(options: { }; }, fileExists: options.fileExists ?? defaultWindowsMpvFileExists, - spawnDetached: (command, args) => + logInfo: options.logInfo, + spawnDetached: (command, args, env) => new Promise((resolve, reject) => { try { const child = spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true, + env: env ? { ...process.env, ...env } : process.env, }); let settled = false; child.once('error', (error) => { diff --git a/src/main/startup.ts b/src/main/startup.ts index 48d908bc..a46d3bd5 100644 --- a/src/main/startup.ts +++ b/src/main/startup.ts @@ -7,6 +7,7 @@ export interface StartupBootstrapRuntimeFactoryDeps { argv: string[]; parseArgs: (argv: string[]) => CliArgs; setLogLevel: (level: string, source: LogLevelSource) => void; + setLogRotation?: (rotation: number) => void; forceX11Backend: (args: CliArgs) => void; enforceUnsupportedWaylandMode: (args: CliArgs) => void; shouldStartApp: (args: CliArgs) => boolean; @@ -35,6 +36,7 @@ export function createStartupBootstrapRuntimeDeps( argv: params.argv, parseArgs: params.parseArgs, setLogLevel: params.setLogLevel, + setLogRotation: params.setLogRotation, forceX11Backend: (args: CliArgs) => params.forceX11Backend(args), enforceUnsupportedWaylandMode: (args: CliArgs) => params.enforceUnsupportedWaylandMode(args), getDefaultSocketPath: params.getDefaultSocketPath, diff --git a/src/preload.ts b/src/preload.ts index 2875b992..e400a114 100644 --- a/src/preload.ts +++ b/src/preload.ts @@ -156,9 +156,6 @@ function createLatestValueIpcListenerWithPayload( const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen); const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen); -const onOpenCharacterDictionaryEvent = createQueuedIpcListener( - IPC_CHANNELS.event.characterDictionaryOpen, -); const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener( IPC_CHANNELS.event.characterDictionaryManagerOpen, ); @@ -390,7 +387,6 @@ const electronAPI: ElectronAPI = { onOpenJimaku: onOpenJimakuEvent, onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent, onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent, - onOpenCharacterDictionary: onOpenCharacterDictionaryEvent, onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent, onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent, onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent, diff --git a/src/renderer/modals/session-help-sections.ts b/src/renderer/modals/session-help-sections.ts index 8c2b231c..cca69cc8 100644 --- a/src/renderer/modals/session-help-sections.ts +++ b/src/renderer/modals/session-help-sections.ts @@ -204,8 +204,6 @@ function describeSessionAction( return 'Open runtime options'; case 'openSessionHelp': return 'Open session help'; - case 'openCharacterDictionary': - return 'Open AniList override selector'; case 'openCharacterDictionaryManager': return 'Open character dictionary manager'; case 'openControllerSelect': @@ -256,7 +254,6 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string { return 'Subtitle sync'; case 'openRuntimeOptions': case 'openJimaku': - case 'openCharacterDictionary': case 'openCharacterDictionaryManager': case 'openControllerSelect': case 'openControllerDebug': diff --git a/src/renderer/renderer.ts b/src/renderer/renderer.ts index 3dc9444d..a4571839 100644 --- a/src/renderer/renderer.ts +++ b/src/renderer/renderer.ts @@ -460,11 +460,6 @@ function registerModalOpenHandlers(): void { window.electronAPI.notifyOverlayModalOpened('runtime-options'); }); }); - window.electronAPI.onOpenCharacterDictionary(() => { - runGuardedAsync('character-dictionary:open', async () => { - await characterDictionaryModal.openCharacterDictionaryModal(); - }); - }); window.electronAPI.onOpenCharacterDictionaryManager(() => { runGuardedAsync('character-dictionary-manager:open', async () => { await characterDictionaryModal.openCharacterDictionaryManagerModal(); diff --git a/src/shared/ipc/contracts.ts b/src/shared/ipc/contracts.ts index 2b6c1715..e68f43cf 100644 --- a/src/shared/ipc/contracts.ts +++ b/src/shared/ipc/contracts.ts @@ -133,7 +133,6 @@ export const IPC_CHANNELS = { keyboardModeToggleRequested: 'keyboard-mode-toggle:requested', lookupWindowToggleRequested: 'lookup-window-toggle:requested', sessionHelpOpen: 'session-help:open', - characterDictionaryOpen: 'character-dictionary:open', characterDictionaryManagerOpen: 'character-dictionary:manager-open', controllerSelectOpen: 'controller-select:open', controllerDebugOpen: 'controller-debug:open', diff --git a/src/shared/ipc/validators.ts b/src/shared/ipc/validators.ts index 8997e3ed..4d7ed083 100644 --- a/src/shared/ipc/validators.ts +++ b/src/shared/ipc/validators.ts @@ -33,7 +33,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [ 'toggleSubtitleSidebar', 'openRuntimeOptions', 'openSessionHelp', - 'openCharacterDictionary', 'openCharacterDictionaryManager', 'openControllerSelect', 'openControllerDebug', diff --git a/src/shared/log-files.test.ts b/src/shared/log-files.test.ts index 9093ea71..1441ae2a 100644 --- a/src/shared/log-files.test.ts +++ b/src/shared/log-files.test.ts @@ -3,7 +3,14 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { appendLogLine, pruneLogFiles, resolveDefaultLogFilePath } from './log-files'; +import { + applyLogFileTogglesToEnv, + appendLogLine, + isLogFileEnabled, + pruneLogDirectoryForPath, + pruneLogFiles, + resolveDefaultLogFilePath, +} from './log-files'; test('resolveDefaultLogFilePath uses app prefix by default', () => { const now = new Date('2026-03-22T12:00:00.000Z'); @@ -15,16 +22,47 @@ test('resolveDefaultLogFilePath uses app prefix by default', () => { assert.equal( resolved, - path.join( - '/home/tester', - '.config', - 'SubMiner', - 'logs', - `app-${now.toISOString().slice(0, 10)}.log`, - ), + path.join('/home/tester', '.config', 'SubMiner', 'logs', 'app-2026-03-22.log'), ); }); +test('resolveDefaultLogFilePath uses daily filenames for mpv logs', () => { + const now = new Date('2026-03-22T12:00:00.000Z'); + const resolved = resolveDefaultLogFilePath('mpv', { + platform: 'linux', + homeDir: '/home/tester', + now, + }); + + assert.equal( + resolved, + path.join('/home/tester', '.config', 'SubMiner', 'logs', 'mpv-2026-03-22.log'), + ); +}); + +test('log file toggles keep app and launcher enabled while mpv defaults off', () => { + assert.equal(isLogFileEnabled('app', {}), true); + assert.equal(isLogFileEnabled('launcher', {}), true); + assert.equal(isLogFileEnabled('mpv', {}), false); + assert.equal(isLogFileEnabled('mpv', { SUBMINER_MPV_LOG: '/tmp/mpv.log' }), true); + assert.equal( + isLogFileEnabled('mpv', { + SUBMINER_MPV_LOG: '/tmp/mpv.log', + SUBMINER_MPV_LOG_ENABLED: 'false', + }), + false, + ); +}); + +test('applyLogFileTogglesToEnv writes log enable env flags', () => { + const env: NodeJS.ProcessEnv = {}; + applyLogFileTogglesToEnv({ app: false, launcher: true, mpv: true }, env); + + assert.equal(env.SUBMINER_APP_LOG_ENABLED, 'false'); + assert.equal(env.SUBMINER_LAUNCHER_LOG_ENABLED, 'true'); + assert.equal(env.SUBMINER_MPV_LOG_ENABLED, 'true'); +}); + test('pruneLogFiles removes logs older than retention window', () => { const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-log-prune-')); const stalePath = path.join(logsDir, 'app-old.log'); @@ -69,3 +107,22 @@ test('appendLogLine trims oversized logs to newest bytes', () => { fs.rmSync(logsDir, { recursive: true, force: true }); } }); + +test('empty log path operations are no-ops', () => { + const cwd = process.cwd(); + const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-empty-log-path-')); + const candidate = path.join(logsDir, 'cwd.log'); + + try { + process.chdir(logsDir); + fs.writeFileSync(candidate, 'keep\n', 'utf8'); + + pruneLogDirectoryForPath('', 1); + appendLogLine('', 'ignored', { retentionDays: 1 }); + + assert.equal(fs.readFileSync(candidate, 'utf8'), 'keep\n'); + } finally { + process.chdir(cwd); + fs.rmSync(logsDir, { recursive: true, force: true }); + } +}); diff --git a/src/shared/log-files.ts b/src/shared/log-files.ts index 7e837824..19b7f10f 100644 --- a/src/shared/log-files.ts +++ b/src/shared/log-files.ts @@ -3,15 +3,35 @@ import os from 'node:os'; import path from 'node:path'; export type LogKind = 'app' | 'launcher' | 'mpv'; +export type LogRotation = number; +export type LogFileToggles = Record; export const DEFAULT_LOG_RETENTION_DAYS = 7; export const DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024; +export const DEFAULT_LOG_ROTATION: LogRotation = DEFAULT_LOG_RETENTION_DAYS; +export const DEFAULT_LOG_FILE_TOGGLES: LogFileToggles = { + app: true, + launcher: true, + mpv: false, +}; const TRUNCATED_MARKER = '[truncated older log content]\n'; const prunedDirectories = new Set(); const NS_PER_MS = 1_000_000n; const MS_PER_DAY = 86_400_000n; +const LOG_ENABLED_ENV_BY_KIND: Record = { + app: 'SUBMINER_APP_LOG_ENABLED', + launcher: 'SUBMINER_LAUNCHER_LOG_ENABLED', + mpv: 'SUBMINER_MPV_LOG_ENABLED', +}; + +const LOG_PATH_ENV_BY_KIND: Record = { + app: 'SUBMINER_APP_LOG', + launcher: 'SUBMINER_LAUNCHER_LOG', + mpv: 'SUBMINER_MPV_LOG', +}; + function floorDiv(left: number, right: number): number { return Math.floor(left / right); } @@ -54,10 +74,56 @@ export function resolveDefaultLogFilePath( homeDir?: string; appDataDir?: string; now?: Date; + rotation?: unknown; }, ): string { - const date = (options?.now ?? new Date()).toISOString().slice(0, 10); - return path.join(resolveLogBaseDir(options), 'logs', `${kind}-${date}.log`); + const now = options?.now ?? new Date(); + const suffix = now.toISOString().slice(0, 10); + return path.join(resolveLogBaseDir(options), 'logs', `${kind}-${suffix}.log`); +} + +export function normalizeLogRotation(rotation: unknown): LogRotation | undefined { + const parsed = + typeof rotation === 'number' + ? rotation + : typeof rotation === 'string' && /^\d+$/.test(rotation.trim()) + ? Number(rotation.trim()) + : Number.NaN; + if (!Number.isInteger(parsed) || parsed <= 0) return undefined; + return parsed; +} + +export function normalizeLogFileEnabled(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value; + if (typeof value !== 'string') return undefined; + const normalized = value.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return undefined; +} + +export function isLogFileEnabled(kind: LogKind, env: NodeJS.ProcessEnv = process.env): boolean { + const configured = normalizeLogFileEnabled(env[LOG_ENABLED_ENV_BY_KIND[kind]]); + if (configured !== undefined) return configured; + const explicitPath = env[LOG_PATH_ENV_BY_KIND[kind]]?.trim(); + if (explicitPath) return true; + return DEFAULT_LOG_FILE_TOGGLES[kind]; +} + +export function applyLogFileTogglesToEnv( + files: Partial | undefined, + env: NodeJS.ProcessEnv = process.env, +): void { + for (const kind of Object.keys(LOG_ENABLED_ENV_BY_KIND) as LogKind[]) { + const explicitPath = env[LOG_PATH_ENV_BY_KIND[kind]]?.trim(); + const enabled = files?.[kind] ?? (explicitPath ? true : DEFAULT_LOG_FILE_TOGGLES[kind]); + env[LOG_ENABLED_ENV_BY_KIND[kind]] = String(enabled); + } +} + +export function getLogRetentionDays(rotation: unknown): number { + const normalized = normalizeLogRotation(rotation) ?? DEFAULT_LOG_ROTATION; + return normalized; } export function pruneLogFiles( @@ -107,6 +173,11 @@ function maybePruneLogDirectory(logPath: string, retentionDays: number): void { prunedDirectories.add(key); } +export function pruneLogDirectoryForPath(logPath: string, rotation?: unknown): void { + if (!logPath.trim()) return; + maybePruneLogDirectory(logPath, getLogRetentionDays(rotation)); +} + function trimLogFileToMaxBytes(logPath: string, maxBytes: number): void { if (!Number.isFinite(maxBytes) || maxBytes <= 0) return; @@ -135,10 +206,12 @@ export function appendLogLine( line: string, options?: { retentionDays?: number; + rotation?: unknown; maxBytes?: number; }, ): void { - const retentionDays = options?.retentionDays ?? DEFAULT_LOG_RETENTION_DAYS; + if (!logPath.trim()) return; + const retentionDays = options?.retentionDays ?? getLogRetentionDays(options?.rotation); const maxBytes = options?.maxBytes ?? DEFAULT_LOG_MAX_BYTES; try { diff --git a/src/shared/mpv-logging-args.ts b/src/shared/mpv-logging-args.ts new file mode 100644 index 00000000..e05c5918 --- /dev/null +++ b/src/shared/mpv-logging-args.ts @@ -0,0 +1,27 @@ +export type SharedLogLevel = 'debug' | 'info' | 'warn' | 'error'; + +function hasOption(args: readonly string[], option: string): boolean { + return args.some((arg) => arg === option || arg.startsWith(`${option}=`)); +} + +export function buildMpvMsgLevel(logLevel: SharedLogLevel): string { + return `all=warn,subminer=${logLevel}`; +} + +export function buildMpvLoggingArgs( + logLevel: SharedLogLevel, + logPath: string, + existingArgs: readonly string[] = [], +): string[] { + if (!logPath.trim()) { + return []; + } + const args: string[] = []; + if (!hasOption(existingArgs, '--log-file')) { + args.push(`--log-file=${logPath}`); + } + if (!hasOption(existingArgs, '--msg-level')) { + args.push(`--msg-level=${buildMpvMsgLevel(logLevel)}`); + } + return args; +} diff --git a/src/shared/stored-zip.ts b/src/shared/stored-zip.ts new file mode 100644 index 00000000..9c10ea8a --- /dev/null +++ b/src/shared/stored-zip.ts @@ -0,0 +1,219 @@ +import * as fs from 'fs'; + +type ZipEntry = { + name: string; + crc32: number; + size: number; + localHeaderOffset: number; +}; + +const ZIP32_MAX_UINT16 = 0xffff; +const ZIP32_MAX_UINT32 = 0xffffffff; + +export type StoredZipFile = { + name: string; + data: Buffer; +}; + +function writeUint32LE(buffer: Buffer, value: number, offset: number): number { + const normalized = value >>> 0; + buffer[offset] = normalized & 0xff; + buffer[offset + 1] = (normalized >>> 8) & 0xff; + buffer[offset + 2] = (normalized >>> 16) & 0xff; + buffer[offset + 3] = (normalized >>> 24) & 0xff; + return offset + 4; +} + +const CRC32_TABLE = (() => { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i += 1) { + let crc = i; + for (let j = 0; j < 8; j += 1) { + crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1; + } + table[i] = crc >>> 0; + } + return table; +})(); + +function crc32(data: Buffer): number { + let crc = 0xffffffff; + for (const byte of data) { + crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function createLocalFileHeader(fileName: Buffer, fileCrc32: number, fileSize: number): Buffer { + const local = Buffer.alloc(30 + fileName.length); + let cursor = 0; + writeUint32LE(local, 0x04034b50, cursor); + cursor += 4; + local.writeUInt16LE(20, cursor); + cursor += 2; + local.writeUInt16LE(0, cursor); + cursor += 2; + local.writeUInt16LE(0, cursor); + cursor += 2; + local.writeUInt16LE(0, cursor); + cursor += 2; + local.writeUInt16LE(0, cursor); + cursor += 2; + writeUint32LE(local, fileCrc32, cursor); + cursor += 4; + writeUint32LE(local, fileSize, cursor); + cursor += 4; + writeUint32LE(local, fileSize, cursor); + cursor += 4; + local.writeUInt16LE(fileName.length, cursor); + cursor += 2; + local.writeUInt16LE(0, cursor); + cursor += 2; + fileName.copy(local, cursor); + return local; +} + +function createCentralDirectoryHeader(entry: ZipEntry): Buffer { + const fileName = Buffer.from(entry.name, 'utf8'); + const central = Buffer.alloc(46 + fileName.length); + let cursor = 0; + writeUint32LE(central, 0x02014b50, cursor); + cursor += 4; + central.writeUInt16LE(20, cursor); + cursor += 2; + central.writeUInt16LE(20, cursor); + cursor += 2; + central.writeUInt16LE(0, cursor); + cursor += 2; + central.writeUInt16LE(0, cursor); + cursor += 2; + central.writeUInt16LE(0, cursor); + cursor += 2; + central.writeUInt16LE(0, cursor); + cursor += 2; + writeUint32LE(central, entry.crc32, cursor); + cursor += 4; + writeUint32LE(central, entry.size, cursor); + cursor += 4; + writeUint32LE(central, entry.size, cursor); + cursor += 4; + central.writeUInt16LE(fileName.length, cursor); + cursor += 2; + central.writeUInt16LE(0, cursor); + cursor += 2; + central.writeUInt16LE(0, cursor); + cursor += 2; + central.writeUInt16LE(0, cursor); + cursor += 2; + central.writeUInt16LE(0, cursor); + cursor += 2; + writeUint32LE(central, 0, cursor); + cursor += 4; + writeUint32LE(central, entry.localHeaderOffset, cursor); + cursor += 4; + fileName.copy(central, cursor); + return central; +} + +function createEndOfCentralDirectory( + entriesLength: number, + centralSize: number, + centralStart: number, +): Buffer { + if ( + entriesLength > ZIP32_MAX_UINT16 || + centralSize > ZIP32_MAX_UINT32 || + centralStart > ZIP32_MAX_UINT32 + ) { + throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)'); + } + + const end = Buffer.alloc(22); + let cursor = 0; + writeUint32LE(end, 0x06054b50, cursor); + cursor += 4; + end.writeUInt16LE(0, cursor); + cursor += 2; + end.writeUInt16LE(0, cursor); + cursor += 2; + end.writeUInt16LE(entriesLength, cursor); + cursor += 2; + end.writeUInt16LE(entriesLength, cursor); + cursor += 2; + writeUint32LE(end, centralSize, cursor); + cursor += 4; + writeUint32LE(end, centralStart, cursor); + cursor += 4; + end.writeUInt16LE(0, cursor); + return end; +} + +function writeBuffer(fd: number, buffer: Buffer): void { + let written = 0; + while (written < buffer.length) { + written += fs.writeSync(fd, buffer, written, buffer.length - written); + } +} + +export function writeStoredZip( + outputPath: string, + files: Iterable, +): { entryCount: number } { + const entries: ZipEntry[] = []; + let offset = 0; + const fd = fs.openSync(outputPath, 'w'); + + try { + for (const file of files) { + const fileName = Buffer.from(file.name, 'utf8'); + const fileSize = file.data.length; + if (fileName.length > ZIP32_MAX_UINT16) { + throw new RangeError(`ZIP entry name too long: ${file.name}`); + } + if (fileSize > ZIP32_MAX_UINT32) { + throw new RangeError(`ZIP entry too large for ZIP32: ${file.name}`); + } + if (offset > ZIP32_MAX_UINT32) { + throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)'); + } + const fileCrc32 = crc32(file.data); + const localHeader = createLocalFileHeader(fileName, fileCrc32, fileSize); + const nextOffset = offset + localHeader.length + fileSize; + if (nextOffset > ZIP32_MAX_UINT32) { + throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)'); + } + writeBuffer(fd, localHeader); + writeBuffer(fd, file.data); + entries.push({ + name: file.name, + crc32: fileCrc32, + size: fileSize, + localHeaderOffset: offset, + }); + if (nextOffset > ZIP32_MAX_UINT32) { + throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)'); + } + offset = nextOffset; + } + + const centralStart = offset; + if (centralStart > ZIP32_MAX_UINT32) { + throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)'); + } + for (const entry of entries) { + const centralHeader = createCentralDirectoryHeader(entry); + writeBuffer(fd, centralHeader); + offset += centralHeader.length; + } + + const centralSize = offset - centralStart; + writeBuffer(fd, createEndOfCentralDirectory(entries.length, centralSize, centralStart)); + } catch (error) { + fs.closeSync(fd); + fs.rmSync(outputPath, { force: true }); + throw error; + } + + fs.closeSync(fd); + return { entryCount: entries.length }; +} diff --git a/src/shared/subminer-plugin-script-opts.ts b/src/shared/subminer-plugin-script-opts.ts index 78bbe035..19bd4b67 100644 --- a/src/shared/subminer-plugin-script-opts.ts +++ b/src/shared/subminer-plugin-script-opts.ts @@ -1,9 +1,12 @@ import type { MpvBackend } from '../types/config'; +import type { LogRotation } from './log-files'; export interface SubminerPluginRuntimeScriptOptConfig { socketPath: string; binaryPath?: string; backend: MpvBackend; + logLevel?: 'debug' | 'info' | 'warn' | 'error'; + logRotation?: LogRotation; autoStart: boolean; autoStartVisibleOverlay: boolean; autoStartPauseUntilReady: boolean; diff --git a/src/types/config.ts b/src/types/config.ts index b860218c..c6e36202 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -93,6 +93,14 @@ export interface UpdatesConfig { channel?: UpdateChannel; } +export type LogRotation = number; + +export interface LogFilesConfig { + app?: boolean; + launcher?: boolean; + mpv?: boolean; +} + export interface ShortcutsConfig { toggleVisibleOverlayGlobal?: string | null; copySubtitle?: string | null; @@ -143,6 +151,8 @@ export interface Config { updates?: UpdatesConfig; logging?: { level?: 'debug' | 'info' | 'warn' | 'error'; + rotation?: LogRotation; + files?: LogFilesConfig; }; } @@ -286,7 +296,6 @@ export interface ResolvedConfig { enabled: boolean; accessToken: string; characterDictionary: { - enabled: boolean; refreshTtlHours: number; maxLoaded: number; evictionPolicy: AnilistCharacterDictionaryEvictionPolicy; @@ -372,6 +381,8 @@ export interface ResolvedConfig { updates: Required; logging: { level: 'debug' | 'info' | 'warn' | 'error'; + rotation: LogRotation; + files: Required; }; } diff --git a/src/types/integrations.ts b/src/types/integrations.ts index 340395a3..b8da692b 100644 --- a/src/types/integrations.ts +++ b/src/types/integrations.ts @@ -64,7 +64,6 @@ export interface AnilistCharacterDictionaryCollapsibleSectionsConfig { } export interface AnilistCharacterDictionaryConfig { - enabled?: boolean; refreshTtlHours?: number; maxLoaded?: number; evictionPolicy?: AnilistCharacterDictionaryEvictionPolicy; diff --git a/src/types/runtime.ts b/src/types/runtime.ts index a8e61bf3..0abad13c 100644 --- a/src/types/runtime.ts +++ b/src/types/runtime.ts @@ -468,7 +468,6 @@ export interface ElectronAPI { onOpenJimaku: (callback: () => void) => void; onOpenYoutubeTrackPicker: (callback: (payload: YoutubePickerOpenPayload) => void) => void; onOpenPlaylistBrowser: (callback: () => void) => void; - onOpenCharacterDictionary: (callback: () => void) => void; onOpenCharacterDictionaryManager: (callback: () => void) => void; onSubtitleSidebarToggle: (callback: () => void) => void; onPrimarySubtitleBarToggle: (callback: () => void) => void; diff --git a/src/types/session-bindings.ts b/src/types/session-bindings.ts index ed78d4ee..5a76ad53 100644 --- a/src/types/session-bindings.ts +++ b/src/types/session-bindings.ts @@ -16,7 +16,6 @@ export type SessionActionId = | 'markAudioCard' | 'openRuntimeOptions' | 'openSessionHelp' - | 'openCharacterDictionary' | 'openCharacterDictionaryManager' | 'openControllerSelect' | 'openControllerDebug' @@ -67,10 +66,16 @@ export interface CompiledSessionActionBinding extends CompiledSessionBindingBase export type CompiledSessionBinding = CompiledMpvCommandBinding | CompiledSessionActionBinding; +export interface PluginSessionActionBinding extends CompiledSessionActionBinding { + cliArgs?: string[]; +} + +export type PluginSessionBinding = CompiledMpvCommandBinding | PluginSessionActionBinding; + export interface PluginSessionBindingsArtifact { version: 1; generatedAt: string; numericSelectionTimeoutMs: number; - bindings: CompiledSessionBinding[]; + bindings: PluginSessionBinding[]; warnings: SessionBindingWarning[]; } diff --git a/src/verify-config-example.ts b/src/verify-config-example.ts index 2b5360b1..4954a40a 100644 --- a/src/verify-config-example.ts +++ b/src/verify-config-example.ts @@ -1,6 +1,8 @@ import fs from 'node:fs'; -import { DEFAULT_CONFIG, generateConfigTemplate } from './config'; -import { resolveConfigExampleOutputPaths } from './generate-config-example'; +import { + generateConfigExampleTemplate, + resolveConfigExampleOutputPaths, +} from './generate-config-example'; export type ConfigExampleVerificationResult = { docsSiteDetected: boolean; @@ -21,7 +23,7 @@ export function verifyConfigExampleArtifacts(options?: { }): ConfigExampleVerificationResult { const existsSync = options?.deps?.existsSync ?? fs.existsSync; const readFileSync = options?.deps?.readFileSync ?? fs.readFileSync; - const template = options?.template ?? generateConfigTemplate(DEFAULT_CONFIG); + const template = options?.template ?? generateConfigExampleTemplate(); const outputPaths = resolveConfigExampleOutputPaths({ cwd: options?.cwd, docsSiteDirName: options?.docsSiteDirName,