mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
Compare commits
18 Commits
a91c3f8beb
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
2add95d541
|
|||
|
f62fff2585
|
|||
| 11c196821d | |||
|
43ebc7d371
|
|||
| 639e331f24 | |||
|
78be72e32f
|
|||
| 3932e53ced | |||
| 097b619d71 | |||
|
f7abcedd75
|
|||
| 807c0ff3db | |||
| 7e6f9672cf | |||
|
9fe13601fb
|
|||
| 920cbab1bc | |||
| 17d97f0b7e | |||
|
10463e7348
|
|||
|
e9abbd5f05
|
|||
|
d6ff50455a
|
|||
| b1bdeabca8 |
@@ -34,11 +34,13 @@ Rules:
|
|||||||
How fragments turn into a release:
|
How fragments turn into a release:
|
||||||
|
|
||||||
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
- At release time, `bun run changelog:build` (and `bun run changelog:prerelease-notes`) pipes every pending fragment through `claude -p` to merge related items, drop noise, and rewrite into a clean user-facing release body. Write fragments as raw, informative notes — don't worry about polished prose, deduping across PRs, or line-by-line phrasing. The polish step handles all of that.
|
||||||
|
- The polish step treats pending fragments as the final release outcome, not prerelease history. If a feature is added and then renamed or fixed before the stable cut, ship the final feature bullet instead of separate prerelease-only breaking/fix entries.
|
||||||
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
- `internal` fragments stay in `CHANGELOG.md` (inside a collapsed `<details>` block) but are dropped from the GitHub release notes entirely.
|
||||||
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
- The polished `CHANGELOG.md` and `release/release-notes.md` are committed and reviewed before tagging — edit the Markdown by hand if Claude misses something.
|
||||||
|
|
||||||
Prerelease notes:
|
Prerelease notes:
|
||||||
|
|
||||||
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
|
- prerelease tags like `v0.11.3-beta.1` and `v0.11.3-rc.1` reuse the current pending fragments to generate `release/prerelease-notes.md`
|
||||||
|
- existing prerelease notes are a reviewed baseline; later prerelease runs should replace stale beta/RC wording with the current outcome instead of appending fix churn
|
||||||
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
- prerelease note generation does not consume fragments and does not update `CHANGELOG.md` or `docs-site/changelog.md`
|
||||||
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes
|
- the final stable release is the point where `bun run changelog:build` consumes fragments into the stable changelog and release notes
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: anilist
|
||||||
|
|
||||||
|
- Prevent repeated missing-token checks from rapidly exhausting AniList retry attempts or duplicating dead-letter entries for the same episode.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: release
|
||||||
|
|
||||||
|
- Release-note polishing now treats pending fragments and reviewed prerelease notes as a cumulative final outcome, so prerelease-only fixes or breakages collapse into the final user-facing change.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: dictionary
|
||||||
|
|
||||||
|
- Keep character dictionary lookup entries scoped to generated Japanese name aliases instead of surfacing raw romanized/English aliases as separate results, and refresh cached v15 snapshots so old English-name entries are regenerated.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: character-dictionary
|
||||||
|
|
||||||
|
- Character dictionary entries are now scoped to the current AniList media for name matching and inline portraits, and a new `Ctrl/Cmd+D` manager modal can remove, reorder, or override loaded dictionary entries.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: changed
|
||||||
|
area: character-dictionary
|
||||||
|
|
||||||
|
- **Character Dictionary:** Changed the in-app AniList selector to wait for an explicit title search. The search box is prefilled from the current filename guess, so you can edit it before choosing an override.
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
type: added
|
||||||
|
area: subtitles
|
||||||
|
|
||||||
|
- Added optional inline AniList portraits for character-name subtitle matches, including automatic refresh of cached character dictionary snapshots that do not contain portrait data.
|
||||||
|
- Scoped manual AniList overrides by parent media directory, so separate season folders can keep separate character dictionary selections.
|
||||||
|
- Fixed large character dictionary imports by serving the merged ZIP through a local URL when supported, with a base64 fallback for older bundled Yomitan builds.
|
||||||
|
- Allowed subtitle overlay data image sources so inline character portraits render instead of showing a broken image icon.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: character-dictionary
|
||||||
|
|
||||||
|
- Added surname honorific matches for Japanese localized character aliases embedded in AniList alternative names, such as Korean-source characters with Japanese names in parentheses, and refresh cached snapshots so those aliases are regenerated.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Hid the visible subtitle overlay as soon as the character dictionary modal opens, including while AniList lookup is still loading or returns no results.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: desktop
|
||||||
|
|
||||||
|
- Fixed Hyprland settings windows opening behind the subtitle overlay by promoting SubMiner and Yomitan settings above the overlay without hiding subtitles.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: launcher
|
||||||
|
|
||||||
|
- Fixed `subminer app` on Linux so launching the tray app returns control to the terminal immediately instead of waiting for the tray process to exit.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: mpv
|
||||||
|
|
||||||
|
- Pass generated session-action CLI args to the mpv plugin.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: overlay
|
||||||
|
|
||||||
|
- Fixed Windows managed mpv launches from a background SubMiner instance so the existing warm app receives the start command, retargets the new mpv socket, binds to the player window, and receives startup overlay options.
|
||||||
@@ -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.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
type: fixed
|
type: fixed
|
||||||
area: updater
|
area: updater
|
||||||
|
|
||||||
- Fixed tray update checks for builds that cannot install native app updates, showing a manual install message instead of a restart prompt that cannot apply the update.
|
- Fixed macOS tray update checks for builds that cannot install native app updates, so newer stable or prerelease GitHub releases are reported instead of incorrectly saying the current build is up to date.
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: release
|
||||||
|
|
||||||
|
- Fixed macOS updater metadata mismatches by giving macOS and Windows ZIP release assets distinct build-time filenames.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
type: fixed
|
||||||
|
area: tray
|
||||||
|
|
||||||
|
- Fixed the Windows tray "Open SubMiner Setup" action so it opens the setup window after first-run setup is already complete.
|
||||||
+13
-7
@@ -46,10 +46,16 @@
|
|||||||
// Logging
|
// Logging
|
||||||
// Controls logging verbosity.
|
// Controls logging verbosity.
|
||||||
// Set to debug for full runtime diagnostics.
|
// 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": {
|
"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.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -187,7 +193,7 @@
|
|||||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
|
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
|
||||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal.
|
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
@@ -383,7 +389,8 @@
|
|||||||
"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
|
"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
|
"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
|
"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.
|
"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.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
@@ -587,11 +594,10 @@
|
|||||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
"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.
|
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
||||||
"characterDictionary": {
|
"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.
|
"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.
|
"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
|
"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": {
|
"collapsibleSections": {
|
||||||
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
|
"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
|
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
|
||||||
@@ -624,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.
|
"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
|
"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.
|
"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
|
"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
|
"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
|
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||||
|
|||||||
@@ -19,18 +19,26 @@ type VersionManifest = {
|
|||||||
versions: Array<{ version: string; path: string }>;
|
versions: Array<{ version: string; path: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const base = normalizeBase(process.env.SUBMINER_DOCS_BASE ?? '/');
|
function optionalEnv(value: string | undefined): string | undefined {
|
||||||
const outDir = process.env.SUBMINER_DOCS_OUT_DIR;
|
return value && value !== 'undefined' ? value : undefined;
|
||||||
const docsSourceDir = process.env.SUBMINER_DOCS_SOURCE_DIR ?? process.cwd();
|
}
|
||||||
const localArchiveDir = resolve(
|
|
||||||
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR ??
|
const base = normalizeBase(optionalEnv(process.env.SUBMINER_DOCS_BASE) ?? '/');
|
||||||
join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
|
const outDir = optionalEnv(process.env.SUBMINER_DOCS_OUT_DIR);
|
||||||
);
|
const docsSourceDir = optionalEnv(process.env.SUBMINER_DOCS_SOURCE_DIR) ?? process.cwd();
|
||||||
const channel = normalizeChannel(process.env.SUBMINER_DOCS_CHANNEL);
|
const channel = normalizeChannel(optionalEnv(process.env.SUBMINER_DOCS_CHANNEL));
|
||||||
const docsVersion = process.env.SUBMINER_DOCS_VERSION;
|
const docsVersion = optionalEnv(process.env.SUBMINER_DOCS_VERSION);
|
||||||
const latestStable = process.env.SUBMINER_DOCS_LATEST_STABLE ?? 'v0.14.0';
|
const latestStable = optionalEnv(process.env.SUBMINER_DOCS_LATEST_STABLE) ?? 'v0.14.0';
|
||||||
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
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 {
|
function normalizeBase(value: string): string {
|
||||||
if (!value || value === '/') return '/';
|
if (!value || value === '/') return '/';
|
||||||
@@ -43,7 +51,7 @@ function normalizeChannel(value: string | undefined): DocsChannel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function parseVersionManifest(value: string | undefined): VersionManifest {
|
function parseVersionManifest(value: string | undefined): VersionManifest {
|
||||||
if (!value) {
|
if (!value || value === 'undefined') {
|
||||||
return {
|
return {
|
||||||
latestStable,
|
latestStable,
|
||||||
channels: [
|
channels: [
|
||||||
@@ -218,6 +226,7 @@ function isFile(path: string): boolean {
|
|||||||
function archiveFileForPathname(pathname: string): string | null {
|
function archiveFileForPathname(pathname: string): string | null {
|
||||||
if (!shouldHandleLocalVersionRoute(pathname)) return null;
|
if (!shouldHandleLocalVersionRoute(pathname)) return null;
|
||||||
|
|
||||||
|
const localArchiveDir = getLocalArchiveDir();
|
||||||
const routePath = decodeURIComponent(pathname).replace(/^\/+/, '');
|
const routePath = decodeURIComponent(pathname).replace(/^\/+/, '');
|
||||||
const filePath = resolve(localArchiveDir, routePath);
|
const filePath = resolve(localArchiveDir, routePath);
|
||||||
if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) {
|
if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) {
|
||||||
@@ -234,7 +243,11 @@ function archiveFileForPathname(pathname: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean {
|
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);
|
const filePath = archiveFileForPathname(pathname);
|
||||||
if (!filePath) return false;
|
if (!filePath) return false;
|
||||||
|
|||||||
@@ -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`) |
|
| `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: `""`) |
|
| `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.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.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 |
|
| `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
|
## CLI Commands
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
- **Character Dictionary:** Loaded entries are now scoped to the current AniList media for subtitle name matching and inline portraits. Added a character dictionary manager at `Ctrl/Cmd+D`; AniList overrides now live inside that manager instead of using a separate default shortcut.
|
||||||
|
|
||||||
## v0.14.0 (2026-05-12)
|
## v0.14.0 (2026-05-12)
|
||||||
|
|
||||||
SubMiner no longer requires a globally-installed mpv plugin. The bundled plugin is injected at runtime only when SubMiner launches mpv — through the `subminer` launcher, the app's managed launch, or the packaged Windows SubMiner mpv shortcut. When you open mpv on its own, SubMiner is not involved and the plugin is never loaded. If you have a legacy global SubMiner plugin under mpv's `scripts` directory, first-run setup detects it and prompts you to remove it before playback starts.
|
SubMiner no longer requires a globally-installed mpv plugin. The bundled plugin is injected at runtime only when SubMiner launches mpv — through the `subminer` launcher, the app's managed launch, or the packaged Windows SubMiner mpv shortcut. When you open mpv on its own, SubMiner is not involved and the plugin is never loaded. If you have a legacy global SubMiner plugin under mpv's `scripts` directory, first-run setup detects it and prompts you to remove it before playback starts.
|
||||||
|
|||||||
@@ -14,14 +14,14 @@ The feature has three stages: **snapshot**, **merge**, and **match**.
|
|||||||
|
|
||||||
2. **Merge** — SubMiner maintains a most-recently-used list of media IDs (default: 3). Snapshots from those titles are merged into a single Yomitan ZIP — `character-dictionaries/merged.zip` — which is always named "SubMiner Character Dictionary" so Yomitan treats it as a single stable dictionary across rebuilds.
|
2. **Merge** — SubMiner maintains a most-recently-used list of media IDs (default: 3). Snapshots from those titles are merged into a single Yomitan ZIP — `character-dictionaries/merged.zip` — which is always named "SubMiner Character Dictionary" so Yomitan treats it as a single stable dictionary across rebuilds.
|
||||||
|
|
||||||
3. **Match** — During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. Tokens that match a character entry are flagged with `isNameMatch` and highlighted in the overlay with a distinct color.
|
3. **Match** — During subtitle rendering, Yomitan scans subtitle text against all loaded dictionaries including the character dictionary. SubMiner only accepts character entries for the current AniList media when that media ID is known, then flags matching tokens with `isNameMatch` and highlights them in the overlay with a distinct color.
|
||||||
|
|
||||||
## Enabling the Feature
|
## Enabling the Feature
|
||||||
|
|
||||||
Character dictionary sync is disabled by default. To turn it on:
|
Character dictionary sync is disabled by default. To turn it on:
|
||||||
|
|
||||||
1. Authenticate with AniList (see [AniList Integration](/anilist-integration#setup)).
|
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.
|
3. Start watching — SubMiner will generate a snapshot for the current media and import the merged dictionary into Yomitan automatically.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
@@ -29,9 +29,9 @@ Character dictionary sync is disabled by default. To turn it on:
|
|||||||
"anilist": {
|
"anilist": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"accessToken": "your-token",
|
"accessToken": "your-token",
|
||||||
"characterDictionary": {
|
},
|
||||||
"enabled": true,
|
"subtitleStyle": {
|
||||||
},
|
"nameMatchEnabled": true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@@ -89,23 +89,29 @@ Name matching runs inside Yomitan's scanning pipeline during subtitle tokenizati
|
|||||||
|
|
||||||
1. Yomitan receives subtitle text and scans for dictionary matches.
|
1. Yomitan receives subtitle text and scans for dictionary matches.
|
||||||
2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching — the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`.
|
2. Entries from "SubMiner Character Dictionary" are checked with exact primary-source matching — the token must match the entry's `originalText` with `isPrimary: true` and `matchType: 'exact'`.
|
||||||
3. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
|
3. When the current AniList media ID is known, entries whose embedded media ID belongs to a different title are ignored for name matching and inline portraits.
|
||||||
4. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
|
4. Matched tokens are flagged `isNameMatch: true` and forwarded to the renderer.
|
||||||
|
5. If `subtitleStyle.nameMatchEnabled` is enabled, the renderer applies the name-match highlight color (default: `#f5bde6`).
|
||||||
|
6. If `subtitleStyle.nameMatchImagesEnabled` is enabled, the renderer also injects a small circular AniList portrait from the cached snapshot image data.
|
||||||
|
|
||||||
|
Older snapshot schema versions are regenerated automatically. Current-version snapshots are normally reused, but when `subtitleStyle.nameMatchImagesEnabled` is enabled SubMiner also checks whether the cached snapshot contains usable character portrait data. If it does not, the snapshot is refreshed so the merged dictionary can include images.
|
||||||
|
|
||||||
Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target.
|
Name matches are visually distinct from [N+1 targeting, frequency highlighting, and JLPT tags](/subtitle-annotations) so you can tell at a glance whether a highlighted word is a character name or a vocabulary target.
|
||||||
|
|
||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| -------------------------------- | --------- | ---------------------------------- |
|
| -------------------------------------- | --------- | ----------------------------------------- |
|
||||||
| `subtitleStyle.nameMatchEnabled` | `false` | Toggle character-name highlighting |
|
| `subtitleStyle.nameMatchEnabled` | `false` | Enable dictionary sync and highlighting |
|
||||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits beside names |
|
||||||
|
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for matched names |
|
||||||
|
|
||||||
## Dictionary Entries
|
## Dictionary Entries
|
||||||
|
|
||||||
Each character entry in the Yomitan dictionary includes structured content:
|
Each character entry in the Yomitan dictionary includes structured content:
|
||||||
|
|
||||||
- **Name** — native (Japanese) and romanized forms
|
- **Name** — the matched Japanese name form
|
||||||
|
- **Known names** — generated non-honorific Japanese aliases for that character, excluding raw romanized/English aliases from lookup results
|
||||||
- **Role badge** — color-coded by role: main (score 100), supporting (90), side (80), background (70)
|
- **Role badge** — color-coded by role: main (score 100), supporting (90), side (80), background (70)
|
||||||
- **Portrait** — character image from AniList, embedded in the ZIP
|
- **Portrait** — character image from AniList, embedded in the ZIP
|
||||||
- **Description** — biography text from AniList (collapsible)
|
- **Description** — biography text from AniList (collapsible)
|
||||||
@@ -130,7 +136,7 @@ The three collapsible sections can be configured to start open or closed:
|
|||||||
|
|
||||||
## Auto-Sync Lifecycle
|
## 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:**
|
**Phases:**
|
||||||
|
|
||||||
@@ -169,10 +175,13 @@ This creates a standalone dictionary ZIP for the target media and saves it along
|
|||||||
|
|
||||||
## Correcting AniList Matches
|
## Correcting AniList Matches
|
||||||
|
|
||||||
SubMiner uses `guessit` to infer the anime title from the active filename, then searches AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
|
SubMiner uses `guessit` to infer the anime title from the active filename before searching AniList. Some filenames can still resolve to the wrong title. For example, `Re - ZERO, Starting Life in Another World (2016)` can be misread as a different `Re...` series.
|
||||||
|
|
||||||
Use the in-app selector or CLI to pin the correct AniList media for the whole series:
|
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.
|
||||||
|
- CLI: `--dictionary-candidates` still lists matches for the current filename guess.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# List candidate AniList matches for a file
|
# List candidate AniList matches for a file
|
||||||
subminer dictionary --candidates "/path/to/episode.mkv"
|
subminer dictionary --candidates "/path/to/episode.mkv"
|
||||||
@@ -185,10 +194,20 @@ SubMiner.AppImage --dictionary-candidates --dictionary-target "/path/to/episode.
|
|||||||
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv"
|
SubMiner.AppImage --dictionary-select --dictionary-anilist-id 21355 --dictionary-target "/path/to/episode.mkv"
|
||||||
|
|
||||||
# Open the in-app selector from the running app
|
# 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 filename guess. Later episodes with the same series key use the selected AniList ID automatically. 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.
|
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.
|
||||||
|
|
||||||
|
## Managing Loaded Entries
|
||||||
|
|
||||||
|
Open the manager with `Ctrl/Cmd+D` (`shortcuts.openCharacterDictionaryManager`). The manager shows the merged dictionary's active MRU entries, marks the current anime, and lets you adjust eviction priority for the other loaded entries.
|
||||||
|
|
||||||
|
- **Remove** drops a non-current entry from the active merged dictionary and rebuilds/imports once.
|
||||||
|
- **Up/Down** changes MRU order for future eviction without rebuilding.
|
||||||
|
- **Override** opens the AniList selector for that entry's title so you can replace a saved loaded entry.
|
||||||
|
|
||||||
|
The current anime cannot be removed while you are watching it; it stays loaded until playback changes.
|
||||||
|
|
||||||
## File Structure
|
## File Structure
|
||||||
|
|
||||||
@@ -207,7 +226,7 @@ character-dictionaries/
|
|||||||
m170942-va67890.jpg # Voice actor portrait
|
m170942-va67890.jpg # Voice actor portrait
|
||||||
```
|
```
|
||||||
|
|
||||||
**Snapshot format** (v15): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images.
|
**Snapshot format** (v17): each snapshot contains the media ID, title, entry count, timestamp, an array of Yomitan term entries, and base64-encoded images.
|
||||||
|
|
||||||
**ZIP structure** follows the Yomitan dictionary format:
|
**ZIP structure** follows the Yomitan dictionary format:
|
||||||
|
|
||||||
@@ -224,13 +243,13 @@ merged.zip
|
|||||||
|
|
||||||
| Option | Default | Description |
|
| 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.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.profileScope` | `"all"` | Apply dictionary to `"all"` Yomitan profiles or `"active"` only |
|
||||||
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
|
| `anilist.characterDictionary.collapsibleSections.description` | `false` | Start Description section expanded |
|
||||||
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
|
| `anilist.characterDictionary.collapsibleSections.characterInformation` | `false` | Start Character Information section expanded |
|
||||||
| `anilist.characterDictionary.collapsibleSections.voicedBy` | `false` | Start Voiced By 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 |
|
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Highlight color for character-name matches |
|
||||||
|
|
||||||
## Reference Implementation
|
## Reference Implementation
|
||||||
@@ -252,9 +271,10 @@ If you work with visual novels or want a standalone dictionary generator indepen
|
|||||||
|
|
||||||
## Troubleshooting
|
## 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.
|
- **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 selector (`--open-character-dictionary`) or run `--dictionary-candidates`, then save the correct media with `--dictionary-select --dictionary-anilist-id <id>`. This replaces stale wrong-title entries for that series. If names are only from an older unrelated show, they'll rotate out once you watch enough new titles to push it past `maxLoaded`.
|
- **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 <id>`. SubMiner ignores character entries from other loaded titles for subtitle name matching and inline portraits once the current media ID is known.
|
||||||
- **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this.
|
- **Yomitan import fails:** SubMiner waits up to 7 seconds for Yomitan to be ready for mutations. If Yomitan is still loading dictionaries or performing another import, the operation may time out. Restarting the overlay typically resolves this.
|
||||||
- **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate.
|
- **Portraits missing:** Images are downloaded from AniList CDN during snapshot generation. If the network was unavailable during the initial sync, delete the snapshot file from `character-dictionaries/snapshots/` and let it regenerate.
|
||||||
|
|
||||||
|
|||||||
+81
-70
@@ -193,14 +193,24 @@ Control the minimum log level for runtime output:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"logging": {
|
"logging": {
|
||||||
"level": "info"
|
"level": "warn",
|
||||||
|
"rotation": 7,
|
||||||
|
"files": {
|
||||||
|
"app": true,
|
||||||
|
"launcher": true,
|
||||||
|
"mpv": false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------- | ---------------------------------------- | --------------------------------------------------------- |
|
| ---------------- | ---------------------------------------- | -------------------------------------------------------------------- |
|
||||||
| `level` | `"debug"`, `"info"`, `"warn"`, `"error"` | Minimum log level for runtime logging (default: `"info"`) |
|
| `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
|
### Updates
|
||||||
|
|
||||||
@@ -371,34 +381,35 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
| `fontFamily` | string | CSS font-family value (default: `"Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP"`) |
|
||||||
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
| `fontSize` | number (px) | Font size in pixels (default: `35`) |
|
||||||
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
| `fontColor` | string | Any CSS color value (default: `"#cad3f5"`) |
|
||||||
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
|
| `css` | object | CSS declarations applied to subtitles after normal style defaults; the settings window writes textbox edits here |
|
||||||
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
||||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
||||||
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
| `enableJlpt` | boolean | Enable JLPT level underline styling (`false` by default) |
|
||||||
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
| `preserveLineBreaks` | boolean | Preserve line breaks in visible overlay subtitle rendering (`false` by default). Enable to mirror mpv line layout. |
|
||||||
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
| `autoPauseVideoOnHover` | boolean | Pause playback while mouse hovers subtitle text, then resume on leave (`true` by default). |
|
||||||
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
| `autoPauseVideoOnYomitanPopup` | boolean | Pause playback while the Yomitan popup is open, then resume when the popup closes (`true` by default). |
|
||||||
| `hoverTokenColor` | string | Hex color used for hovered subtitle token highlight in mpv (default: catppuccin mauve) |
|
| `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 |
|
| `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) |
|
||||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
| `nameMatchImagesEnabled` | boolean | Show small cached AniList character portraits beside matched character-name tokens (`false` by default) |
|
||||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||||
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` by default) |
|
||||||
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
| `frequencyDictionary.sourcePath` | string | Path to a local frequency dictionary root. Leave empty or omit to use installed/default frequency-dictionary search paths. |
|
||||||
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
| `frequencyDictionary.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
||||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
| `frequencyDictionary.mode` | string | `"single"` or `"banded"` (`"single"` by default) |
|
||||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||||
| `secondary` | object | Override any of the above for secondary subtitles (optional), including `secondary.css` declarations |
|
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||||
|
| `secondary` | object | Override any of the above for secondary subtitles (optional), including `secondary.css` declarations |
|
||||||
|
|
||||||
The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
|
The Settings window keeps subtitle color controls separate, then saves CSS textboxes to
|
||||||
`subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`. The generated example
|
`subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`. The generated example
|
||||||
@@ -419,9 +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:
|
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.
|
- `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.
|
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.
|
||||||
|
|
||||||
@@ -616,7 +628,7 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
"mineSentence": "CommandOrControl+S",
|
"mineSentence": "CommandOrControl+S",
|
||||||
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
"mineSentenceMultiple": "CommandOrControl+Shift+S",
|
||||||
"markAudioCard": "CommandOrControl+Shift+A",
|
"markAudioCard": "CommandOrControl+Shift+A",
|
||||||
"openCharacterDictionary": "CommandOrControl+Alt+A",
|
"openCharacterDictionaryManager": "CommandOrControl+D",
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
"openRuntimeOptions": "CommandOrControl+Shift+O",
|
||||||
"openSessionHelp": "CommandOrControl+Slash",
|
"openSessionHelp": "CommandOrControl+Slash",
|
||||||
"openControllerSelect": "Alt+C",
|
"openControllerSelect": "Alt+C",
|
||||||
@@ -628,26 +640,26 @@ See `config.example.jsonc` for detailed configuration options.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ----------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `toggleVisibleOverlayGlobal` | string \| `null` | Global accelerator for toggling visible subtitle overlay (default: `"Alt+Shift+O"`) |
|
| `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"`) |
|
| `copySubtitle` | string \| `null` | Accelerator for copying current subtitle (default: `"CommandOrControl+C"`) |
|
||||||
| `copySubtitleMultiple` | string \| `null` | Accelerator for multi-copy mode (default: `"CommandOrControl+Shift+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"`) |
|
| `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`) |
|
| `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"`) |
|
| `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"`) |
|
| `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"`) |
|
| `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`) |
|
| `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"`) |
|
| `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"`) |
|
| `markAudioCard` | string \| `null` | Accelerator for marking last card as audio card (default: `"CommandOrControl+Shift+A"`) |
|
||||||
| `openCharacterDictionary` | string \| `null` | Opens the character dictionary AniList selector (default: `"CommandOrControl+Alt+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"`) |
|
| `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"`) |
|
| `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"`) |
|
| `openControllerSelect` | string \| `null` | Opens the controller config/remap modal (default: `"Alt+C"`) |
|
||||||
| `openControllerDebug` | string \| `null` | Opens the controller debug modal (default: `"Alt+Shift+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"`) |
|
| `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. |
|
| `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.
|
**See `config.example.jsonc`** for the complete list of shortcut configuration options.
|
||||||
|
|
||||||
@@ -785,7 +797,7 @@ When `behavior.autoUpdateNewCards` is set to `false`, new cards are detected but
|
|||||||
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
|
| `Ctrl+Shift+S` | Enter multi-mine mode. Press `1-9` to create a sentence card from that many recent lines, or `Esc` to cancel |
|
||||||
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
|
| `Ctrl+Shift+V` | Cycle secondary subtitle display mode (hidden → visible → hover) |
|
||||||
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
|
| `Ctrl+Shift+A` | Mark the last added Anki card as an audio card (sets IsAudioCard, SentenceAudio, Sentence, Picture) |
|
||||||
| `Ctrl+Alt+A` | Open character dictionary AniList selector |
|
| `Ctrl+D` | Open loaded character dictionary manager |
|
||||||
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
|
| `Ctrl+Shift+O` | Open runtime options palette (session-only live toggles) |
|
||||||
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
|
| `Ctrl/Cmd+A` | Append clipboard video path to MPV playlist (fixed, not currently configurable) |
|
||||||
|
|
||||||
@@ -865,15 +877,15 @@ This is the single, shared connection to an OpenAI-compatible LLM endpoint. Conf
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ------------------ | -------------------- | ---------------------------------------------------------------------------------- |
|
| ------------------ | -------------------- | ------------------------------------------------------------------------------------ |
|
||||||
| `enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
|
| `enabled` | `true`, `false` | Enable shared AI provider features (default: `false`) |
|
||||||
| `apiKey` | string | Static API key for the shared provider |
|
| `apiKey` | string | Static API key for the shared provider |
|
||||||
| `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) |
|
| `apiKeyCommand` | string | Shell command used to resolve the API key (preferred over a plaintext `apiKey`) |
|
||||||
| `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) |
|
| `model` | string | Default model identifier requested from the provider (default: `openai/gpt-4o-mini`) |
|
||||||
| `baseUrl` | string (URL) | OpenAI-compatible base URL (default: `https://openrouter.ai/api`) |
|
| `baseUrl` | string (URL) | OpenAI-compatible base URL (default: `https://openrouter.ai/api`) |
|
||||||
| `systemPrompt` | string | Default system prompt sent with requests (default: a translation-engine prompt) |
|
| `systemPrompt` | string | Default system prompt sent with requests (default: a translation-engine prompt) |
|
||||||
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
|
| `requestTimeoutMs` | integer milliseconds | Shared request timeout (default: `15000`) |
|
||||||
|
|
||||||
SubMiner uses the shared provider for:
|
SubMiner uses the shared provider for:
|
||||||
|
|
||||||
@@ -1125,12 +1137,12 @@ Sync the active subtitle track from the overlay picker using `alass` or `ffsubsy
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Option | Values | Description |
|
| Option | Values | Description |
|
||||||
| ---------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | --------------- | ------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
|
| `alass_path` | string path | Path to `alass` executable. Empty or `null` resolves from `PATH`. `alass` must be installed separately. |
|
||||||
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
|
| `ffsubsync_path` | string path | Path to `ffsubsync` executable. Empty or `null` resolves from `PATH`. `ffsubsync` must be installed separately. |
|
||||||
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
| `ffmpeg_path` | string path | Path to `ffmpeg` (used for internal subtitle extraction). Empty or `null` falls back to `/usr/bin/ffmpeg`. |
|
||||||
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
| `replace` | `true`, `false` | When `true` (default), overwrite the active subtitle file on successful sync. When `false`, write `<name>_retimed.<ext>`. |
|
||||||
|
|
||||||
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
Default trigger is `Ctrl+Alt+S` via `shortcuts.triggerSubsync`.
|
||||||
Customize it there, or set it to `null` to disable.
|
Customize it there, or set it to `null` to disable.
|
||||||
@@ -1164,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`) |
|
| `enabled` | `true`, `false` | Enable AniList post-watch progress updates (default: `false`) |
|
||||||
| `accessToken` | string | Optional explicit AniList access token override (default: empty string) |
|
| `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.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.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 |
|
| `characterDictionary.evictionPolicy` | `"delete"`, `"disable"` | Legacy compatibility setting. Parsed and preserved, but merged dictionary eviction is now usage-based |
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ If you prefer to install it manually, see [manual launcher install](#manual-laun
|
|||||||
Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
|
Download the latest installer from [GitHub Releases](https://github.com/ksyasuda/SubMiner/releases/latest):
|
||||||
|
|
||||||
- `SubMiner-<version>.exe` — installer (recommended)
|
- `SubMiner-<version>.exe` — installer (recommended)
|
||||||
- `SubMiner-<version>.zip` — portable fallback
|
- `SubMiner-<version>-win.zip` — portable fallback
|
||||||
|
|
||||||
Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup.
|
Make sure `mpv.exe` is on your `PATH`, or set `mpv.executablePath` in the config during first-run setup.
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ subminer video.mkv # play a specific file (default plugin c
|
|||||||
subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
|
subminer https://youtu.be/... # YouTube playback (requires yt-dlp)
|
||||||
subminer --backend x11 video.mkv # Force x11 backend for a specific file
|
subminer --backend x11 video.mkv # Force x11 backend for a specific file
|
||||||
subminer -u # check for SubMiner updates
|
subminer -u # check for SubMiner updates
|
||||||
|
subminer logs -e # export sanitized log ZIP
|
||||||
subminer stats # open immersion dashboard
|
subminer stats # open immersion dashboard
|
||||||
subminer stats -b # start background stats daemon
|
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 stats cleanup` | Backfill vocabulary metadata and prune stale rows |
|
||||||
| `subminer doctor` | Dependency + config + socket diagnostics |
|
| `subminer doctor` | Dependency + config + socket diagnostics |
|
||||||
| `subminer settings` | Open the SubMiner settings window |
|
| `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 path` | Print active config file path |
|
||||||
| `subminer config show` | Print active config contents |
|
| `subminer config show` | Print active config contents |
|
||||||
| `subminer mpv status` | Check mpv socket readiness |
|
| `subminer mpv status` | Check mpv socket readiness |
|
||||||
|
|||||||
@@ -46,10 +46,16 @@
|
|||||||
// Logging
|
// Logging
|
||||||
// Controls logging verbosity.
|
// Controls logging verbosity.
|
||||||
// Set to debug for full runtime diagnostics.
|
// 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": {
|
"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.
|
}, // Controls logging verbosity.
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@@ -187,7 +193,7 @@
|
|||||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
|
"toggleSecondarySub": "CommandOrControl+Shift+V", // Accelerator that toggles the secondary subtitle bar visibility.
|
||||||
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
|
"markAudioCard": "CommandOrControl+Shift+A", // Accelerator that marks the last mined card as an audio card.
|
||||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal.
|
"openCharacterDictionaryManager": "CommandOrControl+D", // Accelerator that opens the character dictionary manager modal.
|
||||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||||
@@ -383,7 +389,8 @@
|
|||||||
"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
|
"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
|
"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
|
"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.
|
"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.
|
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||||
@@ -587,11 +594,10 @@
|
|||||||
"enabled": false, // Enable AniList post-watch progress updates. Values: true | false
|
"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.
|
"accessToken": "", // Optional explicit AniList access token override; leave empty to use locally stored token from setup.
|
||||||
"characterDictionary": {
|
"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.
|
"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.
|
"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
|
"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": {
|
"collapsibleSections": {
|
||||||
"description": false, // Open the Description section by default in character dictionary glossary entries. Values: true | false
|
"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
|
"characterInformation": false, // Open the Character Information section by default in character dictionary glossary entries. Values: true | false
|
||||||
@@ -624,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.
|
"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
|
"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.
|
"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
|
"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
|
"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
|
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||||
|
|||||||
@@ -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_VERSION_LINK_ORIGIN = 'local';
|
||||||
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir;
|
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir;
|
||||||
try {
|
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:
|
let routeHandler:
|
||||||
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
|
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|||||||
+12
-12
@@ -75,17 +75,17 @@ Mouse-hover playback behavior is configured separately from shortcuts: `subtitle
|
|||||||
|
|
||||||
## Subtitle & Feature Shortcuts
|
## Subtitle & Feature Shortcuts
|
||||||
|
|
||||||
| Shortcut | Action | Config key |
|
| Shortcut | Action | Config key |
|
||||||
| ------------------ | -------------------------------------------------------- | ----------------------------------- |
|
| ------------------ | -------------------------------------------------------- | ----------------------------------------------- |
|
||||||
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
| `Ctrl/Cmd+Shift+V` | Cycle secondary subtitle mode (hidden → visible → hover) | `shortcuts.toggleSecondarySub` |
|
||||||
| `Ctrl/Cmd+Alt+A` | Open character dictionary AniList selector | `shortcuts.openCharacterDictionary` |
|
| `Ctrl/Cmd+D` | Open loaded character dictionary manager | `shortcuts.openCharacterDictionaryManager` |
|
||||||
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
| `Ctrl/Cmd+Shift+O` | Open runtime options palette | `shortcuts.openRuntimeOptions` |
|
||||||
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
| `Ctrl/Cmd+/` | Open session help modal | `shortcuts.openSessionHelp` |
|
||||||
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
| `Ctrl+Shift+J` | Open Jimaku subtitle search modal | `shortcuts.openJimaku` |
|
||||||
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
| `Ctrl+Alt+C` | Open the manual YouTube subtitle picker | `keybindings` |
|
||||||
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
| `Ctrl+Alt+S` | Open subtitle sync (subsync) modal | `shortcuts.triggerSubsync` |
|
||||||
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
| `\` | Toggle subtitle sidebar | `subtitleSidebar.toggleKey` |
|
||||||
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
| `` ` `` | Toggle stats overlay | `stats.toggleKey` |
|
||||||
|
|
||||||
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
The stats toggle is handled inside the focused visible overlay window. It is configurable through the top-level `stats.toggleKey` setting and defaults to `Backquote`.
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ When the overlay has focus, press `y` then `d` to toggle DevTools (debugging hel
|
|||||||
|
|
||||||
## Customizing Shortcuts
|
## Customizing Shortcuts
|
||||||
|
|
||||||
All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+Alt+A"`. Use `null` to disable a shortcut.
|
All `shortcuts.*` keys accept [Electron accelerator strings](https://www.electronjs.org/docs/latest/tutorial/keyboard-shortcuts), for example `"CommandOrControl+D"`. Use `null` to disable a shortcut.
|
||||||
|
|
||||||
```jsonc
|
```jsonc
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,20 +37,22 @@ Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection
|
|||||||
|
|
||||||
## Character-Name Highlighting
|
## Character-Name Highlighting
|
||||||
|
|
||||||
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles — portraits, roles, voice actors, and biographical detail.
|
Character-name matches are built from the active merged SubMiner character dictionary, which auto-syncs character data from AniList for your recently-watched titles. When the current AniList media ID is known, SubMiner ignores loaded entries from other titles for subtitle name matching and inline portraits. Matching names are highlighted in subtitles and become available for hover-driven Yomitan character profiles — portraits, roles, voice actors, and biographical detail.
|
||||||
|
|
||||||
**How it works:**
|
**How it works:**
|
||||||
|
|
||||||
1. Subtitles are tokenized, then candidate name tokens are matched against the character dictionary via Yomitan's scanning pipeline.
|
1. Subtitles are tokenized, then candidate name tokens are matched against the character dictionary via Yomitan's scanning pipeline.
|
||||||
2. Matching tokens receive a dedicated style distinct from N+1 and frequency layers.
|
2. Matching tokens receive a dedicated style distinct from N+1 and frequency layers.
|
||||||
3. This layer can be independently toggled with `subtitleStyle.nameMatchEnabled`.
|
3. This layer can be independently toggled with `subtitleStyle.nameMatchEnabled`.
|
||||||
|
4. When `subtitleStyle.nameMatchImagesEnabled` is also enabled, SubMiner shows the cached AniList portrait beside matched names.
|
||||||
|
|
||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| -------------------------------- | --------- | ---------------------------------------- |
|
| -------------------------------------- | --------- | ------------------------------------------------ |
|
||||||
| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting |
|
| `subtitleStyle.nameMatchEnabled` | `false` | Enable character-name token highlighting |
|
||||||
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
|
| `subtitleStyle.nameMatchImagesEnabled` | `false` | Show small AniList portraits next to name tokens |
|
||||||
|
| `subtitleStyle.nameMatchColor` | `#f5bde6` | Color used for character-name matches |
|
||||||
|
|
||||||
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
|
For full details on dictionary generation, name variant expansion, auto-sync lifecycle, and configuration, see the dedicated [Character Dictionary](/character-dictionary) page.
|
||||||
|
|
||||||
@@ -67,14 +69,14 @@ SubMiner looks up each token's `frequencyRank` from `term_meta_bank_*.json` file
|
|||||||
|
|
||||||
**Key settings:**
|
**Key settings:**
|
||||||
|
|
||||||
| Option | Default | Description |
|
| Option | Default | Description |
|
||||||
| ------------------------------------------------ | ------------ | ---------------------------------------- |
|
| ------------------------------------------------ | ------------ | ---------------------------------------------------------------- |
|
||||||
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
|
| `subtitleStyle.frequencyDictionary.enabled` | `false` | Enable frequency highlighting |
|
||||||
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
|
| `subtitleStyle.frequencyDictionary.topX` | `1000` | Max frequency rank to highlight |
|
||||||
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
|
| `subtitleStyle.frequencyDictionary.mode` | `"single"` | `"single"` or `"banded"` |
|
||||||
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
|
| `subtitleStyle.frequencyDictionary.matchMode` | `"headword"` | `"headword"` or `"surface"` |
|
||||||
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for single mode |
|
| `subtitleStyle.frequencyDictionary.singleColor` | `#f5a97f` | Color for single mode |
|
||||||
| `subtitleStyle.frequencyDictionary.bandedColors` | 5 colors[^1] | Array of five hex colors for banded mode |
|
| `subtitleStyle.frequencyDictionary.bandedColors` | 5 colors[^1] | Array of five hex colors for banded mode |
|
||||||
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Custom path to frequency dictionary root (empty = auto-discover) |
|
| `subtitleStyle.frequencyDictionary.sourcePath` | `""` | Custom path to frequency dictionary root (empty = auto-discover) |
|
||||||
|
|
||||||
[^1]: Default banded palette (most common → least common): `#ed8796`, `#f5a97f`, `#f9e2af`, `#8bd5ca`, `#8aadf4`.
|
[^1]: Default banded palette (most common → least common): `#ed8796`, `#f5a97f`, `#f9e2af`, `#8bd5ca`, `#8aadf4`.
|
||||||
@@ -122,6 +124,7 @@ All annotation layers can be toggled at runtime via the mpv command menu without
|
|||||||
|
|
||||||
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
|
- `ankiConnect.knownWords.highlightEnabled` (`On` / `Off`)
|
||||||
- `subtitleStyle.nameMatchEnabled` (`On` / `Off`)
|
- `subtitleStyle.nameMatchEnabled` (`On` / `Off`)
|
||||||
|
- `subtitleStyle.nameMatchImagesEnabled` (`On` / `Off`)
|
||||||
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
- `subtitleStyle.enableJlpt` (`On` / `Off`)
|
||||||
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
|
- `subtitleStyle.frequencyDictionary.enabled` (`On` / `Off`)
|
||||||
|
|
||||||
|
|||||||
+9
-6
@@ -48,10 +48,10 @@ From there, subtitles render as interactive, hoverable word spans and you mine c
|
|||||||
|
|
||||||
### Ways to Launch
|
### Ways to Launch
|
||||||
|
|
||||||
| Approach | Use when | How |
|
| 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` 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` |
|
| **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 |
|
| **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.
|
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 jellyfin -d # Jellyfin cast-discovery mode (background tray app)
|
||||||
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
|
subminer app --stop # Stop background app (including Jellyfin cast broadcast)
|
||||||
subminer doctor # Dependency + config + socket diagnostics
|
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 path # Print active config path
|
||||||
subminer config show # Print active config contents
|
subminer config show # Print active config contents
|
||||||
subminer mpv socket # Print active mpv socket path
|
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 # Generate character dictionary ZIP for current anime
|
||||||
SubMiner.AppImage --dictionary-candidates # List AniList candidates for current character dictionary series
|
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 --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
|
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.
|
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
|
### 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 jellyfin` / `subminer jf`: Jellyfin-focused workflow aliases.
|
||||||
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
- `subminer doctor`: health checks for core dependencies and runtime paths.
|
||||||
- `subminer settings`: open the SubMiner settings window (also `subminer --settings`).
|
- `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 config`: config file helpers (`path`, `show`).
|
||||||
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
- `subminer mpv`: mpv helpers (`status`, `socket`, `idle`).
|
||||||
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
- `subminer dictionary <path>`: generates a Yomitan-importable character dictionary ZIP from a file/directory target.
|
||||||
@@ -220,7 +223,7 @@ Setup flow:
|
|||||||
|
|
||||||
AniList character dictionary auto-sync (optional):
|
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.
|
- 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`).
|
- Rotation limit defaults to 3 recent media snapshots in that merged dictionary (`maxLoaded`).
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -88,10 +88,11 @@ Notes:
|
|||||||
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
|
- AUR publish is best-effort: the workflow retries transient SSH clone/push failures, then warns and leaves the GitHub Release green if AUR still fails. Follow up with a manual `git push aur master` from the AUR checkout when needed.
|
||||||
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
- Required GitHub Actions secret: `AUR_SSH_PRIVATE_KEY`. Add the matching public key to your AUR account before relying on the automation.
|
||||||
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
|
- Release and prerelease workflows upload updater metadata (`*.yml`) and blockmaps (`*.blockmap`) alongside platform artifacts. Do not remove those files while `electron-updater` is enabled.
|
||||||
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS ZIP, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
|
- macOS tray app updates use the standard `electron-updater`/Squirrel path. Keep `latest-mac.yml`, the macOS `SubMiner-<version>-mac.zip`, and ZIP blockmap published; Squirrel uses the ZIP payload even when the DMG remains the user-facing installer.
|
||||||
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
|
- macOS update metadata and full ZIP downloads are routed through `/usr/bin/curl` before Squirrel installation to avoid Electron main-process network crashes on update checks.
|
||||||
- Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks.
|
- Windows tray app updates use the standard `electron-updater`/NSIS path. Keep `latest.yml`, the Windows NSIS installer, and installer blockmap published; updater HTTP is routed through main-process fetch to avoid Electron main-process network crashes during update checks.
|
||||||
|
- Build config emits distinct ZIP names: `SubMiner-<version>-mac.zip` for the macOS Squirrel updater payload and `SubMiner-<version>-win.zip` for the Windows portable fallback. The user-facing DMG and Windows installer keep the unqualified `SubMiner-<version>` basename.
|
||||||
- Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason.
|
- Linux GitHub release metadata and asset downloads also use `/usr/bin/curl` instead of Electron networking for the same reason.
|
||||||
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
|
- Local macOS build-output apps outside `/Applications` or `~/Applications` skip native update checks. Manual tray and launcher checks still use GitHub release metadata to report newer releases, but automatic notifications stay quiet when native app installation is unsupported. To validate auto-update end to end, install the signed and notarized app bundle into one of those Applications folders and point it at a published updater feed.
|
||||||
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
|
- The first updater-enabled release cannot update older installs automatically. Users need one manual install to get the updater code.
|
||||||
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
|
- Stable auto-update checks ignore beta/RC prereleases by default. Set `updates.channel` to `"prerelease"` on a test install when validating beta/RC updater behavior.
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ test('buildSubminerScriptOpts includes aniskip payload fields', () => {
|
|||||||
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
|
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
|
||||||
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
|
||||||
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
|
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_title=Frieren: Beyond Journey's End/);
|
||||||
assert.match(opts, /subminer-aniskip_season=1/);
|
assert.match(opts, /subminer-aniskip_season=1/);
|
||||||
assert.match(opts, /subminer-aniskip_episode=5/);
|
assert.match(opts, /subminer-aniskip_episode=5/);
|
||||||
|
|||||||
@@ -564,7 +564,7 @@ export function buildSubminerScriptOpts(
|
|||||||
appPath: string,
|
appPath: string,
|
||||||
socketPath: string,
|
socketPath: string,
|
||||||
aniSkipMetadata: AniSkipMetadata | null,
|
aniSkipMetadata: AniSkipMetadata | null,
|
||||||
logLevel: LogLevel = 'info',
|
_logLevel: LogLevel = 'info',
|
||||||
extraParts: string[] = [],
|
extraParts: string[] = [],
|
||||||
): string {
|
): string {
|
||||||
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
|
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
|
||||||
@@ -574,9 +574,6 @@ export function buildSubminerScriptOpts(
|
|||||||
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
|
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
|
||||||
...extraParts.map(sanitizeScriptOptValue),
|
...extraParts.map(sanitizeScriptOptValue),
|
||||||
];
|
];
|
||||||
if (logLevel !== 'info') {
|
|
||||||
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
|
|
||||||
}
|
|
||||||
if (aniSkipMetadata && aniSkipMetadata.title) {
|
if (aniSkipMetadata && aniSkipMetadata.title) {
|
||||||
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
|
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,43 @@
|
|||||||
import { launchTexthookerOnly, runAppCommandWithInherit } from '../mpv.js';
|
import {
|
||||||
|
launchAppBackgroundDetached,
|
||||||
|
launchTexthookerOnly,
|
||||||
|
runAppCommandWithInherit,
|
||||||
|
} from '../mpv.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
export function runAppPassthroughCommand(context: LauncherCommandContext): boolean {
|
type AppCommandDeps = {
|
||||||
|
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => void;
|
||||||
|
launchAppBackgroundDetached: (
|
||||||
|
appPath: string,
|
||||||
|
logLevel: LauncherCommandContext['args']['logLevel'],
|
||||||
|
) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultAppCommandDeps: AppCommandDeps = {
|
||||||
|
runAppCommandWithInherit,
|
||||||
|
launchAppBackgroundDetached,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function runAppPassthroughCommand(
|
||||||
|
context: LauncherCommandContext,
|
||||||
|
deps: AppCommandDeps = defaultAppCommandDeps,
|
||||||
|
): boolean {
|
||||||
const { args, appPath } = context;
|
const { args, appPath } = context;
|
||||||
if (!appPath) {
|
if (!appPath) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (args.settings) {
|
if (args.settings) {
|
||||||
runAppCommandWithInherit(appPath, ['--settings']);
|
deps.runAppCommandWithInherit(appPath, ['--settings']);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!args.appPassthrough) {
|
if (!args.appPassthrough) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
runAppCommandWithInherit(appPath, args.appArgs);
|
if (args.appArgs.length === 0) {
|
||||||
|
deps.launchAppBackgroundDetached(appPath, args.logLevel);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
deps.runAppCommandWithInherit(appPath, args.appArgs);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ import type { LauncherCommandContext } from './context.js';
|
|||||||
import { runConfigCommand } from './config-command.js';
|
import { runConfigCommand } from './config-command.js';
|
||||||
import { runDictionaryCommand } from './dictionary-command.js';
|
import { runDictionaryCommand } from './dictionary-command.js';
|
||||||
import { runDoctorCommand } from './doctor-command.js';
|
import { runDoctorCommand } from './doctor-command.js';
|
||||||
|
import { runLogsCommand } from './logs-command.js';
|
||||||
import { runMpvPreAppCommand } from './mpv-command.js';
|
import { runMpvPreAppCommand } from './mpv-command.js';
|
||||||
|
import { runAppPassthroughCommand } from './app-command.js';
|
||||||
import { runStatsCommand } from './stats-command.js';
|
import { runStatsCommand } from './stats-command.js';
|
||||||
import { runUpdateCommand } from './update-command.js';
|
import { runUpdateCommand } from './update-command.js';
|
||||||
|
|
||||||
@@ -168,6 +170,92 @@ test('doctor command forwards refresh-known-words to app binary', () => {
|
|||||||
assert.deepEqual(forwarded, [['--refresh-known-words']]);
|
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;
|
||||||
|
context.args.appArgs = [];
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const handled = runAppPassthroughCommand(context, {
|
||||||
|
runAppCommandWithInherit: () => {
|
||||||
|
calls.push('attached');
|
||||||
|
},
|
||||||
|
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||||
|
calls.push(`detached:${appPath}:${logLevel}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app command starts default Linux background app detached from launcher', () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args.appPassthrough = true;
|
||||||
|
context.args.appArgs = [];
|
||||||
|
const calls: string[] = [];
|
||||||
|
|
||||||
|
const handled = runAppPassthroughCommand(context, {
|
||||||
|
runAppCommandWithInherit: () => {
|
||||||
|
calls.push('attached');
|
||||||
|
},
|
||||||
|
launchAppBackgroundDetached: (appPath, logLevel) => {
|
||||||
|
calls.push(`detached:${appPath}:${logLevel}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('app command keeps explicit passthrough args attached', () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args.appPassthrough = true;
|
||||||
|
context.args.appArgs = ['--settings'];
|
||||||
|
const forwarded: string[][] = [];
|
||||||
|
const detached: string[] = [];
|
||||||
|
|
||||||
|
const handled = runAppPassthroughCommand(context, {
|
||||||
|
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||||
|
forwarded.push(appArgs);
|
||||||
|
},
|
||||||
|
launchAppBackgroundDetached: () => {
|
||||||
|
detached.push('detached');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(forwarded, [['--settings']]);
|
||||||
|
assert.deepEqual(detached, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
context.args.mpvStatus = true;
|
context.args.mpvStatus = true;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { runAppCommandWithInherit } from '../mpv.js';
|
import { runAppCommandWithInherit } from '../mpv.js';
|
||||||
|
import { shouldForwardLogLevel } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
interface DictionaryCommandDeps {
|
interface DictionaryCommandDeps {
|
||||||
@@ -35,7 +36,7 @@ export function runDictionaryCommand(
|
|||||||
if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) {
|
if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) {
|
||||||
forwarded.push('--dictionary-target', args.dictionaryTarget);
|
forwarded.push('--dictionary-target', args.dictionaryTarget);
|
||||||
}
|
}
|
||||||
if (args.logLevel !== 'info') {
|
if (shouldForwardLogLevel(args.logLevel)) {
|
||||||
forwarded.push('--log-level', args.logLevel);
|
forwarded.push('--log-level', args.logLevel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { fail } from '../log.js';
|
|||||||
import { runAppCommandWithInherit } from '../mpv.js';
|
import { runAppCommandWithInherit } from '../mpv.js';
|
||||||
import { commandExists } from '../util.js';
|
import { commandExists } from '../util.js';
|
||||||
import { runJellyfinPlayMenu } from '../jellyfin.js';
|
import { runJellyfinPlayMenu } from '../jellyfin.js';
|
||||||
|
import { shouldForwardLogLevel } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
export async function runJellyfinCommand(context: LauncherCommandContext): Promise<boolean> {
|
export async function runJellyfinCommand(context: LauncherCommandContext): Promise<boolean> {
|
||||||
@@ -18,7 +19,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
|
|
||||||
if (args.jellyfin) {
|
if (args.jellyfin) {
|
||||||
const forwarded = ['--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);
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
return true;
|
return true;
|
||||||
@@ -42,7 +43,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
'--jellyfin-password',
|
'--jellyfin-password',
|
||||||
password,
|
password,
|
||||||
];
|
];
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
|
||||||
appendPasswordStore(forwarded);
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
return true;
|
return true;
|
||||||
@@ -50,7 +51,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
|
|
||||||
if (args.jellyfinLogout) {
|
if (args.jellyfinLogout) {
|
||||||
const forwarded = ['--jellyfin-logout'];
|
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);
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
return true;
|
return true;
|
||||||
@@ -69,7 +70,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
|
|||||||
|
|
||||||
if (args.jellyfinDiscovery) {
|
if (args.jellyfinDiscovery) {
|
||||||
const forwarded = ['--background', '--jellyfin-remote-announce'];
|
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);
|
appendPasswordStore(forwarded);
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -36,6 +36,7 @@ function createContext(): LauncherCommandContext {
|
|||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
useRofi: false,
|
useRofi: false,
|
||||||
logLevel: 'info',
|
logLevel: 'info',
|
||||||
|
logRotation: 7,
|
||||||
passwordStore: '',
|
passwordStore: '',
|
||||||
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
|
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
|
||||||
targetKind: 'url',
|
targetKind: 'url',
|
||||||
@@ -55,6 +56,7 @@ function createContext(): LauncherCommandContext {
|
|||||||
stats: false,
|
stats: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
|
logsExport: false,
|
||||||
version: false,
|
version: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configPath: 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 () => {
|
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
|
||||||
const context = createContext();
|
const context = createContext();
|
||||||
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
|
||||||
|
const originalAppData = process.env.APPDATA;
|
||||||
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
|
||||||
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
|
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
|
||||||
fs.mkdirSync(expectedConfigDir, { recursive: true });
|
fs.mkdirSync(expectedConfigDir, { recursive: true });
|
||||||
@@ -347,6 +350,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
process.env.XDG_CONFIG_HOME = xdgConfigHome;
|
||||||
|
process.env.APPDATA = xdgConfigHome;
|
||||||
|
|
||||||
await runPlaybackCommandWithDeps(context, {
|
await runPlaybackCommandWithDeps(context, {
|
||||||
ensurePlaybackSetupReady: async () => {},
|
ensurePlaybackSetupReady: async () => {},
|
||||||
@@ -376,6 +380,11 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
|
|||||||
} else {
|
} else {
|
||||||
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
|
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 });
|
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import path from 'node:path';
|
|||||||
import { runAppCommandAttached } from '../mpv.js';
|
import { runAppCommandAttached } from '../mpv.js';
|
||||||
import { nowMs } from '../time.js';
|
import { nowMs } from '../time.js';
|
||||||
import { sleep } from '../util.js';
|
import { sleep } from '../util.js';
|
||||||
|
import { shouldForwardLogLevel } from '../types.js';
|
||||||
import type { LauncherCommandContext } from './context.js';
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
type StatsCommandResponse = {
|
type StatsCommandResponse = {
|
||||||
@@ -156,7 +157,7 @@ export async function runStatsCommand(
|
|||||||
if (args.statsCleanupLifetime) {
|
if (args.statsCleanupLifetime) {
|
||||||
forwarded.push('--stats-cleanup-lifetime');
|
forwarded.push('--stats-cleanup-lifetime');
|
||||||
}
|
}
|
||||||
if (args.logLevel !== 'info') {
|
if (shouldForwardLogLevel(args.logLevel)) {
|
||||||
forwarded.push('--log-level', args.logLevel);
|
forwarded.push('--log-level', args.logLevel);
|
||||||
}
|
}
|
||||||
const attachedExitPromise = resolvedDeps.runAppCommandAttached(
|
const attachedExitPromise = resolvedDeps.runAppCommandAttached(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ test('launcher root help lists subcommands', () => {
|
|||||||
assert.match(output, /doctor/);
|
assert.match(output, /doctor/);
|
||||||
assert.match(output, /config/);
|
assert.match(output, /config/);
|
||||||
assert.match(output, /mpv/);
|
assert.match(output, /mpv/);
|
||||||
|
assert.match(output, /logs/);
|
||||||
assert.match(output, /dictionary\|dict/);
|
assert.match(output, /dictionary\|dict/);
|
||||||
assert.match(output, /texthooker/);
|
assert.match(output, /texthooker/);
|
||||||
assert.match(output, /app\|bin/);
|
assert.match(output, /app\|bin/);
|
||||||
|
|||||||
+50
-1
@@ -1,12 +1,14 @@
|
|||||||
import { fail } from './log.js';
|
import { fail } from './log.js';
|
||||||
import type {
|
import type {
|
||||||
Args,
|
Args,
|
||||||
|
LauncherLoggingConfig,
|
||||||
LauncherJellyfinConfig,
|
LauncherJellyfinConfig,
|
||||||
LauncherMpvConfig,
|
LauncherMpvConfig,
|
||||||
LauncherYoutubeSubgenConfig,
|
LauncherYoutubeSubgenConfig,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
PluginRuntimeConfig,
|
PluginRuntimeConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { normalizeLogRotation } from '../src/shared/log-files.js';
|
||||||
import {
|
import {
|
||||||
applyInvocationsToArgs,
|
applyInvocationsToArgs,
|
||||||
applyRootOptionsToArgs,
|
applyRootOptionsToArgs,
|
||||||
@@ -52,6 +54,52 @@ export function loadLauncherMpvConfig(): LauncherMpvConfig {
|
|||||||
return parseLauncherMpvConfig(root);
|
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<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const files =
|
||||||
|
logging?.files && typeof logging.files === 'object' && !Array.isArray(logging.files)
|
||||||
|
? (logging.files as Record<string, unknown>)
|
||||||
|
: 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 {
|
export function hasLauncherExternalYomitanProfileConfig(): boolean {
|
||||||
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
|
||||||
}
|
}
|
||||||
@@ -65,9 +113,10 @@ export function parseArgs(
|
|||||||
scriptName: string,
|
scriptName: string,
|
||||||
launcherConfig: LauncherYoutubeSubgenConfig,
|
launcherConfig: LauncherYoutubeSubgenConfig,
|
||||||
launcherMpvConfig: LauncherMpvConfig = {},
|
launcherMpvConfig: LauncherMpvConfig = {},
|
||||||
|
launcherLoggingConfig: LauncherLoggingConfig = {},
|
||||||
): Args {
|
): Args {
|
||||||
const topLevelCommand = resolveTopLevelCommand(argv);
|
const topLevelCommand = resolveTopLevelCommand(argv);
|
||||||
const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig);
|
const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig, launcherLoggingConfig);
|
||||||
|
|
||||||
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
|
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
|
||||||
parsed.appPassthrough = true;
|
parsed.appPassthrough = true;
|
||||||
|
|||||||
@@ -51,6 +51,13 @@ test('createDefaultArgs seeds mpv profile from launcher config', () => {
|
|||||||
assert.equal(parsed.profile, 'anime');
|
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', () => {
|
test('applyRootOptionsToArgs appends CLI mpv profile to configured profile', () => {
|
||||||
const parsed = createDefaultArgs({}, { profile: 'anime' });
|
const parsed = createDefaultArgs({}, { profile: 'anime' });
|
||||||
|
|
||||||
@@ -131,6 +138,8 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
|||||||
doctorTriggered: false,
|
doctorTriggered: false,
|
||||||
doctorLogLevel: null,
|
doctorLogLevel: null,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
|
logsTriggered: false,
|
||||||
|
logsExport: false,
|
||||||
texthookerTriggered: false,
|
texthookerTriggered: false,
|
||||||
texthookerLogLevel: null,
|
texthookerLogLevel: null,
|
||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
@@ -175,6 +184,8 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
|
|||||||
doctorTriggered: false,
|
doctorTriggered: false,
|
||||||
doctorLogLevel: null,
|
doctorLogLevel: null,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
|
logsTriggered: false,
|
||||||
|
logsExport: false,
|
||||||
texthookerTriggered: false,
|
texthookerTriggered: false,
|
||||||
texthookerLogLevel: null,
|
texthookerLogLevel: null,
|
||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
@@ -212,6 +223,8 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
|
|||||||
doctorTriggered: false,
|
doctorTriggered: false,
|
||||||
doctorLogLevel: null,
|
doctorLogLevel: null,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
|
logsTriggered: false,
|
||||||
|
logsExport: false,
|
||||||
texthookerTriggered: false,
|
texthookerTriggered: false,
|
||||||
texthookerLogLevel: null,
|
texthookerLogLevel: null,
|
||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
@@ -247,6 +260,8 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
|||||||
doctorTriggered: false,
|
doctorTriggered: false,
|
||||||
doctorLogLevel: null,
|
doctorLogLevel: null,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
|
logsTriggered: false,
|
||||||
|
logsExport: false,
|
||||||
texthookerTriggered: true,
|
texthookerTriggered: true,
|
||||||
texthookerLogLevel: null,
|
texthookerLogLevel: null,
|
||||||
texthookerOpenBrowser: true,
|
texthookerOpenBrowser: true,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { fail } from '../log.js';
|
|||||||
import type {
|
import type {
|
||||||
Args,
|
Args,
|
||||||
Backend,
|
Backend,
|
||||||
|
LauncherLoggingConfig,
|
||||||
LauncherMpvConfig,
|
LauncherMpvConfig,
|
||||||
LauncherYoutubeSubgenConfig,
|
LauncherYoutubeSubgenConfig,
|
||||||
LogLevel,
|
LogLevel,
|
||||||
@@ -106,6 +107,7 @@ function parseDictionaryAnilistId(value: string): number {
|
|||||||
export function createDefaultArgs(
|
export function createDefaultArgs(
|
||||||
launcherConfig: LauncherYoutubeSubgenConfig,
|
launcherConfig: LauncherYoutubeSubgenConfig,
|
||||||
mpvConfig: LauncherMpvConfig = {},
|
mpvConfig: LauncherMpvConfig = {},
|
||||||
|
loggingConfig: LauncherLoggingConfig = {},
|
||||||
): Args {
|
): Args {
|
||||||
const configuredSecondaryLangs = uniqueNormalizedLangCodes(
|
const configuredSecondaryLangs = uniqueNormalizedLangCodes(
|
||||||
launcherConfig.secondarySubLanguages ?? [],
|
launcherConfig.secondarySubLanguages ?? [],
|
||||||
@@ -162,6 +164,7 @@ export function createDefaultArgs(
|
|||||||
statsCleanupLifetime: false,
|
statsCleanupLifetime: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
|
logsExport: false,
|
||||||
version: false,
|
version: false,
|
||||||
update: false,
|
update: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
@@ -195,7 +198,8 @@ export function createDefaultArgs(
|
|||||||
texthookerOnly: false,
|
texthookerOnly: false,
|
||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
useRofi: false,
|
useRofi: false,
|
||||||
logLevel: 'info',
|
logLevel: loggingConfig.level ?? 'warn',
|
||||||
|
logRotation: loggingConfig.rotation ?? 7,
|
||||||
passwordStore: '',
|
passwordStore: '',
|
||||||
target: '',
|
target: '',
|
||||||
targetKind: '',
|
targetKind: '',
|
||||||
@@ -260,6 +264,10 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
|||||||
}
|
}
|
||||||
if (invocations.doctorTriggered) parsed.doctor = true;
|
if (invocations.doctorTriggered) parsed.doctor = true;
|
||||||
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = 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.texthookerTriggered) parsed.texthookerOnly = true;
|
||||||
if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true;
|
if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true;
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export interface CliInvocations {
|
|||||||
doctorTriggered: boolean;
|
doctorTriggered: boolean;
|
||||||
doctorLogLevel: string | null;
|
doctorLogLevel: string | null;
|
||||||
doctorRefreshKnownWords: boolean;
|
doctorRefreshKnownWords: boolean;
|
||||||
|
logsTriggered: boolean;
|
||||||
|
logsExport: boolean;
|
||||||
texthookerTriggered: boolean;
|
texthookerTriggered: boolean;
|
||||||
texthookerLogLevel: string | null;
|
texthookerLogLevel: string | null;
|
||||||
texthookerOpenBrowser: boolean;
|
texthookerOpenBrowser: boolean;
|
||||||
@@ -91,6 +93,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
|||||||
'config',
|
'config',
|
||||||
'settings',
|
'settings',
|
||||||
'mpv',
|
'mpv',
|
||||||
|
'logs',
|
||||||
'dictionary',
|
'dictionary',
|
||||||
'dict',
|
'dict',
|
||||||
'stats',
|
'stats',
|
||||||
@@ -158,6 +161,8 @@ export function parseCliPrograms(
|
|||||||
let statsLogLevel: string | null = null;
|
let statsLogLevel: string | null = null;
|
||||||
let doctorLogLevel: string | null = null;
|
let doctorLogLevel: string | null = null;
|
||||||
let doctorRefreshKnownWords = false;
|
let doctorRefreshKnownWords = false;
|
||||||
|
let logsTriggered = false;
|
||||||
|
let logsExport = false;
|
||||||
let texthookerLogLevel: string | null = null;
|
let texthookerLogLevel: string | null = null;
|
||||||
let texthookerOpenBrowser = false;
|
let texthookerOpenBrowser = false;
|
||||||
let doctorTriggered = false;
|
let doctorTriggered = false;
|
||||||
@@ -294,6 +299,15 @@ export function parseCliPrograms(
|
|||||||
doctorRefreshKnownWords = options.refreshKnownWords === true;
|
doctorRefreshKnownWords = options.refreshKnownWords === true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('logs')
|
||||||
|
.description('Log file helpers')
|
||||||
|
.option('-e, --export', 'Export sanitized log archive')
|
||||||
|
.action((options: Record<string, unknown>) => {
|
||||||
|
logsTriggered = true;
|
||||||
|
logsExport = options.export === true;
|
||||||
|
});
|
||||||
|
|
||||||
commandProgram
|
commandProgram
|
||||||
.command('config')
|
.command('config')
|
||||||
.description('Config file helpers (path|show)')
|
.description('Config file helpers (path|show)')
|
||||||
@@ -388,6 +402,8 @@ export function parseCliPrograms(
|
|||||||
doctorTriggered,
|
doctorTriggered,
|
||||||
doctorLogLevel,
|
doctorLogLevel,
|
||||||
doctorRefreshKnownWords,
|
doctorRefreshKnownWords,
|
||||||
|
logsTriggered,
|
||||||
|
logsExport,
|
||||||
texthookerTriggered,
|
texthookerTriggered,
|
||||||
texthookerLogLevel,
|
texthookerLogLevel,
|
||||||
texthookerOpenBrowser,
|
texthookerOpenBrowser,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
|
|||||||
|
|
||||||
export function parsePluginRuntimeConfigFromMainConfig(
|
export function parsePluginRuntimeConfigFromMainConfig(
|
||||||
root: Record<string, unknown> | null,
|
root: Record<string, unknown> | null,
|
||||||
|
logLevel: LogLevel = 'info',
|
||||||
): PluginRuntimeConfig {
|
): PluginRuntimeConfig {
|
||||||
const mpvConfig = root ? parseLauncherMpvConfig(root) : {};
|
const mpvConfig = root ? parseLauncherMpvConfig(root) : {};
|
||||||
const texthooker = rootObject(root, 'texthooker');
|
const texthooker = rootObject(root, 'texthooker');
|
||||||
@@ -48,6 +49,7 @@ export function parsePluginRuntimeConfigFromMainConfig(
|
|||||||
socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH,
|
socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH,
|
||||||
binaryPath: mpvConfig.subminerBinaryPath ?? '',
|
binaryPath: mpvConfig.subminerBinaryPath ?? '',
|
||||||
backend: validBackendOrDefault(mpvConfig.backend, 'auto'),
|
backend: validBackendOrDefault(mpvConfig.backend, 'auto'),
|
||||||
|
logLevel,
|
||||||
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
|
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
|
||||||
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
|
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
|
||||||
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
|
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
|
||||||
@@ -65,7 +67,7 @@ export function buildPluginRuntimeScriptOptParts(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||||
const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject());
|
const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject(), logLevel);
|
||||||
|
|
||||||
log(
|
log(
|
||||||
'debug',
|
'debug',
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
JellyfinItemEntry,
|
JellyfinItemEntry,
|
||||||
JellyfinGroupEntry,
|
JellyfinGroupEntry,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
import { shouldForwardLogLevel } from './types.js';
|
||||||
import { log, fail, getMpvLogPath } from './log.js';
|
import { log, fail, getMpvLogPath } from './log.js';
|
||||||
import { nowMs } from './time.js';
|
import { nowMs } from './time.js';
|
||||||
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
||||||
@@ -1036,7 +1037,7 @@ export async function runJellyfinPlayMenu(
|
|||||||
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
|
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
|
||||||
}
|
}
|
||||||
const forwarded = ['--start', '--jellyfin-play', `--jellyfin-item-id=${itemId}`];
|
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);
|
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
|
||||||
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-5
@@ -1,6 +1,14 @@
|
|||||||
import type { LogLevel } from './types.js';
|
import type { LogLevel } from './types.js';
|
||||||
import { DEFAULT_MPV_LOG_FILE, getDefaultLauncherLogFile } from './types.js';
|
import { getDefaultLauncherLogFile } from './types.js';
|
||||||
import { appendLogLine, resolveDefaultLogFilePath } from '../src/shared/log-files.js';
|
import {
|
||||||
|
appendLogLine,
|
||||||
|
DEFAULT_LOG_ROTATION,
|
||||||
|
isLogFileEnabled,
|
||||||
|
normalizeLogRotation,
|
||||||
|
pruneLogDirectoryForPath,
|
||||||
|
resolveDefaultLogFilePath,
|
||||||
|
type LogRotation,
|
||||||
|
} from '../src/shared/log-files.js';
|
||||||
|
|
||||||
export const COLORS = {
|
export const COLORS = {
|
||||||
red: '\x1b[0;31m',
|
red: '\x1b[0;31m',
|
||||||
@@ -22,25 +30,36 @@ export function shouldLog(level: LogLevel, configured: LogLevel): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getMpvLogPath(): string {
|
export function getMpvLogPath(): string {
|
||||||
|
if (!isLogFileEnabled('mpv')) return '';
|
||||||
const envPath = process.env.SUBMINER_MPV_LOG?.trim();
|
const envPath = process.env.SUBMINER_MPV_LOG?.trim();
|
||||||
if (envPath) return envPath;
|
const logPath = envPath || resolveDefaultLogFilePath('mpv');
|
||||||
return DEFAULT_MPV_LOG_FILE;
|
pruneLogDirectoryForPath(logPath, getLogRotation());
|
||||||
|
return logPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getLauncherLogPath(): string {
|
export function getLauncherLogPath(): string {
|
||||||
|
if (!isLogFileEnabled('launcher')) return '';
|
||||||
const envPath = process.env.SUBMINER_LAUNCHER_LOG?.trim();
|
const envPath = process.env.SUBMINER_LAUNCHER_LOG?.trim();
|
||||||
if (envPath) return envPath;
|
if (envPath) return envPath;
|
||||||
return getDefaultLauncherLogFile();
|
return getDefaultLauncherLogFile();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAppLogPath(): string {
|
export function getAppLogPath(): string {
|
||||||
|
if (!isLogFileEnabled('app')) return '';
|
||||||
const envPath = process.env.SUBMINER_APP_LOG?.trim();
|
const envPath = process.env.SUBMINER_APP_LOG?.trim();
|
||||||
if (envPath) return envPath;
|
if (envPath) return envPath;
|
||||||
return resolveDefaultLogFilePath('app');
|
return resolveDefaultLogFilePath('app');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLogRotation(): LogRotation {
|
||||||
|
return normalizeLogRotation(process.env.SUBMINER_LOG_ROTATION) ?? DEFAULT_LOG_ROTATION;
|
||||||
|
}
|
||||||
|
|
||||||
function appendTimestampedLog(logPath: string, message: string): void {
|
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 {
|
export function appendToMpvLog(message: string): void {
|
||||||
|
|||||||
+32
-2
@@ -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/<user>')), true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('config path prefers jsonc over json for same directory', () => {
|
test('config path prefers jsonc over json for same directory', () => {
|
||||||
withTempDir((root) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
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) => {
|
withTempDir((root) => {
|
||||||
const homeDir = path.join(root, 'home');
|
const homeDir = path.join(root, 'home');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
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,
|
autoStartSubMiner: true,
|
||||||
pauseUntilOverlayReady: true,
|
pauseUntilOverlayReady: true,
|
||||||
},
|
},
|
||||||
|
logging: {
|
||||||
|
files: {
|
||||||
|
mpv: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
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);
|
const result = runLauncher(['--log-level', 'debug', videoPath], env);
|
||||||
|
|
||||||
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
|
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/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+20
-1
@@ -1,7 +1,9 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import packageJson from '../package.json';
|
import packageJson from '../package.json';
|
||||||
|
import { applyLogFileTogglesToEnv } from '../src/shared/log-files.js';
|
||||||
import {
|
import {
|
||||||
loadLauncherJellyfinConfig,
|
loadLauncherJellyfinConfig,
|
||||||
|
loadLauncherLoggingConfig,
|
||||||
loadLauncherMpvConfig,
|
loadLauncherMpvConfig,
|
||||||
loadLauncherYoutubeSubgenConfig,
|
loadLauncherYoutubeSubgenConfig,
|
||||||
parseArgs,
|
parseArgs,
|
||||||
@@ -16,6 +18,7 @@ import { runConfigCommand } from './commands/config-command.js';
|
|||||||
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
|
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
|
||||||
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
|
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
|
||||||
import { runDictionaryCommand } from './commands/dictionary-command.js';
|
import { runDictionaryCommand } from './commands/dictionary-command.js';
|
||||||
|
import { runLogsCommand } from './commands/logs-command.js';
|
||||||
import { runStatsCommand } from './commands/stats-command.js';
|
import { runStatsCommand } from './commands/stats-command.js';
|
||||||
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||||
@@ -61,7 +64,19 @@ async function main(): Promise<void> {
|
|||||||
const scriptName = path.basename(scriptPath);
|
const scriptName = path.basename(scriptPath);
|
||||||
const launcherConfig = loadLauncherYoutubeSubgenConfig();
|
const launcherConfig = loadLauncherYoutubeSubgenConfig();
|
||||||
const launcherMpvConfig = loadLauncherMpvConfig();
|
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) {
|
if (args.version) {
|
||||||
console.log(`SubMiner ${APP_VERSION}`);
|
console.log(`SubMiner ${APP_VERSION}`);
|
||||||
@@ -87,6 +102,10 @@ async function main(): Promise<void> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (runLogsCommand(context)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const resolvedAppPath = ensureAppPath(context);
|
const resolvedAppPath = ensureAppPath(context);
|
||||||
state.appPath = resolvedAppPath;
|
state.appPath = resolvedAppPath;
|
||||||
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
|
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
buildMpvEnv,
|
buildMpvEnv,
|
||||||
cleanupPlaybackSession,
|
cleanupPlaybackSession,
|
||||||
detectBackend,
|
detectBackend,
|
||||||
|
launchAppBackgroundDetached,
|
||||||
findAppBinary,
|
findAppBinary,
|
||||||
launchAppCommandDetached,
|
launchAppCommandDetached,
|
||||||
launchTexthookerOnly,
|
launchTexthookerOnly,
|
||||||
@@ -256,6 +257,7 @@ test('buildConfiguredMpvDefaultArgs appends maximized launch mode to configured
|
|||||||
'--sub-file-paths=.;subs;subtitles',
|
'--sub-file-paths=.;subs;subtitles',
|
||||||
'--sid=auto',
|
'--sid=auto',
|
||||||
'--secondary-sid=auto',
|
'--secondary-sid=auto',
|
||||||
|
'--sub-visibility=no',
|
||||||
'--secondary-sub-visibility=no',
|
'--secondary-sub-visibility=no',
|
||||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
@@ -424,6 +426,34 @@ test('launchAppCommandDetached handles child process spawn errors', async () =>
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('launchAppBackgroundDetached starts background child directly', async () => {
|
||||||
|
const { dir } = createTempSocketPath();
|
||||||
|
const appPath = path.join(dir, 'fake-subminer.sh');
|
||||||
|
const argsPath = path.join(dir, 'args.txt');
|
||||||
|
const envPath = path.join(dir, 'env.txt');
|
||||||
|
fs.writeFileSync(
|
||||||
|
appPath,
|
||||||
|
[
|
||||||
|
'#!/bin/sh',
|
||||||
|
`printf '%s\\n' "$@" > ${JSON.stringify(argsPath)}`,
|
||||||
|
`printf '%s\\n' "$SUBMINER_BACKGROUND_CHILD" > ${JSON.stringify(envPath)}`,
|
||||||
|
'',
|
||||||
|
].join('\n'),
|
||||||
|
);
|
||||||
|
fs.chmodSync(appPath, 0o755);
|
||||||
|
|
||||||
|
launchAppBackgroundDetached(appPath, 'info');
|
||||||
|
|
||||||
|
const deadline = Date.now() + 1000;
|
||||||
|
while ((!fs.existsSync(argsPath) || !fs.existsSync(envPath)) && Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(fs.readFileSync(argsPath, 'utf8').trim(), '--start\n--background');
|
||||||
|
assert.equal(fs.readFileSync(envPath, 'utf8').trim(), '1');
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
test('stopOverlay logs a warning when stop command cannot be spawned', () => {
|
test('stopOverlay logs a warning when stop command cannot be spawned', () => {
|
||||||
const originalWrite = process.stdout.write;
|
const originalWrite = process.stdout.write;
|
||||||
const writes: string[] = [];
|
const writes: string[] = [];
|
||||||
@@ -536,6 +566,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
texthookerOpenBrowser: false,
|
texthookerOpenBrowser: false,
|
||||||
useRofi: false,
|
useRofi: false,
|
||||||
logLevel: 'error',
|
logLevel: 'error',
|
||||||
|
logRotation: 7,
|
||||||
passwordStore: '',
|
passwordStore: '',
|
||||||
target: '',
|
target: '',
|
||||||
targetKind: '',
|
targetKind: '',
|
||||||
@@ -555,6 +586,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
|||||||
stats: false,
|
stats: false,
|
||||||
doctor: false,
|
doctor: false,
|
||||||
doctorRefreshKnownWords: false,
|
doctorRefreshKnownWords: false,
|
||||||
|
logsExport: false,
|
||||||
version: false,
|
version: false,
|
||||||
settings: false,
|
settings: false,
|
||||||
configPath: false,
|
configPath: false,
|
||||||
|
|||||||
+59
-14
@@ -4,6 +4,7 @@ import os from 'node:os';
|
|||||||
import net from 'node:net';
|
import net from 'node:net';
|
||||||
import { spawn, spawnSync } from 'node:child_process';
|
import { spawn, spawnSync } from 'node:child_process';
|
||||||
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
|
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
|
||||||
|
import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js';
|
||||||
import {
|
import {
|
||||||
isAppControlServerAvailable as checkAppControlServerAvailable,
|
isAppControlServerAvailable as checkAppControlServerAvailable,
|
||||||
sendAppControlCommand,
|
sendAppControlCommand,
|
||||||
@@ -14,7 +15,11 @@ import {
|
|||||||
type InstalledMpvPluginDetection,
|
type InstalledMpvPluginDetection,
|
||||||
} from '../src/main/runtime/first-run-setup-plugin.js';
|
} from '../src/main/runtime/first-run-setup-plugin.js';
|
||||||
import type { LogLevel, Backend, Args, MpvTrack, PluginRuntimeConfig } from './types.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 { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
||||||
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||||
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
|
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
|
||||||
@@ -47,13 +52,17 @@ type SpawnTarget = {
|
|||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>;
|
type PathModule = Pick<
|
||||||
|
typeof path,
|
||||||
|
'dirname' | 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve' | 'isAbsolute' | 'normalize'
|
||||||
|
>;
|
||||||
|
|
||||||
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
|
||||||
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
const OVERLAY_START_SOCKET_READY_TIMEOUT_MS = 900;
|
||||||
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
const OVERLAY_START_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
||||||
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||||
|
const BACKGROUND_CHILD_ENV = 'SUBMINER_BACKGROUND_CHILD';
|
||||||
|
|
||||||
export interface LauncherRuntimePluginPlan {
|
export interface LauncherRuntimePluginPlan {
|
||||||
scriptPath: string | null;
|
scriptPath: string | null;
|
||||||
@@ -62,6 +71,12 @@ export interface LauncherRuntimePluginPlan {
|
|||||||
errorMessage: string | null;
|
errorMessage: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvePluginCandidatePath(candidate: string, pathModule: PathModule): string {
|
||||||
|
return pathModule.isAbsolute(candidate)
|
||||||
|
? pathModule.normalize(candidate)
|
||||||
|
: pathModule.resolve(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
export function parseMpvArgString(input: string): string[] {
|
export function parseMpvArgString(input: string): string[] {
|
||||||
const chars = input;
|
const chars = input;
|
||||||
const args: string[] = [];
|
const args: string[] = [];
|
||||||
@@ -291,12 +306,12 @@ export function resolveLauncherRuntimePluginPath(options: {
|
|||||||
pathModule?: typeof path;
|
pathModule?: typeof path;
|
||||||
existsSync?: (candidate: string) => boolean;
|
existsSync?: (candidate: string) => boolean;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
|
const platform = options.platform ?? process.platform;
|
||||||
const pathModule = options.pathModule ?? path;
|
const pathModule = options.pathModule ?? path;
|
||||||
const existsSync = options.existsSync ?? fs.existsSync;
|
const existsSync = options.existsSync ?? fs.existsSync;
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
const dirname = options.dirname ?? __dirname;
|
const dirname = options.dirname ?? __dirname;
|
||||||
const cwd = options.cwd ?? process.cwd();
|
const cwd = options.cwd ?? process.cwd();
|
||||||
const platform = options.platform ?? process.platform;
|
|
||||||
const homeDir = options.homeDir ?? os.homedir();
|
const homeDir = options.homeDir ?? os.homedir();
|
||||||
const candidates: string[] = [];
|
const candidates: string[] = [];
|
||||||
|
|
||||||
@@ -344,7 +359,7 @@ export function resolveLauncherRuntimePluginPath(options: {
|
|||||||
|
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
const resolved = pathModule.resolve(candidate);
|
const resolved = resolvePluginCandidatePath(candidate, pathModule);
|
||||||
if (seen.has(resolved)) continue;
|
if (seen.has(resolved)) continue;
|
||||||
seen.add(resolved);
|
seen.add(resolved);
|
||||||
const entrypoint = normalizeRuntimePluginEntrypoint(resolved, { pathModule, existsSync });
|
const entrypoint = normalizeRuntimePluginEntrypoint(resolved, { pathModule, existsSync });
|
||||||
@@ -941,7 +956,7 @@ export async function startMpv(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
mpvArgs.push(`--script-opts=${scriptOpts}`);
|
mpvArgs.push(`--script-opts=${scriptOpts}`);
|
||||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
fs.rmSync(socketPath, { force: true });
|
fs.rmSync(socketPath, { force: true });
|
||||||
@@ -1021,7 +1036,7 @@ export async function startOverlay(
|
|||||||
socketPath,
|
socketPath,
|
||||||
...extraAppArgs,
|
...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');
|
if (args.useTexthooker) overlayArgs.push('--texthooker');
|
||||||
|
|
||||||
const controlResult = await sendAppControlCommand(overlayArgs, {
|
const controlResult = await sendAppControlCommand(overlayArgs, {
|
||||||
@@ -1166,7 +1181,7 @@ export function launchTexthookerOnly(
|
|||||||
): never {
|
): never {
|
||||||
const overlayArgs = ['--texthooker'];
|
const overlayArgs = ['--texthooker'];
|
||||||
if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser');
|
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...');
|
log('info', args.logLevel, 'Launching texthooker mode...');
|
||||||
const result = runSyncAppCommand(appPath, overlayArgs, true);
|
const result = runSyncAppCommand(appPath, overlayArgs, true);
|
||||||
@@ -1244,7 +1259,7 @@ function stopManagedOverlayApp(args: Args): void {
|
|||||||
log('info', args.logLevel, 'Stopping SubMiner overlay...');
|
log('info', args.logLevel, 'Stopping SubMiner overlay...');
|
||||||
|
|
||||||
const stopArgs = ['--stop'];
|
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 target = resolveAppSpawnTarget(state.appPath, stopArgs);
|
||||||
const result = spawnSync(target.command, target.args, {
|
const result = spawnSync(target.command, target.args, {
|
||||||
@@ -1296,6 +1311,8 @@ function buildAppEnv(
|
|||||||
...baseEnv,
|
...baseEnv,
|
||||||
SUBMINER_APP_LOG: getAppLogPath(),
|
SUBMINER_APP_LOG: getAppLogPath(),
|
||||||
SUBMINER_MPV_LOG: getMpvLogPath(),
|
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;
|
delete env.ELECTRON_RUN_AS_NODE;
|
||||||
clearTransportedAppArgs(env);
|
clearTransportedAppArgs(env);
|
||||||
@@ -1316,10 +1333,13 @@ function buildAppEnv(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildMpvEnv(
|
export function buildMpvEnv(
|
||||||
args: Pick<Args, 'backend'>,
|
args: Pick<Args, 'backend' | 'logLevel' | 'logRotation'>,
|
||||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||||
): NodeJS.ProcessEnv {
|
): 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)) {
|
if (!shouldForceX11MpvBackend(args, env)) {
|
||||||
return env;
|
return env;
|
||||||
}
|
}
|
||||||
@@ -1576,15 +1596,24 @@ export function runAppCommandWithInheritLogged(
|
|||||||
|
|
||||||
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
|
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
|
||||||
const startArgs = ['--start'];
|
const startArgs = ['--start'];
|
||||||
if (logLevel !== 'info') startArgs.push('--log-level', logLevel);
|
if (shouldForwardLogLevel(logLevel)) startArgs.push('--log-level', logLevel);
|
||||||
launchAppCommandDetached(appPath, startArgs, logLevel, 'start');
|
launchAppCommandDetached(appPath, startArgs, logLevel, 'start');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function launchAppBackgroundDetached(appPath: string, logLevel: LogLevel): void {
|
||||||
|
const startArgs = ['--start', '--background'];
|
||||||
|
if (shouldForwardLogLevel(logLevel)) startArgs.push('--log-level', logLevel);
|
||||||
|
launchAppCommandDetached(appPath, startArgs, logLevel, 'app', {
|
||||||
|
[BACKGROUND_CHILD_ENV]: '1',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function launchAppCommandDetached(
|
export function launchAppCommandDetached(
|
||||||
appPath: string,
|
appPath: string,
|
||||||
appArgs: string[],
|
appArgs: string[],
|
||||||
logLevel: LogLevel,
|
logLevel: LogLevel,
|
||||||
label: string,
|
label: string,
|
||||||
|
extraEnv: NodeJS.ProcessEnv = {},
|
||||||
): void {
|
): void {
|
||||||
if (maybeCaptureAppArgs(appArgs)) {
|
if (maybeCaptureAppArgs(appArgs)) {
|
||||||
return;
|
return;
|
||||||
@@ -1596,6 +1625,22 @@ export function launchAppCommandDetached(
|
|||||||
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
|
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
|
||||||
);
|
);
|
||||||
const appLogPath = getAppLogPath();
|
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 });
|
fs.mkdirSync(path.dirname(appLogPath), { recursive: true });
|
||||||
const stdoutFd = fs.openSync(appLogPath, 'a');
|
const stdoutFd = fs.openSync(appLogPath, 'a');
|
||||||
const stderrFd = fs.openSync(appLogPath, 'a');
|
const stderrFd = fs.openSync(appLogPath, 'a');
|
||||||
@@ -1603,7 +1648,7 @@ export function launchAppCommandDetached(
|
|||||||
const proc = spawn(target.command, target.args, {
|
const proc = spawn(target.command, target.args, {
|
||||||
stdio: ['ignore', stdoutFd, stderrFd],
|
stdio: ['ignore', stdoutFd, stderrFd],
|
||||||
detached: true,
|
detached: true,
|
||||||
env: buildAppEnv(process.env, target.env),
|
env: buildAppEnv(process.env, { ...target.env, ...extraEnv }),
|
||||||
});
|
});
|
||||||
proc.once('error', (error) => {
|
proc.once('error', (error) => {
|
||||||
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
|
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
|
||||||
@@ -1654,7 +1699,7 @@ export function launchMpvIdleDetached(
|
|||||||
runtimeScriptOpts,
|
runtimeScriptOpts,
|
||||||
)}`,
|
)}`,
|
||||||
);
|
);
|
||||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
|
||||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||||
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
|
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
|
||||||
normalizeWindowsShellArgs: false,
|
normalizeWindowsShellArgs: false,
|
||||||
@@ -1704,7 +1749,7 @@ export async function waitForUnixSocketReady(
|
|||||||
const deadline = nowMs() + timeoutMs;
|
const deadline = nowMs() + timeoutMs;
|
||||||
while (nowMs() < deadline) {
|
while (nowMs() < deadline) {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(socketPath)) {
|
if (process.platform === 'win32' || fs.existsSync(socketPath)) {
|
||||||
const ready = await canConnectUnixSocket(socketPath);
|
const ready = await canConnectUnixSocket(socketPath);
|
||||||
if (ready) return true;
|
if (ready) return true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,3 +244,18 @@ test('parseArgs maps doctor refresh-known-words flag', () => {
|
|||||||
assert.equal(parsed.doctor, true);
|
assert.equal(parsed.doctor, true);
|
||||||
assert.equal(parsed.doctorRefreshKnownWords, 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/);
|
||||||
|
});
|
||||||
|
|||||||
+2
-2
@@ -365,8 +365,8 @@ export function findRofiTheme(scriptPath: string): string | null {
|
|||||||
} else {
|
} else {
|
||||||
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share');
|
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share');
|
||||||
candidates.push(path.join(xdgDataHome, 'SubMiner/themes', ROFI_THEME_FILE));
|
candidates.push(path.join(xdgDataHome, 'SubMiner/themes', ROFI_THEME_FILE));
|
||||||
candidates.push(path.join('/usr/local/share/SubMiner/themes', ROFI_THEME_FILE));
|
candidates.push(path.posix.join('/usr/local/share/SubMiner/themes', ROFI_THEME_FILE));
|
||||||
candidates.push(path.join('/usr/share/SubMiner/themes', ROFI_THEME_FILE));
|
candidates.push(path.posix.join('/usr/share/SubMiner/themes', ROFI_THEME_FILE));
|
||||||
}
|
}
|
||||||
|
|
||||||
candidates.push(path.join(scriptDir, 'assets', 'themes', ROFI_THEME_FILE));
|
candidates.push(path.join(scriptDir, 'assets', 'themes', ROFI_THEME_FILE));
|
||||||
|
|||||||
+38
-10
@@ -23,6 +23,8 @@ type SmokeCase = {
|
|||||||
artifactsDir: string;
|
artifactsDir: string;
|
||||||
binDir: string;
|
binDir: string;
|
||||||
xdgConfigHome: string;
|
xdgConfigHome: string;
|
||||||
|
appDataDir: string;
|
||||||
|
localAppDataDir: string;
|
||||||
homeDir: string;
|
homeDir: string;
|
||||||
socketDir: string;
|
socketDir: string;
|
||||||
socketPath: string;
|
socketPath: string;
|
||||||
@@ -40,6 +42,19 @@ function writeExecutable(filePath: string, body: string): void {
|
|||||||
fs.chmodSync(filePath, 0o755);
|
fs.chmodSync(filePath, 0o755);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function writeFixtureExecutable(basePath: string, body: string): string {
|
||||||
|
if (process.platform !== 'win32') {
|
||||||
|
writeExecutable(basePath, body);
|
||||||
|
return basePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scriptPath = `${basePath}.js`;
|
||||||
|
const commandPath = `${basePath}.cmd`;
|
||||||
|
fs.writeFileSync(scriptPath, body);
|
||||||
|
fs.writeFileSync(commandPath, `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`);
|
||||||
|
return commandPath;
|
||||||
|
}
|
||||||
|
|
||||||
function createSmokeCase(name: string): SmokeCase {
|
function createSmokeCase(name: string): SmokeCase {
|
||||||
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-smoke');
|
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-smoke');
|
||||||
fs.mkdirSync(baseDir, { recursive: true });
|
fs.mkdirSync(baseDir, { recursive: true });
|
||||||
@@ -48,19 +63,21 @@ function createSmokeCase(name: string): SmokeCase {
|
|||||||
const artifactsDir = path.join(root, 'artifacts');
|
const artifactsDir = path.join(root, 'artifacts');
|
||||||
const binDir = path.join(root, 'bin');
|
const binDir = path.join(root, 'bin');
|
||||||
const xdgConfigHome = path.join(root, 'xdg');
|
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 homeDir = path.join(root, 'home');
|
||||||
const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-smoke-sock-'));
|
const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-smoke-sock-'));
|
||||||
const socketPath = path.join(socketDir, 'subminer.sock');
|
const socketPath = path.join(socketDir, 'subminer.sock');
|
||||||
const videoPath = path.join(root, 'video.mkv');
|
const videoPath = path.join(root, 'video.mkv');
|
||||||
const fakeAppPath = path.join(binDir, 'fake-subminer');
|
const fakeAppBasePath = path.join(binDir, 'fake-subminer');
|
||||||
const fakeMpvPath = path.join(binDir, 'mpv');
|
const fakeMpvBasePath = path.join(binDir, 'mpv');
|
||||||
const mpvOverlayLogPath = path.join(artifactsDir, 'mpv-overlay.log');
|
const mpvOverlayLogPath = path.join(artifactsDir, 'mpv-overlay.log');
|
||||||
|
|
||||||
fs.mkdirSync(artifactsDir, { recursive: true });
|
fs.mkdirSync(artifactsDir, { recursive: true });
|
||||||
fs.mkdirSync(binDir, { recursive: true });
|
fs.mkdirSync(binDir, { recursive: true });
|
||||||
fs.writeFileSync(videoPath, 'fake video fixture');
|
fs.writeFileSync(videoPath, 'fake video fixture');
|
||||||
|
|
||||||
const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir });
|
const configDir = getDefaultConfigDir({ xdgConfigHome, appDataDir, homeDir });
|
||||||
fs.mkdirSync(configDir, { recursive: true });
|
fs.mkdirSync(configDir, { recursive: true });
|
||||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } }));
|
fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } }));
|
||||||
const setupState = createDefaultSetupState();
|
const setupState = createDefaultSetupState();
|
||||||
@@ -74,8 +91,8 @@ function createSmokeCase(name: string): SmokeCase {
|
|||||||
const fakeAppStartLogPath = path.join(artifactsDir, 'fake-app-start.log');
|
const fakeAppStartLogPath = path.join(artifactsDir, 'fake-app-start.log');
|
||||||
const fakeAppStopLogPath = path.join(artifactsDir, 'fake-app-stop.log');
|
const fakeAppStopLogPath = path.join(artifactsDir, 'fake-app-stop.log');
|
||||||
|
|
||||||
writeExecutable(
|
const fakeMpvPath = writeFixtureExecutable(
|
||||||
fakeMpvPath,
|
fakeMpvBasePath,
|
||||||
`#!/usr/bin/env bun
|
`#!/usr/bin/env bun
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const net = require('node:net');
|
const net = require('node:net');
|
||||||
@@ -113,8 +130,8 @@ process.on('SIGTERM', closeAndExit);
|
|||||||
`,
|
`,
|
||||||
);
|
);
|
||||||
|
|
||||||
writeExecutable(
|
const fakeAppPath = writeFixtureExecutable(
|
||||||
fakeAppPath,
|
fakeAppBasePath,
|
||||||
`#!/usr/bin/env bun
|
`#!/usr/bin/env bun
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
|
||||||
@@ -146,6 +163,8 @@ process.exit(0);
|
|||||||
artifactsDir,
|
artifactsDir,
|
||||||
binDir,
|
binDir,
|
||||||
xdgConfigHome,
|
xdgConfigHome,
|
||||||
|
appDataDir,
|
||||||
|
localAppDataDir,
|
||||||
homeDir,
|
homeDir,
|
||||||
socketDir,
|
socketDir,
|
||||||
socketPath,
|
socketPath,
|
||||||
@@ -157,14 +176,23 @@ process.exit(0);
|
|||||||
}
|
}
|
||||||
|
|
||||||
function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
|
function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
|
||||||
return {
|
const env: NodeJS.ProcessEnv = {
|
||||||
...process.env,
|
...process.env,
|
||||||
HOME: smokeCase.homeDir,
|
HOME: smokeCase.homeDir,
|
||||||
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
|
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
|
||||||
|
APPDATA: smokeCase.appDataDir,
|
||||||
|
LOCALAPPDATA: smokeCase.localAppDataDir,
|
||||||
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
|
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
|
||||||
SUBMINER_MPV_LOG: smokeCase.mpvOverlayLogPath,
|
SUBMINER_MPV_LOG: smokeCase.mpvOverlayLogPath,
|
||||||
PATH: `${smokeCase.binDir}${path.delimiter}${process.env.PATH || ''}`,
|
|
||||||
};
|
};
|
||||||
|
const pathKey = Object.keys(env).find((key) => key.toLowerCase() === 'path') ?? 'PATH';
|
||||||
|
env[pathKey] = `${smokeCase.binDir}${path.delimiter}${env[pathKey] || ''}`;
|
||||||
|
for (const key of Object.keys(env)) {
|
||||||
|
if (key !== pathKey && key.toLowerCase() === 'path') {
|
||||||
|
delete env[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return env;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runLauncher(
|
function runLauncher(
|
||||||
@@ -390,7 +418,7 @@ test(
|
|||||||
const env = makeTestEnv(smokeCase);
|
const env = makeTestEnv(smokeCase);
|
||||||
const result = runLauncher(
|
const result = runLauncher(
|
||||||
smokeCase,
|
smokeCase,
|
||||||
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
|
['--backend', 'x11', '--log-level', 'info', '--start-overlay', smokeCase.videoPath],
|
||||||
env,
|
env,
|
||||||
'overlay-start-stop',
|
'overlay-start-stop',
|
||||||
);
|
);
|
||||||
|
|||||||
+18
-1
@@ -1,7 +1,11 @@
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
|
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 { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
||||||
|
|
||||||
export const ROFI_THEME_FILE = 'subminer.rasi';
|
export const ROFI_THEME_FILE = 'subminer.rasi';
|
||||||
@@ -60,12 +64,16 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
|
|||||||
'--sub-file-paths=.;subs;subtitles',
|
'--sub-file-paths=.;subs;subtitles',
|
||||||
'--sid=auto',
|
'--sid=auto',
|
||||||
'--secondary-sid=auto',
|
'--secondary-sid=auto',
|
||||||
|
'--sub-visibility=no',
|
||||||
'--secondary-sub-visibility=no',
|
'--secondary-sub-visibility=no',
|
||||||
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
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 Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
|
||||||
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
|
||||||
|
|
||||||
@@ -105,6 +113,7 @@ export interface Args {
|
|||||||
texthookerOpenBrowser: boolean;
|
texthookerOpenBrowser: boolean;
|
||||||
useRofi: boolean;
|
useRofi: boolean;
|
||||||
logLevel: LogLevel;
|
logLevel: LogLevel;
|
||||||
|
logRotation: LogRotation;
|
||||||
passwordStore: string;
|
passwordStore: string;
|
||||||
target: string;
|
target: string;
|
||||||
targetKind: '' | 'file' | 'url';
|
targetKind: '' | 'file' | 'url';
|
||||||
@@ -131,6 +140,7 @@ export interface Args {
|
|||||||
dictionaryTarget?: string;
|
dictionaryTarget?: string;
|
||||||
doctor: boolean;
|
doctor: boolean;
|
||||||
doctorRefreshKnownWords: boolean;
|
doctorRefreshKnownWords: boolean;
|
||||||
|
logsExport: boolean;
|
||||||
version: boolean;
|
version: boolean;
|
||||||
update?: boolean;
|
update?: boolean;
|
||||||
settings: boolean;
|
settings: boolean;
|
||||||
@@ -185,10 +195,17 @@ export interface LauncherMpvConfig {
|
|||||||
aniskipButtonKey?: string;
|
aniskipButtonKey?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LauncherLoggingConfig {
|
||||||
|
level?: LogLevel;
|
||||||
|
rotation?: LogRotation;
|
||||||
|
files?: Partial<LogFileToggles>;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PluginRuntimeConfig {
|
export interface PluginRuntimeConfig {
|
||||||
socketPath: string;
|
socketPath: string;
|
||||||
binaryPath: string;
|
binaryPath: string;
|
||||||
backend: Backend;
|
backend: Backend;
|
||||||
|
logLevel?: LogLevel;
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
autoStartVisibleOverlay: boolean;
|
autoStartVisibleOverlay: boolean;
|
||||||
autoStartPauseUntilReady: boolean;
|
autoStartPauseUntilReady: boolean;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.15.0-beta.3",
|
"version": "0.15.0-beta.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"version": "0.15.0-beta.3",
|
"version": "0.15.0-beta.8",
|
||||||
"license": "GPL-3.0-or-later",
|
"license": "GPL-3.0-or-later",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.8",
|
"@fontsource-variable/geist": "^5.2.8",
|
||||||
|
|||||||
+9
-3
@@ -2,7 +2,7 @@
|
|||||||
"name": "subminer",
|
"name": "subminer",
|
||||||
"productName": "SubMiner",
|
"productName": "SubMiner",
|
||||||
"desktopName": "SubMiner.desktop",
|
"desktopName": "SubMiner.desktop",
|
||||||
"version": "0.15.0-beta.4",
|
"version": "0.15.0-beta.9",
|
||||||
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
"description": "All-in-one sentence mining overlay with AnkiConnect and dictionary integration",
|
||||||
"packageManager": "bun@1.3.5",
|
"packageManager": "bun@1.3.5",
|
||||||
"main": "dist/main-entry.js",
|
"main": "dist/main-entry.js",
|
||||||
@@ -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: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: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: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/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-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/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/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: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/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: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: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: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",
|
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||||
@@ -158,6 +158,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"mac": {
|
"mac": {
|
||||||
|
"artifactName": "SubMiner-${version}-mac.${ext}",
|
||||||
"target": [
|
"target": [
|
||||||
"dmg",
|
"dmg",
|
||||||
"zip"
|
"zip"
|
||||||
@@ -174,7 +175,11 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"dmg": {
|
||||||
|
"artifactName": "SubMiner-${version}.${ext}"
|
||||||
|
},
|
||||||
"win": {
|
"win": {
|
||||||
|
"artifactName": "SubMiner-${version}-win.${ext}",
|
||||||
"target": [
|
"target": [
|
||||||
"nsis",
|
"nsis",
|
||||||
"zip"
|
"zip"
|
||||||
@@ -182,6 +187,7 @@
|
|||||||
"icon": "assets/SubMiner.ico"
|
"icon": "assets/SubMiner.ico"
|
||||||
},
|
},
|
||||||
"nsis": {
|
"nsis": {
|
||||||
|
"artifactName": "SubMiner-${version}.${ext}",
|
||||||
"oneClick": false,
|
"oneClick": false,
|
||||||
"perMachine": false,
|
"perMachine": false,
|
||||||
"allowToChangeInstallationDirectory": true,
|
"allowToChangeInstallationDirectory": true,
|
||||||
|
|||||||
@@ -13,6 +13,16 @@ function M.create(ctx)
|
|||||||
local APP_RUNNING_CACHE_TTL_SECONDS = 2
|
local APP_RUNNING_CACHE_TTL_SECONDS = 2
|
||||||
|
|
||||||
local function is_windows()
|
local function is_windows()
|
||||||
|
local platform = mp.get_property("platform") or ""
|
||||||
|
if platform ~= "" then
|
||||||
|
local normalized = platform:lower()
|
||||||
|
if normalized == "windows" or normalized == "win32" then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
if normalized == "macos" or normalized == "darwin" or normalized == "osx" or normalized == "linux" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
return package.config:sub(1, 1) == "\\"
|
return package.config:sub(1, 1) == "\\"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -126,7 +126,9 @@ function M.create(ctx)
|
|||||||
subminer_log(
|
subminer_log(
|
||||||
"info",
|
"info",
|
||||||
"lifecycle",
|
"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)
|
schedule_aniskip_fetch("file-loaded", 0)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
|
|
||||||
local function should_log(level)
|
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)
|
local target = normalize_log_level(level)
|
||||||
return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current]
|
return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current]
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -172,13 +172,29 @@ function M.create(ctx)
|
|||||||
return trimmed
|
return trimmed
|
||||||
end
|
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 expected_socket = normalize_socket_path(target_socket_path or opts.socket_path)
|
||||||
local active_socket = normalize_socket_path(mp.get_property("input-ipc-server"))
|
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 "<empty>"
|
||||||
|
local active_socket = match.active_socket or "<empty>"
|
||||||
if expected_socket == nil or active_socket == nil then
|
if expected_socket == nil or active_socket == nil then
|
||||||
return false
|
return "expected=" .. expected_socket .. "; active=" .. active_socket .. "; matching=no"
|
||||||
end
|
end
|
||||||
return expected_socket == active_socket
|
return "expected=" .. expected_socket .. "; active=" .. active_socket .. "; matching=" .. (match.matching and "yes" or "no")
|
||||||
end
|
end
|
||||||
|
|
||||||
local function resolve_backend(override_backend)
|
local function resolve_backend(override_backend)
|
||||||
@@ -822,6 +838,7 @@ function M.create(ctx)
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
build_command_args = build_command_args,
|
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,
|
has_matching_mpv_ipc_socket = has_matching_mpv_ipc_socket,
|
||||||
run_control_command_async = run_control_command_async,
|
run_control_command_async = run_control_command_async,
|
||||||
record_visible_overlay_visibility = record_visible_overlay_visibility,
|
record_visible_overlay_visibility = record_visible_overlay_visibility,
|
||||||
|
|||||||
@@ -33,6 +33,30 @@ local MODIFIER_MAP = {
|
|||||||
meta = "Meta",
|
meta = "Meta",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
local SHIFTED_KEY_NAME_MAP = {
|
||||||
|
Digit1 = "!",
|
||||||
|
Digit2 = "@",
|
||||||
|
Digit3 = "SHARP",
|
||||||
|
Digit4 = "$",
|
||||||
|
Digit5 = "%",
|
||||||
|
Digit6 = "^",
|
||||||
|
Digit7 = "&",
|
||||||
|
Digit8 = "*",
|
||||||
|
Digit9 = "(",
|
||||||
|
Digit0 = ")",
|
||||||
|
Minus = "_",
|
||||||
|
Equal = "+",
|
||||||
|
BracketLeft = "{",
|
||||||
|
BracketRight = "}",
|
||||||
|
Backslash = "|",
|
||||||
|
Semicolon = ":",
|
||||||
|
Quote = '"',
|
||||||
|
Comma = "<",
|
||||||
|
Period = ">",
|
||||||
|
Slash = "?",
|
||||||
|
Backquote = "~",
|
||||||
|
}
|
||||||
|
|
||||||
function M.create(ctx)
|
function M.create(ctx)
|
||||||
local mp = ctx.mp
|
local mp = ctx.mp
|
||||||
local utils = ctx.utils
|
local utils = ctx.utils
|
||||||
@@ -84,7 +108,22 @@ function M.create(ctx)
|
|||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
|
|
||||||
local function key_spec_to_mpv_binding(key)
|
local function contains_value(values, target)
|
||||||
|
for _, value in ipairs(values) do
|
||||||
|
if value == target then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function append_unique(values, value)
|
||||||
|
if not contains_value(values, value) then
|
||||||
|
values[#values + 1] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function key_spec_to_mpv_bindings(key)
|
||||||
if type(key) ~= "table" then
|
if type(key) ~= "table" then
|
||||||
return nil
|
return nil
|
||||||
end
|
end
|
||||||
@@ -123,10 +162,55 @@ function M.create(ctx)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
parts[#parts + 1] = key_name
|
parts[#parts + 1] = key_name
|
||||||
return table.concat(parts, "+")
|
local bindings = { table.concat(parts, "+") }
|
||||||
|
|
||||||
|
local shifted_key_name = SHIFTED_KEY_NAME_MAP[key.code]
|
||||||
|
if has_shift and shifted_key_name then
|
||||||
|
local shifted_parts = {}
|
||||||
|
for _, modifier in ipairs(key.modifiers) do
|
||||||
|
if modifier ~= "shift" then
|
||||||
|
local mapped = MODIFIER_MAP[modifier]
|
||||||
|
if mapped then
|
||||||
|
shifted_parts[#shifted_parts + 1] = mapped
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
shifted_parts[#shifted_parts + 1] = shifted_key_name
|
||||||
|
append_unique(bindings, table.concat(shifted_parts, "+"))
|
||||||
|
end
|
||||||
|
|
||||||
|
return bindings
|
||||||
end
|
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
|
if action_id == "toggleVisibleOverlay" then
|
||||||
return { "--toggle-visible-overlay" }
|
return { "--toggle-visible-overlay" }
|
||||||
elseif action_id == "toggleStatsOverlay" then
|
elseif action_id == "toggleStatsOverlay" then
|
||||||
@@ -167,8 +251,8 @@ function M.create(ctx)
|
|||||||
return { "--open-youtube-picker" }
|
return { "--open-youtube-picker" }
|
||||||
elseif action_id == "openSessionHelp" then
|
elseif action_id == "openSessionHelp" then
|
||||||
return { "--open-session-help" }
|
return { "--open-session-help" }
|
||||||
elseif action_id == "openCharacterDictionary" then
|
elseif action_id == "openCharacterDictionaryManager" then
|
||||||
return { "--open-character-dictionary" }
|
return { "--session-action", '{"actionId":"openCharacterDictionaryManager"}' }
|
||||||
elseif action_id == "openControllerSelect" then
|
elseif action_id == "openControllerSelect" then
|
||||||
return { "--open-controller-select" }
|
return { "--open-controller-select" }
|
||||||
elseif action_id == "openControllerDebug" then
|
elseif action_id == "openControllerDebug" then
|
||||||
@@ -195,13 +279,13 @@ function M.create(ctx)
|
|||||||
return nil
|
return nil
|
||||||
end
|
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
|
if not process.check_binary_available() then
|
||||||
show_osd("Error: binary not found")
|
show_osd("Error: binary not found")
|
||||||
return
|
return
|
||||||
end
|
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
|
if not cli_args then
|
||||||
subminer_log("warn", "session-bindings", "No CLI mapping for action: " .. tostring(action_id))
|
subminer_log("warn", "session-bindings", "No CLI mapping for action: " .. tostring(action_id))
|
||||||
return
|
return
|
||||||
@@ -256,7 +340,7 @@ function M.create(ctx)
|
|||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
invoke_cli_action(binding.actionId, binding.payload)
|
invoke_cli_action(binding.actionId, binding.payload, binding.cliArgs)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function load_artifact()
|
local function load_artifact()
|
||||||
@@ -294,13 +378,20 @@ function M.create(ctx)
|
|||||||
local generation = state.session_binding_generation
|
local generation = state.session_binding_generation
|
||||||
|
|
||||||
for index, binding in ipairs(artifact.bindings) do
|
for index, binding in ipairs(artifact.bindings) do
|
||||||
local key_name = key_spec_to_mpv_binding(binding.key)
|
local key_names = key_spec_to_mpv_bindings(binding.key)
|
||||||
if key_name then
|
if key_names then
|
||||||
local name = "subminer-session-binding-" .. tostring(generation) .. "-" .. tostring(index)
|
for key_index, key_name in ipairs(key_names) do
|
||||||
next_binding_names[#next_binding_names + 1] = name
|
local name = "subminer-session-binding-"
|
||||||
mp.add_forced_key_binding(key_name, name, function()
|
.. tostring(generation)
|
||||||
handle_binding(binding)
|
.. "-"
|
||||||
end)
|
.. tostring(index)
|
||||||
|
.. "-"
|
||||||
|
.. tostring(key_index)
|
||||||
|
next_binding_names[#next_binding_names + 1] = name
|
||||||
|
mp.add_forced_key_binding(key_name, name, function()
|
||||||
|
handle_binding(binding)
|
||||||
|
end)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
subminer_log(
|
subminer_log(
|
||||||
"warn",
|
"warn",
|
||||||
|
|||||||
+55
-23
@@ -3,23 +3,33 @@
|
|||||||
## Highlights
|
## Highlights
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`. Options are organized into Appearance, Behavior, Anki, Input, and Integration sections with learned keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation fields remain supported in config files only.
|
- **Settings Window:** A dedicated Settings window is now available via `subminer --settings` or `subminer settings`, organized into Appearance, Behavior, Anki, Input, and Integration sections. Includes click-to-learn keybinding controls, AnkiConnect-backed deck/field/note-type pickers, and live reload for stats keys, logging level, Jimaku, Subsync, YouTube language defaults, and Anki field mappings. AI and translation settings remain config-file only.
|
||||||
|
|
||||||
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`. Checks include checksum verification, configurable notifications, and an opt-in channel for prerelease builds. The `subminer` launcher and Linux rofi theme are also updated automatically.
|
- **Auto-Updater:** SubMiner can now check for and apply updates from the system tray or by running `subminer -u`, with checksum verification, configurable update notifications, and an opt-in prerelease channel. The `subminer` launcher and Linux rofi theme update automatically. Set `updates.channel` to `"prerelease"` to receive beta and RC builds.
|
||||||
|
|
||||||
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH. First-run setup also includes an Open SubMiner Settings button.
|
- **First-Run Setup:** A new optional setup flow installs Bun and the `subminer` command-line launcher on Linux, macOS, and Windows, with an Open SubMiner Settings button on completion. Windows users get a `subminer.cmd` PATH shim so `subminer` works in any terminal without manually adding `SubMiner.exe` to PATH.
|
||||||
|
|
||||||
- **Launcher:** `subminer --version` / `subminer -v` now prints the installed SubMiner app version.
|
- **Launcher:** `subminer --version` / `subminer -v` now prints the installed app version. The new `mpv.profile` config option passes an mpv profile to SubMiner-managed mpv launches. Bundled mpv plugin startup options are now configurable from SubMiner config.
|
||||||
|
|
||||||
|
- **Character Portraits:** Character-name subtitle matches can now show optional inline AniList character portraits. Manual AniList title overrides are scoped per media directory so separate season folders keep independent character dictionary selections.
|
||||||
|
|
||||||
|
- **Log Export:** Sanitized log ZIP archives can be exported from the tray menu or by running `subminer logs -e`, with home-directory usernames redacted from the exported contents.
|
||||||
|
|
||||||
|
- **Logging Configuration:** SubMiner's logging level is now forwarded into launcher-started and Windows shortcut-started mpv sessions, controlling mpv log verbosity and plugin script logging. The new `logging.rotation` config sets daily log retention (default 7 days), and `logging.files` toggles let you enable or disable per-component log files; mpv logs are off by default unless explicitly enabled for debugging.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Existing configs are migrated automatically. Sidebar appearance is now configured via `subtitleSidebar.css`; the default subtitle font is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
|
- **Subtitle Appearance:** Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`. Sidebar appearance is configured via `subtitleSidebar.css`. The default subtitle font stack is updated to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`. Existing configs are migrated automatically.
|
||||||
|
|
||||||
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys are still accepted with deprecation warnings. Existing configs that had known-word highlighting enabled retain N+1 highlighting; new configs leave N+1 disabled unless `ankiConnect.nPlusOne.enabled` is explicitly set.
|
- **Known-Word Colors:** Known-word and N+1 annotation colors moved to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`. Legacy Anki color keys remain accepted with deprecation warnings. N+1 highlighting is preserved for configs that already had it enabled; new configs leave it disabled unless `ankiConnect.nPlusOne.enabled` is set explicitly.
|
||||||
|
|
||||||
- **Linux Updater:** Tray "Check for Updates" now automatically installs the new AppImage via `electron-updater`, matching the macOS and Windows tray flow. AppImages managed by a system package (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow.
|
- **Character Dictionary:** A new `Ctrl/Cmd+D` manager modal lets you remove, reorder, or override loaded dictionary entries. Entries are scoped to the current AniList media and generated Japanese name aliases only, so raw romanized or English aliases no longer appear as separate results. The in-app AniList title selector now waits for an explicit search rather than triggering automatically; the search box is prefilled from the current filename guess. The manager is blocked with a notice when character dictionary annotations are disabled, and `subtitleStyle.nameMatchEnabled` is the sole switch for enabling name matching and dictionary builds.
|
||||||
|
|
||||||
- **Jellyfin:** The server presets dropdown in Jellyfin setup is removed; setup now shows a single editable server URL field.
|
- **Linux Updater:** Tray "Check for Updates" now installs the new AppImage automatically via `electron-updater`, matching the macOS and Windows update flow. System-package-managed AppImages (e.g. AUR `/opt/SubMiner`) and non-AppImage launches fall back to the GitHub-asset flow.
|
||||||
|
|
||||||
|
- **Subsync:** The subtitle sync dialog now always opens the manual picker; the `subsync.defaultMode` config option has been removed.
|
||||||
|
|
||||||
|
- **Jellyfin:** The server presets dropdown in Jellyfin setup is replaced by a single editable server URL field.
|
||||||
|
|
||||||
- **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry.
|
- **AniSkip:** The key binding setting now uses click-to-learn key capture instead of raw text entry.
|
||||||
|
|
||||||
@@ -27,9 +37,27 @@
|
|||||||
|
|
||||||
- **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
|
- **Defaults:** Jellyfin remote-session startup warmup and character-name subtitle highlighting now default to off.
|
||||||
|
|
||||||
|
- **Runtime:** The bundled Electron runtime is updated from 39.8.6 to 42.2.0.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay now hides when mpv loses focus or is minimized, stays stable through transient window-tracking misses, remains correctly layered during stats mouse passthrough, and opens over fullscreen mpv without switching Spaces. Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. Background tracking overhead is reduced while mpv is stably focused.
|
- **macOS Overlay:** Significantly improved overlay focus and stability: the overlay hides when mpv loses focus, is minimized, or is no longer the foreground target; stays stable through transient window-tracking misses; remains correctly layered during stats mouse passthrough; opens over fullscreen mpv without switching Spaces; and stays stable when mpv remains frontmost but window geometry temporarily disappears from macOS APIs. Passthrough is fixed so mpv controls stay clickable before hovering a subtitle bar. The overlay stays stable when clicking from the overlay back into mpv. Background tracking overhead is reduced while mpv is stably focused.
|
||||||
|
|
||||||
|
- **Linux/Hyprland Overlay:** Overlay placement refreshes after leaving mpv fullscreen so the visible overlay stays aligned to the player. The visible overlay remains stacked above mpv after mpv regains focus from clicks, and is suspended while the in-player stats window is open. Settings windows (SubMiner and Yomitan) now open above the subtitle overlay on Hyprland instead of behind it.
|
||||||
|
|
||||||
|
- **Jellyfin Playback:** Resolved a wide range of Jellyfin discovery and playback issues: the active item is no longer reloaded during startup, paused mpv is no longer misreported as playing, startup unpause no longer repeats after a manual pause or `y-t` toggle, duplicate ready signals no longer re-show the overlay, and long-lived sidebar ffmpeg extractors no longer run against stream URLs. Discovery now correctly handles delayed Japanese subtitle selection and prevents later-loading foreign tracks from stealing the active Japanese track. Discovery resume correctly handles `StartPositionTicks: 0` for items with saved progress.
|
||||||
|
|
||||||
|
- **Jellyfin Subtitles:** Improved subtitle timing by preferring default embedded streams over external sidecars, stripping Jellyfin's server-selected stream from playback URLs, suppressing mpv auto-selection while SubMiner stages managed tracks, and automatically correcting clear Japanese-vs-English cue timeline offsets. Per-stream subtitle delay shifts are restored on load. Track selection now tolerates transient `track-list` read failures and numeric string track IDs on Linux.
|
||||||
|
|
||||||
|
- **Jellyfin Overlay:** The visible subtitle overlay now shows automatically during Jellyfin playback so `subtitleStyle` appearance applies. The bundled mpv plugin is injected when SubMiner auto-launches mpv for Jellyfin so mpv-side keybindings work without overlay focus. The `y-t` overlay toggle is reliable and remains sticky across stream redirects. Passive Linux/Hyprland overlay shows no longer steal keyboard focus from mpv.
|
||||||
|
|
||||||
|
- **Jellyfin Remote Progress:** Fixed progress sync for mpv/SubMiner seek jumps, stopped sessions, startup path changes, and Linux websocket reconnect windows. Play and Resume are now distinct: Play starts from the beginning while Resume starts at the saved position. Final progress reports use SubMiner's last known position when mpv resets during stop.
|
||||||
|
|
||||||
|
- **Jellyfin Identity:** Cast device identity is now derived from the OS hostname. Multiple SubMiner installs no longer share the same remote-session identity, and SubMiner always reports itself as the client regardless of legacy configurable identity fields.
|
||||||
|
|
||||||
|
- **Jellyfin Tray:** The discovery tray checkbox stays in sync on Linux after tray, CLI, or startup remote-session changes. Stale discovery sessions restart automatically when the server no longer lists the SubMiner cast target. Library discovery works correctly when the app log level is set above info.
|
||||||
|
|
||||||
|
- **Jellyfin Setup:** Fixed the Jellyfin setup login flow on Windows: login now uses an IPC bridge with immediate progress feedback, and unreachable servers time out with an inline error instead of hanging.
|
||||||
|
|
||||||
- **Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing.
|
- **Subtitle Sync Modal:** Fixed a macOS issue where opening the subtitle sync modal would flash and disappear on the first attempt, or leave stale state after syncing.
|
||||||
|
|
||||||
@@ -37,37 +65,41 @@
|
|||||||
|
|
||||||
- **AniList Progress:** Progress threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status.
|
- **AniList Progress:** Progress threshold checks now use fresh playback position data so updates fire correctly when playback reaches or skips past the watched threshold. Season-specific results are preferred for multi-season files, and a clear message is shown when the matched season is not in Planning or Watching status.
|
||||||
|
|
||||||
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
|
- **Anki:** Sentence-audio padding is now opt-in by default. When padding is configured, animated AVIF freeze-frame duration is correctly aligned to the word audio length without double-counting sentence padding. Multi-line sentence mining stays aligned when repeated subtitle text appears in the selected history range. Manual clipboard card updates from YouTube playback now use mpv's resolved stream URLs for generated audio and images.
|
||||||
|
|
||||||
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater, `subminer -u` can update independently of the tray app, macOS update dialogs come to the front reliably, unsupported builds show a manual-install message, and Windows keeps the native NSIS update path while routing updater HTTP through the main process. GitHub release lookups now avoid Electron networking on Linux and macOS.
|
- **YouTube:** Primary subtitles are now downloaded to temporary local files so the primary bar and sidebar read the same source, with cleanup on reload and quit. False subtitle load failure notifications are suppressed after SubMiner confirms the selected track loaded. Launcher-managed playback commands create the tray icon even when attaching to an already-running process, and app-owned YouTube playback no longer lets the mpv plugin start a second SubMiner instance.
|
||||||
|
|
||||||
- **Setup - macOS:** First-run setup now recognizes existing `subminer` launcher installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed - both return control to the terminal without requiring Ctrl+C.
|
- **Character Dictionary:** Surname honorifics are now matched for Japanese localized aliases embedded in AniList alternative names (e.g. Korean-source characters whose Japanese name appears in parentheses), and cached snapshots are regenerated to include them. Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits. The visible subtitle overlay is suppressed as soon as the character dictionary modal opens, including while AniList lookup is loading or returns no results.
|
||||||
|
|
||||||
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; the settings window uses a close-only menu to prevent accidentally quitting the tray app; an in-page close button is provided on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can now close correctly without mpv running.
|
- **Updater:** Update checks are more stable across platforms: Linux uses GitHub release metadata instead of the native Electron updater; `subminer -u` can update independently of the tray app; macOS update dialogs reliably appear in the foreground; builds that cannot apply native updates show a manual-install message instead of a restart prompt; Windows retains the native NSIS update path while routing updater HTTP through the main process; and macOS updater metadata mismatches from conflicting ZIP filenames are resolved.
|
||||||
|
|
||||||
- **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
|
- **Setup - macOS:** First-run setup now recognizes existing `subminer` installs in Homebrew or user PATH directories, and manual setup avoids writing into Homebrew-owned paths. `subminer app --setup` opens the setup flow even when SubMiner is already running in the background. The standalone setup app quits after completing first-run setup, and `subminer settings` exits cleanly when the window is closed.
|
||||||
|
|
||||||
- **Launcher:** Launcher-opened videos now reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends.
|
- **Tray App:** Fixed several lifecycle issues with tray-launched Yomitan settings: the tray stays running when settings are closed; settings loading no longer blocks other tray actions; a close-only menu prevents accidentally quitting the tray app; an in-page close button is available on Hyprland where native window controls are unavailable; the embedded popup preview is disabled to prevent renderer hangs during sidebar navigation; extension refreshes at startup are serialized to prevent race conditions; and the session help modal can close correctly without mpv running. On Windows, the tray "Open SubMiner Setup" action now correctly opens the setup window after first-run setup is complete.
|
||||||
|
|
||||||
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay can render text before video playback begins.
|
- **Launcher:** Launcher-opened videos reuse an already-running background SubMiner instance and correctly reapply preferred subtitles on warm launches. Videos stay paused when attaching to a running background app until subtitle priming and tokenization readiness complete. Launcher-owned tray apps close after playback ends. `subminer settings` on macOS no longer emits Electron menu diagnostics. Linux first-run launcher installs now build with a valid Bun shebang. `subminer app` on Linux returns control to the terminal immediately. On Windows, managed mpv launches from a background SubMiner instance correctly retarget the new mpv socket, bind to the player window, and receive startup overlay options.
|
||||||
|
|
||||||
|
- **Playback:** The first subtitle is primed before autoplay resumes so the overlay renders text before video playback begins. Launcher-owned videos quit SubMiner when playback ends while background and tray sessions stay alive.
|
||||||
|
|
||||||
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered.
|
- **Subtitle Frequency:** Frequency highlighting is preserved for determiner-led noun compounds like `その場` while standalone determiners are still filtered.
|
||||||
|
|
||||||
- **macOS Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so configured SubMiner shortcuts work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are now correctly wired through mpv.
|
- **Shortcuts:** Native mpv menu shortcuts are disabled during managed macOS playback so configured SubMiner shortcuts also work while mpv has focus. Session shortcuts including `stats.markWatchedKey` are correctly wired through mpv. The visible overlay receives focus when entering multi-line copy/mine selection so number keys work on macOS and Windows.
|
||||||
|
|
||||||
- **Overlay Restart:** The visible overlay and subtitle stream now stay alive after restarting SubMiner from the `y-r` shortcut, with correct bounds reapplication on Linux and user-paused playback preserved through readiness gates.
|
- **Overlay Restart:** The visible overlay and subtitle stream stay alive after restarting SubMiner from the `y-r` shortcut, with correct bounds reapplication on Linux and user-paused playback preserved through readiness gates.
|
||||||
|
|
||||||
- **WebSocket:** The subtitle WebSocket is now plain-text only; annotation spans and token metadata are sent exclusively on the annotation WebSocket.
|
- **Stats:** In-player stats layering is fixed so delete confirmations, overlay modals, and update-check dialogs appear above the stats window. Jellyfin playback stats are grouped under item metadata instead of stream URLs, so watched episodes merge with matching local library titles and display clean names.
|
||||||
|
|
||||||
- **Jellyfin:** Fixed the setup popup login path on Windows using an IPC bridge, with immediate login progress feedback and a timeout for unreachable server attempts.
|
- **Sidebar:** Yomitan lookup popups opened from the subtitle sidebar now correctly pause playback when popup auto-pause is enabled.
|
||||||
|
|
||||||
|
- **Discord Rich Presence:** Presence no longer falls back to Jellyfin stream URLs; Jellyfin playback titles are primed before loading tokenized streams so presence shows the show/episode title.
|
||||||
|
|
||||||
|
- **WebSocket:** The regular subtitle WebSocket now sends plain text only; annotation spans and token metadata are sent exclusively on the annotation WebSocket.
|
||||||
|
|
||||||
- **Windows:** Startup failures now show a native error dialog and write fatal details to the app log instead of exiting silently.
|
- **Windows:** Startup failures now show a native error dialog and write fatal details to the app log instead of exiting silently.
|
||||||
|
|
||||||
- **Yomitan:** Fixed Yomitan popups not opening when overlay startup races the Yomitan extension load.
|
- **Yomitan:** Fixed Yomitan popups not opening when overlay startup races the Yomitan extension load.
|
||||||
|
|
||||||
- **Settings:** Settings window search now searches across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`.
|
- **Settings:** Search now works across all categories, narrows correctly on multi-word terms, and hides settings with dedicated editors. Live saves for subtitle CSS declarations apply immediately to open overlays. Legacy subtitle appearance options and hover token colors are automatically migrated into `subtitleStyle.css`. The note-fields note type picker defaults to the configured Anki deck's note type, then `Kiku`, then `Lapis`, leaving it blank for manual selection otherwise. User config files are preserved during legacy config compatibility handling. The generated example config uses the same CSS declaration paths written by the Settings window.
|
||||||
|
|
||||||
- **Build - Linux:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
|
|
||||||
|
|
||||||
### Docs
|
### Docs
|
||||||
|
|
||||||
|
|||||||
@@ -374,6 +374,74 @@ test('writeChangelogArtifacts renders breaking changes section above type sectio
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('writeChangelogArtifacts prompts Claude to summarize the final stable outcome instead of prerelease churn', async () => {
|
||||||
|
const { writeChangelogArtifacts } = await loadModule();
|
||||||
|
const workspace = createWorkspace('stable-outcome-prompt');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
|
||||||
|
fs.mkdirSync(projectRoot, { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(projectRoot, 'CHANGELOG.md'), '# Changelog\n', 'utf8');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
[
|
||||||
|
'type: added',
|
||||||
|
'area: config',
|
||||||
|
'',
|
||||||
|
'- Added a dedicated Config window with launcher entry points.',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '002.md'),
|
||||||
|
[
|
||||||
|
'type: changed',
|
||||||
|
'area: config',
|
||||||
|
'breaking: true',
|
||||||
|
'',
|
||||||
|
'- Renamed the Config window to Settings window and changed the launcher entry point to `subminer settings`.',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '003.md'),
|
||||||
|
[
|
||||||
|
'type: fixed',
|
||||||
|
'area: config',
|
||||||
|
'',
|
||||||
|
'- Fixed Settings window search and live subtitle CSS saves.',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stub = defaultStubClaude();
|
||||||
|
writeChangelogArtifacts({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.12.0',
|
||||||
|
date: '2026-05-24',
|
||||||
|
deps: { runClaude: stub.runClaude },
|
||||||
|
});
|
||||||
|
|
||||||
|
const prompts = stub.calls.map((call) => call.input);
|
||||||
|
assert.equal(prompts.length, 2, 'expected changelog and release-notes prompts');
|
||||||
|
for (const prompt of prompts) {
|
||||||
|
assert.match(prompt, /Treat the fragment list as one cumulative release outcome/);
|
||||||
|
assert.match(
|
||||||
|
prompt,
|
||||||
|
/only if the final release requires action from users upgrading from the previous stable release/,
|
||||||
|
);
|
||||||
|
assert.match(prompt, /Config window.*Settings window/s);
|
||||||
|
assert.match(
|
||||||
|
prompt,
|
||||||
|
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('verifyChangelogFragments rejects invalid metadata', async () => {
|
test('verifyChangelogFragments rejects invalid metadata', async () => {
|
||||||
const { verifyChangelogFragments } = await loadModule();
|
const { verifyChangelogFragments } = await loadModule();
|
||||||
const workspace = createWorkspace('lint-invalid');
|
const workspace = createWorkspace('lint-invalid');
|
||||||
@@ -575,6 +643,74 @@ test('writePrereleaseNotesForVersion reuses existing prerelease notes when addin
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('writePrereleaseNotesForVersion prompts Claude to revise stale prerelease bullets instead of appending fix churn', async () => {
|
||||||
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
|
const workspace = createWorkspace('prerelease-net-outcome-prompt');
|
||||||
|
const projectRoot = path.join(workspace, 'SubMiner');
|
||||||
|
const existingNotes = [
|
||||||
|
'> This is a prerelease build for testing. Stable changelog and docs-site updates remain pending until the final stable release.',
|
||||||
|
'',
|
||||||
|
'## Highlights',
|
||||||
|
'### Added',
|
||||||
|
'- Config Window: Previous beta entry.',
|
||||||
|
'',
|
||||||
|
'## Installation',
|
||||||
|
'',
|
||||||
|
'See the README and docs/installation guide for full setup steps.',
|
||||||
|
'',
|
||||||
|
'## Assets',
|
||||||
|
'',
|
||||||
|
'- Linux: `SubMiner.AppImage`',
|
||||||
|
'',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'changes'), { recursive: true });
|
||||||
|
fs.mkdirSync(path.join(projectRoot, 'release'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'package.json'),
|
||||||
|
JSON.stringify({ name: 'subminer', version: '0.12.0-beta.2' }, null, 2),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(path.join(projectRoot, 'release', 'prerelease-notes.md'), existingNotes, 'utf8');
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '001.md'),
|
||||||
|
[
|
||||||
|
'type: changed',
|
||||||
|
'area: config',
|
||||||
|
'breaking: true',
|
||||||
|
'',
|
||||||
|
'- Renamed the Config window to Settings window.',
|
||||||
|
].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(projectRoot, 'changes', '002.md'),
|
||||||
|
['type: fixed', 'area: config', '', '- Fixed Settings window search.'].join('\n'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stub = recordingRunClaude(() => '### Added\n- Settings Window: Current beta state.');
|
||||||
|
writePrereleaseNotesForVersion({
|
||||||
|
cwd: projectRoot,
|
||||||
|
version: '0.12.0-beta.2',
|
||||||
|
deps: { runClaude: stub.runClaude },
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(stub.calls.length, 1, 'prerelease should issue exactly one Claude call');
|
||||||
|
const prompt = stub.calls[0]!.input;
|
||||||
|
assert.match(prompt, /EXISTING PRERELEASE NOTES/);
|
||||||
|
assert.match(prompt, /Existing prerelease notes are a baseline, not an immutable changelog/);
|
||||||
|
assert.match(prompt, /replace stale beta or RC wording/);
|
||||||
|
assert.match(
|
||||||
|
prompt,
|
||||||
|
/Multiple fixes within the same prerelease cycle should collapse into one current-state bullet/,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(workspace, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
|
test('writePrereleaseNotesForVersion supports rc prereleases', async () => {
|
||||||
const { writePrereleaseNotesForVersion } = await loadModule();
|
const { writePrereleaseNotesForVersion } = await loadModule();
|
||||||
const workspace = createWorkspace('prerelease-rc-notes');
|
const workspace = createWorkspace('prerelease-rc-notes');
|
||||||
|
|||||||
@@ -235,6 +235,13 @@ const POLISH_PROMPT_INSTRUCTIONS = `You are formatting a software release change
|
|||||||
|
|
||||||
You will receive a list of FRAGMENT entries below. Each fragment has metadata (type, area, breaking) and one or more bullet points written by the engineer who shipped that change. Your job is to merge, dedupe, and rewrite these fragments into a polished, user-facing release body.
|
You will receive a list of FRAGMENT entries below. Each fragment has metadata (type, area, breaking) and one or more bullet points written by the engineer who shipped that change. Your job is to merge, dedupe, and rewrite these fragments into a polished, user-facing release body.
|
||||||
|
|
||||||
|
## Release Outcome Rules
|
||||||
|
|
||||||
|
- Treat the fragment list as one cumulative release outcome, not a chronological log of beta/RC churn.
|
||||||
|
- Put a fragment in ### Breaking Changes only if the final release requires action from users upgrading from the previous stable release. A breaking: true marker is a warning to preserve and evaluate the substance, not an automatic section choice.
|
||||||
|
- If a breaking or fixed fragment only changes behavior introduced by another pending fragment in the same release cycle, merge it into the final Added or Changed bullet. Example: if fragments first add a Config window and later rename or fix it as a Settings window, output one Settings Window bullet under Added, not separate Config window, Breaking Changes, or Fixed bullets.
|
||||||
|
- Multiple fixes within the same prerelease cycle should collapse into one current-state bullet that describes the final behavior.
|
||||||
|
|
||||||
## Output Rules
|
## Output Rules
|
||||||
|
|
||||||
1. Output Markdown ONLY. No preamble, no commentary, no closing remarks. Start directly with the first section heading.
|
1. Output Markdown ONLY. No preamble, no commentary, no closing remarks. Start directly with the first section heading.
|
||||||
@@ -258,7 +265,7 @@ You will receive a list of FRAGMENT entries below. Each fragment has metadata (t
|
|||||||
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
|
- Be written in user-facing language. Drop implementation jargon, internal class names, file paths, and PR numbers.
|
||||||
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
|
- Be merged with related bullets when possible. If five fragments all touch Windows overlay z-order/focus/restore, write one or two bullets that summarize the overall improvement instead of five.
|
||||||
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
|
- Drop bullets that only describe PR housekeeping, CodeRabbit follow-ups, or test-only changes that don't affect users.
|
||||||
- Preserve the substance of every breaking change in ### Breaking Changes. Do not soften or omit them.
|
- Preserve the substance of breaking changes that remain breaking after applying the Release Outcome Rules. Do not soften or omit them.
|
||||||
5. Do not invent features. Every bullet must be grounded in the input fragments.
|
5. Do not invent features. Every bullet must be grounded in the input fragments.
|
||||||
6. Do not include the version heading (## v...) — that wrapper is added by the caller.
|
6. Do not include the version heading (## v...) — that wrapper is added by the caller.
|
||||||
|
|
||||||
@@ -371,7 +378,7 @@ function polishFragmentsWithClaude(
|
|||||||
? [
|
? [
|
||||||
'## Existing Prerelease Notes',
|
'## Existing Prerelease Notes',
|
||||||
'',
|
'',
|
||||||
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Reuse those highlight bullets as the baseline, preserve their meaning and wording where possible, then merge in only new or changed fragment material. Deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, Installation, or Assets sections.',
|
'The input includes EXISTING PRERELEASE NOTES before the fragment list. Existing prerelease notes are a baseline, not an immutable changelog. Reuse reviewed highlight bullets when they still describe the current outcome, but replace stale beta or RC wording when new fragments supersede it. Merge in only new or changed fragment material, and deduplicate instead of restating existing bullets. Output only the final highlights body using the section headings above; do not include the prerelease disclaimer, Installation, or Assets sections.',
|
||||||
'',
|
'',
|
||||||
].join('\n')
|
].join('\n')
|
||||||
: '';
|
: '';
|
||||||
|
|||||||
@@ -239,11 +239,21 @@ local ctx = {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
key = {
|
key = {
|
||||||
code = "KeyA",
|
code = "KeyD",
|
||||||
modifiers = { "alt", "meta" },
|
modifiers = { "ctrl" },
|
||||||
},
|
},
|
||||||
actionType = "session-action",
|
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
|
}, nil
|
||||||
@@ -322,7 +332,9 @@ end
|
|||||||
|
|
||||||
local expected_cli_bindings = {
|
local expected_cli_bindings = {
|
||||||
{ keys = "Shift+]", flag = "--shift-sub-delay-next-line" },
|
{ keys = "Shift+]", flag = "--shift-sub-delay-next-line" },
|
||||||
|
{ keys = "}", flag = "--shift-sub-delay-next-line" },
|
||||||
{ keys = "Shift+[", flag = "--shift-sub-delay-prev-line" },
|
{ keys = "Shift+[", flag = "--shift-sub-delay-prev-line" },
|
||||||
|
{ keys = "{", flag = "--shift-sub-delay-prev-line" },
|
||||||
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
|
{ keys = "Ctrl+Alt+c", flag = "--open-youtube-picker" },
|
||||||
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
|
{ keys = "Ctrl+Alt+p", flag = "--open-playlist-browser" },
|
||||||
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
|
{ keys = "Ctrl+H", flag = "--replay-current-subtitle" },
|
||||||
@@ -355,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 ~= nil, "play-next binding should invoke CLI action")
|
||||||
assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag")
|
assert_true(play_next_call[2] == "--play-next-subtitle", "play-next binding should pass CLI flag")
|
||||||
|
|
||||||
local character_dictionary = find_binding("Alt+Meta+a")
|
local character_dictionary_manager = find_binding("Ctrl+d")
|
||||||
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")
|
|
||||||
assert_true(
|
assert_true(
|
||||||
character_dictionary_call[2] == "--open-character-dictionary",
|
character_dictionary_manager ~= nil,
|
||||||
"character dictionary binding should pass CLI flag"
|
"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()
|
starter.fn()
|
||||||
|
|||||||
@@ -547,6 +547,15 @@ local function has_osd_message(messages, target)
|
|||||||
return false
|
return false
|
||||||
end
|
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 function count_osd_message(messages, target)
|
||||||
local count = 0
|
local count = 0
|
||||||
for _, message in ipairs(messages) do
|
for _, message in ipairs(messages) do
|
||||||
@@ -2169,6 +2178,13 @@ do
|
|||||||
not has_property_set(recorded.property_sets, "pause", true),
|
not has_property_set(recorded.property_sets, "pause", true),
|
||||||
"pause-until-ready gate should not arm when socket_path does not match"
|
"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
|
end
|
||||||
|
|
||||||
do
|
do
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { AnkiConnectClient } from './anki-connect';
|
import { AnkiConnectClient } from './anki-connect';
|
||||||
|
import { setLogLevel } from './logger';
|
||||||
|
|
||||||
test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
|
test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
|
||||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
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 originalInfo = console.info;
|
||||||
const messages: string[] = [];
|
const messages: string[] = [];
|
||||||
|
setLogLevel('info');
|
||||||
try {
|
try {
|
||||||
console.info = (...args: unknown[]) => {
|
console.info = (...args: unknown[]) => {
|
||||||
messages.push(args.map((value) => String(value)).join(' '));
|
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/);
|
assert.match(messages.join('\n'), /AnkiConnect notesInfo retry 1\/3 after 200ms delay/);
|
||||||
} finally {
|
} finally {
|
||||||
console.info = originalInfo;
|
console.info = originalInfo;
|
||||||
|
setLogLevel(undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ test('parseArgs captures session action forwarding flags', () => {
|
|||||||
'--shift-sub-delay-next-line',
|
'--shift-sub-delay-next-line',
|
||||||
'--cycle-runtime-option',
|
'--cycle-runtime-option',
|
||||||
'anki.autoUpdateNewCards:prev',
|
'anki.autoUpdateNewCards:prev',
|
||||||
|
'--session-action',
|
||||||
|
'{"actionId":"openCharacterDictionaryManager"}',
|
||||||
'--copy-subtitle-count',
|
'--copy-subtitle-count',
|
||||||
'3',
|
'3',
|
||||||
'--mine-sentence-count=2',
|
'--mine-sentence-count=2',
|
||||||
@@ -122,6 +124,7 @@ test('parseArgs captures session action forwarding flags', () => {
|
|||||||
assert.equal(args.shiftSubDelayNextLine, true);
|
assert.equal(args.shiftSubDelayNextLine, true);
|
||||||
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
|
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
|
||||||
assert.equal(args.cycleRuntimeOptionDirection, -1);
|
assert.equal(args.cycleRuntimeOptionDirection, -1);
|
||||||
|
assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' });
|
||||||
assert.equal(args.copySubtitleCount, 3);
|
assert.equal(args.copySubtitleCount, 3);
|
||||||
assert.equal(args.mineSentenceCount, 2);
|
assert.equal(args.mineSentenceCount, 2);
|
||||||
assert.equal(hasExplicitCommand(args), true);
|
assert.equal(hasExplicitCommand(args), true);
|
||||||
@@ -282,6 +285,18 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
|||||||
assert.equal(shouldStartApp(cycleRuntimeOption), true);
|
assert.equal(shouldStartApp(cycleRuntimeOption), true);
|
||||||
assert.equal(commandNeedsOverlayRuntime(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']);
|
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
|
||||||
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
|
||||||
|
|
||||||
|
|||||||
+39
-8
@@ -1,3 +1,5 @@
|
|||||||
|
import type { SessionActionDispatchRequest } from '../types/runtime';
|
||||||
|
|
||||||
export interface CliArgs {
|
export interface CliArgs {
|
||||||
background: boolean;
|
background: boolean;
|
||||||
managedPlayback: boolean;
|
managedPlayback: boolean;
|
||||||
@@ -32,7 +34,6 @@ export interface CliArgs {
|
|||||||
toggleSubtitleSidebar: boolean;
|
toggleSubtitleSidebar: boolean;
|
||||||
openRuntimeOptions: boolean;
|
openRuntimeOptions: boolean;
|
||||||
openSessionHelp: boolean;
|
openSessionHelp: boolean;
|
||||||
openCharacterDictionary: boolean;
|
|
||||||
openControllerSelect: boolean;
|
openControllerSelect: boolean;
|
||||||
openControllerDebug: boolean;
|
openControllerDebug: boolean;
|
||||||
openJimaku: boolean;
|
openJimaku: boolean;
|
||||||
@@ -44,6 +45,7 @@ export interface CliArgs {
|
|||||||
shiftSubDelayNextLine: boolean;
|
shiftSubDelayNextLine: boolean;
|
||||||
cycleRuntimeOptionId?: string;
|
cycleRuntimeOptionId?: string;
|
||||||
cycleRuntimeOptionDirection?: 1 | -1;
|
cycleRuntimeOptionDirection?: 1 | -1;
|
||||||
|
sessionAction?: SessionActionDispatchRequest;
|
||||||
copySubtitleCount?: number;
|
copySubtitleCount?: number;
|
||||||
mineSentenceCount?: number;
|
mineSentenceCount?: number;
|
||||||
anilistStatus: boolean;
|
anilistStatus: boolean;
|
||||||
@@ -139,7 +141,6 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
toggleSubtitleSidebar: false,
|
toggleSubtitleSidebar: false,
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
openSessionHelp: false,
|
openSessionHelp: false,
|
||||||
openCharacterDictionary: false,
|
|
||||||
openControllerSelect: false,
|
openControllerSelect: false,
|
||||||
openControllerDebug: false,
|
openControllerDebug: false,
|
||||||
openJimaku: false,
|
openJimaku: false,
|
||||||
@@ -212,6 +213,31 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
return null;
|
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) {
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
const arg = argv[i];
|
const arg = argv[i];
|
||||||
if (!arg || !arg.startsWith('--')) continue;
|
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 === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
|
||||||
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
|
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
|
||||||
else if (arg === '--open-session-help') args.openSessionHelp = 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-select') args.openControllerSelect = true;
|
||||||
else if (arg === '--open-controller-debug') args.openControllerDebug = true;
|
else if (arg === '--open-controller-debug') args.openControllerDebug = true;
|
||||||
else if (arg === '--open-jimaku') args.openJimaku = true;
|
else if (arg === '--open-jimaku') args.openJimaku = true;
|
||||||
@@ -283,6 +308,12 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||||||
args.cycleRuntimeOptionId = parsed.id;
|
args.cycleRuntimeOptionId = parsed.id;
|
||||||
args.cycleRuntimeOptionDirection = parsed.direction;
|
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=')) {
|
} else if (arg.startsWith('--copy-subtitle-count=')) {
|
||||||
const value = Number(arg.split('=', 2)[1]);
|
const value = Number(arg.split('=', 2)[1]);
|
||||||
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
|
||||||
@@ -516,7 +547,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.toggleSubtitleSidebar ||
|
args.toggleSubtitleSidebar ||
|
||||||
args.openRuntimeOptions ||
|
args.openRuntimeOptions ||
|
||||||
args.openSessionHelp ||
|
args.openSessionHelp ||
|
||||||
args.openCharacterDictionary ||
|
|
||||||
args.openControllerSelect ||
|
args.openControllerSelect ||
|
||||||
args.openControllerDebug ||
|
args.openControllerDebug ||
|
||||||
args.openJimaku ||
|
args.openJimaku ||
|
||||||
@@ -527,6 +557,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
|||||||
args.shiftSubDelayPrevLine ||
|
args.shiftSubDelayPrevLine ||
|
||||||
args.shiftSubDelayNextLine ||
|
args.shiftSubDelayNextLine ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
|
args.sessionAction !== undefined ||
|
||||||
args.copySubtitleCount !== undefined ||
|
args.copySubtitleCount !== undefined ||
|
||||||
args.mineSentenceCount !== undefined ||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.anilistStatus ||
|
args.anilistStatus ||
|
||||||
@@ -591,7 +622,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.toggleSubtitleSidebar &&
|
!args.toggleSubtitleSidebar &&
|
||||||
!args.openRuntimeOptions &&
|
!args.openRuntimeOptions &&
|
||||||
!args.openSessionHelp &&
|
!args.openSessionHelp &&
|
||||||
!args.openCharacterDictionary &&
|
|
||||||
!args.openControllerSelect &&
|
!args.openControllerSelect &&
|
||||||
!args.openControllerDebug &&
|
!args.openControllerDebug &&
|
||||||
!args.openJimaku &&
|
!args.openJimaku &&
|
||||||
@@ -602,6 +632,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
|||||||
!args.shiftSubDelayPrevLine &&
|
!args.shiftSubDelayPrevLine &&
|
||||||
!args.shiftSubDelayNextLine &&
|
!args.shiftSubDelayNextLine &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
|
args.sessionAction === undefined &&
|
||||||
args.copySubtitleCount === undefined &&
|
args.copySubtitleCount === undefined &&
|
||||||
args.mineSentenceCount === undefined &&
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
@@ -657,7 +688,6 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.toggleSubtitleSidebar ||
|
args.toggleSubtitleSidebar ||
|
||||||
args.openRuntimeOptions ||
|
args.openRuntimeOptions ||
|
||||||
args.openSessionHelp ||
|
args.openSessionHelp ||
|
||||||
args.openCharacterDictionary ||
|
|
||||||
args.openControllerSelect ||
|
args.openControllerSelect ||
|
||||||
args.openControllerDebug ||
|
args.openControllerDebug ||
|
||||||
args.openJimaku ||
|
args.openJimaku ||
|
||||||
@@ -668,6 +698,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
|||||||
args.shiftSubDelayPrevLine ||
|
args.shiftSubDelayPrevLine ||
|
||||||
args.shiftSubDelayNextLine ||
|
args.shiftSubDelayNextLine ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
|
args.sessionAction !== undefined ||
|
||||||
args.copySubtitleCount !== undefined ||
|
args.copySubtitleCount !== undefined ||
|
||||||
args.mineSentenceCount !== undefined ||
|
args.mineSentenceCount !== undefined ||
|
||||||
args.dictionary ||
|
args.dictionary ||
|
||||||
@@ -717,7 +748,6 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.toggleSubtitleSidebar &&
|
!args.toggleSubtitleSidebar &&
|
||||||
!args.openRuntimeOptions &&
|
!args.openRuntimeOptions &&
|
||||||
!args.openSessionHelp &&
|
!args.openSessionHelp &&
|
||||||
!args.openCharacterDictionary &&
|
|
||||||
!args.openControllerSelect &&
|
!args.openControllerSelect &&
|
||||||
!args.openControllerDebug &&
|
!args.openControllerDebug &&
|
||||||
!args.openJimaku &&
|
!args.openJimaku &&
|
||||||
@@ -728,6 +758,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
|
|||||||
!args.shiftSubDelayPrevLine &&
|
!args.shiftSubDelayPrevLine &&
|
||||||
!args.shiftSubDelayNextLine &&
|
!args.shiftSubDelayNextLine &&
|
||||||
args.cycleRuntimeOptionId === undefined &&
|
args.cycleRuntimeOptionId === undefined &&
|
||||||
|
args.sessionAction === undefined &&
|
||||||
args.copySubtitleCount === undefined &&
|
args.copySubtitleCount === undefined &&
|
||||||
args.mineSentenceCount === undefined &&
|
args.mineSentenceCount === undefined &&
|
||||||
!args.anilistStatus &&
|
!args.anilistStatus &&
|
||||||
@@ -782,7 +813,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
|||||||
args.markAudioCard ||
|
args.markAudioCard ||
|
||||||
args.openRuntimeOptions ||
|
args.openRuntimeOptions ||
|
||||||
args.openSessionHelp ||
|
args.openSessionHelp ||
|
||||||
args.openCharacterDictionary ||
|
|
||||||
args.openControllerSelect ||
|
args.openControllerSelect ||
|
||||||
args.openControllerDebug ||
|
args.openControllerDebug ||
|
||||||
args.openJimaku ||
|
args.openJimaku ||
|
||||||
@@ -793,6 +823,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
|
|||||||
args.shiftSubDelayPrevLine ||
|
args.shiftSubDelayPrevLine ||
|
||||||
args.shiftSubDelayNextLine ||
|
args.shiftSubDelayNextLine ||
|
||||||
args.cycleRuntimeOptionId !== undefined ||
|
args.cycleRuntimeOptionId !== undefined ||
|
||||||
|
args.sessionAction !== undefined ||
|
||||||
args.copySubtitleCount !== undefined ||
|
args.copySubtitleCount !== undefined ||
|
||||||
args.mineSentenceCount !== undefined
|
args.mineSentenceCount !== undefined
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ ${B}Mining${R}
|
|||||||
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
|
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
|
||||||
--open-runtime-options Open runtime options palette
|
--open-runtime-options Open runtime options palette
|
||||||
--open-session-help Open session help modal
|
--open-session-help Open session help modal
|
||||||
--open-character-dictionary Open character dictionary anime selection modal
|
|
||||||
--open-controller-select Open controller select modal
|
--open-controller-select Open controller select modal
|
||||||
--open-controller-debug Open controller debug modal
|
--open-controller-debug Open controller debug modal
|
||||||
|
|
||||||
|
|||||||
+149
-5
@@ -63,7 +63,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
|
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
|
||||||
assert.equal(config.ankiConnect.media.audioPadding, 0);
|
assert.equal(config.ankiConnect.media.audioPadding, 0);
|
||||||
assert.equal(config.anilist.enabled, false);
|
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.refreshTtlHours, 168);
|
||||||
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
|
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
|
||||||
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||||
@@ -95,7 +95,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
assert.equal(config.startupWarmups.subtitleDictionaries, true);
|
||||||
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
|
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
|
||||||
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
|
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
|
||||||
assert.equal(config.shortcuts.openCharacterDictionary, 'CommandOrControl+Alt+A');
|
assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D');
|
||||||
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
|
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
|
||||||
assert.equal(config.discordPresence.enabled, true);
|
assert.equal(config.discordPresence.enabled, true);
|
||||||
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
|
||||||
@@ -152,7 +152,7 @@ test('loads defaults when config is missing', () => {
|
|||||||
assert.equal(config.updates.checkIntervalHours, 24);
|
assert.equal(config.updates.checkIntervalHours, 24);
|
||||||
assert.equal(config.updates.notificationType, 'system');
|
assert.equal(config.updates.notificationType, 'system');
|
||||||
assert.equal(config.updates.channel, 'stable');
|
assert.equal(config.updates.channel, 'stable');
|
||||||
assert.equal(config.mpv.socketPath, '/tmp/subminer-socket');
|
assert.equal(config.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||||
assert.equal(config.mpv.backend, 'auto');
|
assert.equal(config.mpv.backend, 'auto');
|
||||||
assert.equal(config.mpv.profile, '');
|
assert.equal(config.mpv.profile, '');
|
||||||
assert.equal(config.mpv.autoStartSubMiner, true);
|
assert.equal(config.mpv.autoStartSubMiner, true);
|
||||||
@@ -740,6 +740,44 @@ test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () =>
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parses subtitleStyle.nameMatchImagesEnabled and warns on invalid values', () => {
|
||||||
|
const validDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(validDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"nameMatchImagesEnabled": true
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const validService = new ConfigService(validDir);
|
||||||
|
assert.equal(validService.getConfig().subtitleStyle.nameMatchImagesEnabled, true);
|
||||||
|
|
||||||
|
const invalidDir = makeTempDir();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(invalidDir, 'config.jsonc'),
|
||||||
|
`{
|
||||||
|
"subtitleStyle": {
|
||||||
|
"nameMatchImagesEnabled": "yes"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidService = new ConfigService(invalidDir);
|
||||||
|
assert.equal(
|
||||||
|
invalidService.getConfig().subtitleStyle.nameMatchImagesEnabled,
|
||||||
|
DEFAULT_CONFIG.subtitleStyle.nameMatchImagesEnabled,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
invalidService
|
||||||
|
.getWarnings()
|
||||||
|
.some((warning) => warning.path === 'subtitleStyle.nameMatchImagesEnabled'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('parses anilist.enabled and warns for invalid value', () => {
|
test('parses anilist.enabled and warns for invalid value', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -785,7 +823,6 @@ test('parses anilist.characterDictionary config with clamping and enum validatio
|
|||||||
const config = service.getConfig();
|
const config = service.getConfig();
|
||||||
const warnings = service.getWarnings();
|
const warnings = service.getWarnings();
|
||||||
|
|
||||||
assert.equal(config.anilist.characterDictionary.enabled, true);
|
|
||||||
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1);
|
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1);
|
||||||
assert.equal(config.anilist.characterDictionary.maxLoaded, 20);
|
assert.equal(config.anilist.characterDictionary.maxLoaded, 20);
|
||||||
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||||
@@ -1422,6 +1459,50 @@ test('accepts valid logging.level', () => {
|
|||||||
assert.equal(config.logging.level, 'warn');
|
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', () => {
|
test('falls back for invalid logging.level and reports warning', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -1442,6 +1523,68 @@ test('falls back for invalid logging.level and reports warning', () => {
|
|||||||
assert.ok(warnings.some((warning) => warning.path === 'logging.level'));
|
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', () => {
|
test('warns and ignores unknown top-level config keys', () => {
|
||||||
const dir = makeTempDir();
|
const dir = makeTempDir();
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
@@ -2478,6 +2621,7 @@ test('template generator includes known keys', () => {
|
|||||||
assert.doesNotMatch(output, /"clientVersion":/);
|
assert.doesNotMatch(output, /"clientVersion":/);
|
||||||
assert.doesNotMatch(output, /"youtubeSubgen":/);
|
assert.doesNotMatch(output, /"youtubeSubgen":/);
|
||||||
assert.match(output, /"characterDictionary":\s*\{/);
|
assert.match(output, /"characterDictionary":\s*\{/);
|
||||||
|
assert.doesNotMatch(output, /"characterDictionary":\s*\{\s*"enabled":/);
|
||||||
assert.match(output, /"preserveLineBreaks": false/);
|
assert.match(output, /"preserveLineBreaks": false/);
|
||||||
assert.match(output, /"knownWords"\s*:\s*\{/);
|
assert.match(output, /"knownWords"\s*:\s*\{/);
|
||||||
assert.match(output, /"knownWordColor": "#a6da95"/);
|
assert.match(output, /"knownWordColor": "#a6da95"/);
|
||||||
@@ -2487,7 +2631,7 @@ test('template generator includes known keys', () => {
|
|||||||
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
||||||
assert.match(
|
assert.match(
|
||||||
output,
|
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(
|
assert.match(
|
||||||
output,
|
output,
|
||||||
|
|||||||
@@ -28,7 +28,13 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
port: 6678,
|
port: 6678,
|
||||||
},
|
},
|
||||||
logging: {
|
logging: {
|
||||||
level: 'info',
|
level: 'warn',
|
||||||
|
rotation: 7,
|
||||||
|
files: {
|
||||||
|
app: true,
|
||||||
|
launcher: true,
|
||||||
|
mpv: false,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
texthooker: {
|
texthooker: {
|
||||||
launchAtStartup: false,
|
launchAtStartup: false,
|
||||||
@@ -88,7 +94,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
|||||||
multiCopyTimeoutMs: 3000,
|
multiCopyTimeoutMs: 3000,
|
||||||
toggleSecondarySub: 'CommandOrControl+Shift+V',
|
toggleSecondarySub: 'CommandOrControl+Shift+V',
|
||||||
markAudioCard: 'CommandOrControl+Shift+A',
|
markAudioCard: 'CommandOrControl+Shift+A',
|
||||||
openCharacterDictionary: 'CommandOrControl+Alt+A',
|
openCharacterDictionaryManager: 'CommandOrControl+D',
|
||||||
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
openRuntimeOptions: 'CommandOrControl+Shift+O',
|
||||||
openJimaku: 'Ctrl+Shift+J',
|
openJimaku: 'Ctrl+Shift+J',
|
||||||
openSessionHelp: 'CommandOrControl+Slash',
|
openSessionHelp: 'CommandOrControl+Slash',
|
||||||
|
|||||||
@@ -110,7 +110,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
accessToken: '',
|
accessToken: '',
|
||||||
characterDictionary: {
|
characterDictionary: {
|
||||||
enabled: false,
|
|
||||||
refreshTtlHours: 168,
|
refreshTtlHours: 168,
|
||||||
maxLoaded: 3,
|
maxLoaded: 3,
|
||||||
evictionPolicy: 'delete',
|
evictionPolicy: 'delete',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
|||||||
hoverTokenColor: '#f4dbd6',
|
hoverTokenColor: '#f4dbd6',
|
||||||
hoverTokenBackgroundColor: 'transparent',
|
hoverTokenBackgroundColor: 'transparent',
|
||||||
nameMatchEnabled: false,
|
nameMatchEnabled: false,
|
||||||
|
nameMatchImagesEnabled: false,
|
||||||
nameMatchColor: '#f5bde6',
|
nameMatchColor: '#f5bde6',
|
||||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||||
fontSize: 35,
|
fontSize: 35,
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
|
|
||||||
for (const requiredPath of [
|
for (const requiredPath of [
|
||||||
'logging.level',
|
'logging.level',
|
||||||
|
'logging.files.mpv',
|
||||||
'annotationWebsocket.enabled',
|
'annotationWebsocket.enabled',
|
||||||
'controller.enabled',
|
'controller.enabled',
|
||||||
'controller.scrollPixelsPerSecond',
|
'controller.scrollPixelsPerSecond',
|
||||||
@@ -101,7 +102,7 @@ test('config option registry includes critical paths and has unique entries', ()
|
|||||||
'subtitleStyle.enableJlpt',
|
'subtitleStyle.enableJlpt',
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||||
'ankiConnect.enabled',
|
'ankiConnect.enabled',
|
||||||
'anilist.characterDictionary.enabled',
|
'subtitleStyle.nameMatchEnabled',
|
||||||
'anilist.characterDictionary.collapsibleSections.description',
|
'anilist.characterDictionary.collapsibleSections.description',
|
||||||
'mpv.executablePath',
|
'mpv.executablePath',
|
||||||
'mpv.launchMode',
|
'mpv.launchMode',
|
||||||
|
|||||||
@@ -83,6 +83,30 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
defaultValue: defaultConfig.logging.level,
|
defaultValue: defaultConfig.logging.level,
|
||||||
description: 'Minimum log level for runtime logging.',
|
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',
|
path: 'youtube.primarySubLanguages',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
@@ -542,10 +566,10 @@ export function buildCoreConfigOptionRegistry(
|
|||||||
description: 'Accelerator that marks the last mined card as an audio card.',
|
description: 'Accelerator that marks the last mined card as an audio card.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'shortcuts.openCharacterDictionary',
|
path: 'shortcuts.openCharacterDictionaryManager',
|
||||||
kind: 'string',
|
kind: 'string',
|
||||||
defaultValue: defaultConfig.shortcuts.openCharacterDictionary,
|
defaultValue: defaultConfig.shortcuts.openCharacterDictionaryManager,
|
||||||
description: 'Accelerator that opens the character dictionary modal.',
|
description: 'Accelerator that opens the character dictionary manager modal.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'shortcuts.openRuntimeOptions',
|
path: 'shortcuts.openRuntimeOptions',
|
||||||
|
|||||||
@@ -392,13 +392,6 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
description:
|
description:
|
||||||
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
|
'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',
|
path: 'anilist.characterDictionary.refreshTtlHours',
|
||||||
kind: 'number',
|
kind: 'number',
|
||||||
@@ -426,7 +419,7 @@ export function buildIntegrationConfigOptionRegistry(
|
|||||||
kind: 'enum',
|
kind: 'enum',
|
||||||
enumValues: ['all', 'active'],
|
enumValues: ['all', 'active'],
|
||||||
defaultValue: defaultConfig.anilist.characterDictionary.profileScope,
|
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',
|
path: 'anilist.characterDictionary.collapsibleSections.description',
|
||||||
|
|||||||
@@ -74,7 +74,14 @@ export function buildSubtitleConfigOptionRegistry(
|
|||||||
kind: 'boolean',
|
kind: 'boolean',
|
||||||
defaultValue: defaultConfig.subtitleStyle.nameMatchEnabled,
|
defaultValue: defaultConfig.subtitleStyle.nameMatchEnabled,
|
||||||
description:
|
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',
|
||||||
|
kind: 'boolean',
|
||||||
|
defaultValue: defaultConfig.subtitleStyle.nameMatchImagesEnabled,
|
||||||
|
description:
|
||||||
|
'Show small character portraits beside subtitle tokens matched from the SubMiner character dictionary.',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'subtitleStyle.nameMatchColor',
|
path: 'subtitleStyle.nameMatchColor',
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
|||||||
{
|
{
|
||||||
title: 'Logging',
|
title: 'Logging',
|
||||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
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',
|
key: 'logging',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -100,6 +100,36 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
'Expected debug, info, warn, or error.',
|
'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);
|
applyControllerConfig(context);
|
||||||
@@ -207,7 +237,7 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
|||||||
'mineSentenceMultiple',
|
'mineSentenceMultiple',
|
||||||
'toggleSecondarySub',
|
'toggleSecondarySub',
|
||||||
'markAudioCard',
|
'markAudioCard',
|
||||||
'openCharacterDictionary',
|
'openCharacterDictionaryManager',
|
||||||
'openRuntimeOptions',
|
'openRuntimeOptions',
|
||||||
'openJimaku',
|
'openJimaku',
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -81,18 +81,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
|||||||
if (isObject(src.anilist.characterDictionary)) {
|
if (isObject(src.anilist.characterDictionary)) {
|
||||||
const characterDictionary = 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);
|
const refreshTtlHours = asNumber(characterDictionary.refreshTtlHours);
|
||||||
if (refreshTtlHours !== undefined) {
|
if (refreshTtlHours !== undefined) {
|
||||||
const normalized = Math.min(24 * 365, Math.max(1, Math.floor(refreshTtlHours)));
|
const normalized = Math.min(24 * 365, Math.max(1, Math.floor(refreshTtlHours)));
|
||||||
|
|||||||
@@ -97,7 +97,6 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
|
|||||||
const { context, warnings } = createResolveContext({
|
const { context, warnings } = createResolveContext({
|
||||||
anilist: {
|
anilist: {
|
||||||
characterDictionary: {
|
characterDictionary: {
|
||||||
enabled: true,
|
|
||||||
refreshTtlHours: 0,
|
refreshTtlHours: 0,
|
||||||
maxLoaded: 99,
|
maxLoaded: 99,
|
||||||
evictionPolicy: 'purge' as never,
|
evictionPolicy: 'purge' as never,
|
||||||
@@ -113,7 +112,6 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
|
|||||||
|
|
||||||
applyIntegrationConfig(context);
|
applyIntegrationConfig(context);
|
||||||
|
|
||||||
assert.equal(context.resolved.anilist.characterDictionary.enabled, true);
|
|
||||||
assert.equal(context.resolved.anilist.characterDictionary.refreshTtlHours, 1);
|
assert.equal(context.resolved.anilist.characterDictionary.refreshTtlHours, 1);
|
||||||
assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20);
|
assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20);
|
||||||
assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete');
|
assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||||
|
|||||||
@@ -190,6 +190,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||||
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
||||||
|
const fallbackSubtitleStyleNameMatchImagesEnabled =
|
||||||
|
resolved.subtitleStyle.nameMatchImagesEnabled;
|
||||||
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
||||||
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
const fallbackSubtitleStyleKnownWordColor = resolved.subtitleStyle.knownWordColor;
|
||||||
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
const fallbackSubtitleStyleNPlusOneColor = resolved.subtitleStyle.nPlusOneColor;
|
||||||
@@ -390,6 +392,24 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nameMatchImagesEnabled = asBoolean(
|
||||||
|
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled,
|
||||||
|
);
|
||||||
|
if (nameMatchImagesEnabled !== undefined) {
|
||||||
|
resolved.subtitleStyle.nameMatchImagesEnabled = nameMatchImagesEnabled;
|
||||||
|
} else if (
|
||||||
|
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled !==
|
||||||
|
undefined
|
||||||
|
) {
|
||||||
|
resolved.subtitleStyle.nameMatchImagesEnabled = fallbackSubtitleStyleNameMatchImagesEnabled;
|
||||||
|
warn(
|
||||||
|
'subtitleStyle.nameMatchImagesEnabled',
|
||||||
|
(src.subtitleStyle as { nameMatchImagesEnabled?: unknown }).nameMatchImagesEnabled,
|
||||||
|
resolved.subtitleStyle.nameMatchImagesEnabled,
|
||||||
|
'Expected boolean.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (nameMatchColor !== undefined) {
|
if (nameMatchColor !== undefined) {
|
||||||
resolved.subtitleStyle.nameMatchColor = nameMatchColor;
|
resolved.subtitleStyle.nameMatchColor = nameMatchColor;
|
||||||
} else if ((src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor !== undefined) {
|
} else if ((src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor !== undefined) {
|
||||||
|
|||||||
@@ -172,6 +172,31 @@ test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('subtitleStyle nameMatchImagesEnabled accepts boolean and warns on invalid', () => {
|
||||||
|
const valid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
nameMatchImagesEnabled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(valid.context);
|
||||||
|
assert.equal(valid.context.resolved.subtitleStyle.nameMatchImagesEnabled, true);
|
||||||
|
|
||||||
|
const invalid = createResolveContext({
|
||||||
|
subtitleStyle: {
|
||||||
|
nameMatchImagesEnabled: 'yes' as unknown as boolean,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
applySubtitleDomainConfig(invalid.context);
|
||||||
|
assert.equal(invalid.context.resolved.subtitleStyle.nameMatchImagesEnabled, false);
|
||||||
|
assert.ok(
|
||||||
|
invalid.warnings.some(
|
||||||
|
(warning) =>
|
||||||
|
warning.path === 'subtitleStyle.nameMatchImagesEnabled' &&
|
||||||
|
warning.message === 'Expected boolean.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('subtitleStyle frequencyDictionary defaults to the teal fourth band color', () => {
|
test('subtitleStyle frequencyDictionary defaults to the teal fourth band color', () => {
|
||||||
const { context } = createResolveContext({});
|
const { context } = createResolveContext({});
|
||||||
|
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ test('settings registry moves AniSkip button key into input shortcuts and hot re
|
|||||||
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
|
assert.equal(field('mpv.aniskipButtonKey').restartBehavior, 'hot-reload');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('settings registry exposes character dictionary panel shortcuts dynamically', () => {
|
||||||
|
assert.equal(
|
||||||
|
field('shortcuts.openCharacterDictionaryManager').label,
|
||||||
|
'Open Character Dictionary Manager',
|
||||||
|
);
|
||||||
|
assert.equal(field('shortcuts.openCharacterDictionaryManager').subsection, 'Open Panels');
|
||||||
|
});
|
||||||
|
|
||||||
test('settings registry hides removed modal-only fields', () => {
|
test('settings registry hides removed modal-only fields', () => {
|
||||||
for (const path of [
|
for (const path of [
|
||||||
'shortcuts.multiCopyTimeoutMs',
|
'shortcuts.multiCopyTimeoutMs',
|
||||||
@@ -173,6 +181,7 @@ test('settings registry exposes css declaration editor for primary and secondary
|
|||||||
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
|
assert.equal(field('subtitleStyle.WebkitTextStroke').settingsHidden, true);
|
||||||
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
|
assert.equal(field('subtitleStyle.knownWordColor').settingsHidden, false);
|
||||||
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
|
assert.equal(field('subtitleStyle.nPlusOneColor').settingsHidden, false);
|
||||||
|
assert.equal(field('subtitleStyle.nameMatchImagesEnabled').settingsHidden, false);
|
||||||
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
|
assert.equal(field('subtitleStyle.nameMatchColor').settingsHidden, false);
|
||||||
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
|
assert.equal(field('subtitleStyle.jlptColors.N1').settingsHidden, false);
|
||||||
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
|
assert.equal(field('subtitleStyle.frequencyDictionary.singleColor').settingsHidden, false);
|
||||||
@@ -251,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(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', () => {
|
test('settings registry marks safe live config paths as hot-reloadable', () => {
|
||||||
@@ -260,6 +269,10 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
|
|||||||
'stats.toggleKey',
|
'stats.toggleKey',
|
||||||
'stats.markWatchedKey',
|
'stats.markWatchedKey',
|
||||||
'logging.level',
|
'logging.level',
|
||||||
|
'logging.rotation',
|
||||||
|
'logging.files.app',
|
||||||
|
'logging.files.launcher',
|
||||||
|
'logging.files.mpv',
|
||||||
'youtube.primarySubLanguages',
|
'youtube.primarySubLanguages',
|
||||||
'jimaku.apiBaseUrl',
|
'jimaku.apiBaseUrl',
|
||||||
'jimaku.languagePreference',
|
'jimaku.languagePreference',
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
|
|||||||
'controller.preferredGamepadLabel',
|
'controller.preferredGamepadLabel',
|
||||||
'controller.profiles',
|
'controller.profiles',
|
||||||
'youtubeSubgen.primarySubLanguages',
|
'youtubeSubgen.primarySubLanguages',
|
||||||
|
'anilist.characterDictionary.enabled',
|
||||||
'anilist.characterDictionary.refreshTtlHours',
|
'anilist.characterDictionary.refreshTtlHours',
|
||||||
'anilist.characterDictionary.evictionPolicy',
|
'anilist.characterDictionary.evictionPolicy',
|
||||||
'anilist.characterDictionary.profileScope',
|
'anilist.characterDictionary.profileScope',
|
||||||
@@ -184,6 +185,11 @@ const PATH_ORDER = new Map<string, number>(
|
|||||||
'mpv.launchMode',
|
'mpv.launchMode',
|
||||||
'mpv.executablePath',
|
'mpv.executablePath',
|
||||||
'mpv.aniskipButtonKey',
|
'mpv.aniskipButtonKey',
|
||||||
|
'logging.level',
|
||||||
|
'logging.rotation',
|
||||||
|
'logging.files.app',
|
||||||
|
'logging.files.launcher',
|
||||||
|
'logging.files.mpv',
|
||||||
].map((path, index) => [path, index]),
|
].map((path, index) => [path, index]),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -208,7 +214,7 @@ const LABEL_OVERRIDES: Record<string, string> = {
|
|||||||
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
'ankiConnect.isLapis.enabled': 'Enable Lapis Features',
|
||||||
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
'ankiConnect.isKiku.enabled': 'Enable Kiku Features',
|
||||||
'stats.toggleKey': 'Toggle Stats Overlay',
|
'stats.toggleKey': 'Toggle Stats Overlay',
|
||||||
'shortcuts.openCharacterDictionary': 'Open AniList Override',
|
'shortcuts.openCharacterDictionaryManager': 'Open Character Dictionary Manager',
|
||||||
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
'subtitleSidebar.pauseVideoOnHover': 'Pause Video On Hover - Sidebar',
|
||||||
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
'subtitleStyle.autoPauseVideoOnHover': 'Pause Video On Hover - Subtitles',
|
||||||
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
'subtitleStyle.autoPauseVideoOnYomitanPopup': 'Pause Video On Yomitan Popup',
|
||||||
@@ -345,6 +351,7 @@ function categoryAndSection(path: string): { category: ConfigSettingsCategory; s
|
|||||||
path === 'subtitleStyle.knownWordColor' ||
|
path === 'subtitleStyle.knownWordColor' ||
|
||||||
path === 'subtitleStyle.nPlusOneColor' ||
|
path === 'subtitleStyle.nPlusOneColor' ||
|
||||||
path === 'subtitleStyle.nameMatchEnabled' ||
|
path === 'subtitleStyle.nameMatchEnabled' ||
|
||||||
|
path === 'subtitleStyle.nameMatchImagesEnabled' ||
|
||||||
path === 'subtitleStyle.nameMatchColor'
|
path === 'subtitleStyle.nameMatchColor'
|
||||||
) {
|
) {
|
||||||
return { category: 'appearance', section: 'Annotation Display' };
|
return { category: 'appearance', section: 'Annotation Display' };
|
||||||
@@ -524,7 +531,11 @@ function subsectionForPath(path: string): string | undefined {
|
|||||||
) {
|
) {
|
||||||
return 'Frequency Highlighting';
|
return 'Frequency Highlighting';
|
||||||
}
|
}
|
||||||
if (path === 'subtitleStyle.nameMatchEnabled' || path === 'subtitleStyle.nameMatchColor') {
|
if (
|
||||||
|
path === 'subtitleStyle.nameMatchEnabled' ||
|
||||||
|
path === 'subtitleStyle.nameMatchImagesEnabled' ||
|
||||||
|
path === 'subtitleStyle.nameMatchColor'
|
||||||
|
) {
|
||||||
return 'Character Names';
|
return 'Character Names';
|
||||||
}
|
}
|
||||||
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
|
if (path === 'anilist.characterDictionary.collapsibleSections.description') {
|
||||||
@@ -565,7 +576,7 @@ function subsectionForPath(path: string): string | undefined {
|
|||||||
return 'Toggle & Visibility';
|
return 'Toggle & Visibility';
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
leaf === 'openCharacterDictionary' ||
|
leaf === 'openCharacterDictionaryManager' ||
|
||||||
leaf === 'openRuntimeOptions' ||
|
leaf === 'openRuntimeOptions' ||
|
||||||
leaf === 'openJimaku' ||
|
leaf === 'openJimaku' ||
|
||||||
leaf === 'openSessionHelp' ||
|
leaf === 'openSessionHelp' ||
|
||||||
@@ -662,6 +673,8 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
|
|||||||
path === 'stats.toggleKey' ||
|
path === 'stats.toggleKey' ||
|
||||||
path === 'stats.markWatchedKey' ||
|
path === 'stats.markWatchedKey' ||
|
||||||
path === 'logging.level' ||
|
path === 'logging.level' ||
|
||||||
|
path === 'logging.rotation' ||
|
||||||
|
pathStartsWith(path, 'logging.files') ||
|
||||||
path === 'youtube.primarySubLanguages' ||
|
path === 'youtube.primarySubLanguages' ||
|
||||||
pathStartsWith(path, 'jimaku') ||
|
pathStartsWith(path, 'jimaku') ||
|
||||||
pathStartsWith(path, 'subsync')
|
pathStartsWith(path, 'subsync')
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ test('anilist update queue applies retry backoff and dead-letter', () => {
|
|||||||
assert.equal((pendingPayload.pending[0]?.nextAttemptAt ?? now) - now, 30_000);
|
assert.equal((pendingPayload.pending[0]?.nextAttemptAt ?? now) - now, 30_000);
|
||||||
|
|
||||||
for (let attempt = 2; attempt <= 8; attempt += 1) {
|
for (let attempt = 2; attempt <= 8; attempt += 1) {
|
||||||
queue.markFailure('k2', `fail-${attempt}`, now);
|
queue.markFailure('k2', `fail-${attempt}`, now + attempt * 6 * 60 * 60 * 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
|
const snapshot = queue.getSnapshot(Number.MAX_SAFE_INTEGER);
|
||||||
@@ -83,6 +83,52 @@ test('anilist update queue applies retry backoff and dead-letter', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('anilist update queue ignores duplicate failures while retry is cooling down', () => {
|
||||||
|
const queueFile = createTempQueueFile();
|
||||||
|
const loggerState = createLogger();
|
||||||
|
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
||||||
|
|
||||||
|
const now = 1_700_000 * 1_000_000;
|
||||||
|
queue.enqueue('k2', 'Backoff Demo', 2);
|
||||||
|
queue.markFailure('k2', 'fail-1', now);
|
||||||
|
|
||||||
|
for (let attempt = 2; attempt <= 12; attempt += 1) {
|
||||||
|
queue.markFailure('k2', `duplicate-${attempt}`, now + attempt);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = JSON.parse(fs.readFileSync(queueFile, 'utf-8')) as {
|
||||||
|
pending: Array<{ attemptCount: number; lastError: string | null }>;
|
||||||
|
deadLetter: Array<unknown>;
|
||||||
|
};
|
||||||
|
assert.equal(payload.pending[0]?.attemptCount, 1);
|
||||||
|
assert.equal(payload.pending[0]?.lastError, 'fail-1');
|
||||||
|
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
|
||||||
|
pending: 1,
|
||||||
|
ready: 1,
|
||||||
|
deadLetter: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('anilist update queue does not re-enqueue dead-lettered keys', () => {
|
||||||
|
const queueFile = createTempQueueFile();
|
||||||
|
const loggerState = createLogger();
|
||||||
|
const queue = createAnilistUpdateQueue(queueFile, loggerState.logger);
|
||||||
|
|
||||||
|
const now = 1_700_000 * 1_000_000;
|
||||||
|
queue.enqueue('k4', 'Dead Letter Demo', 4);
|
||||||
|
for (let attempt = 1; attempt <= 8; attempt += 1) {
|
||||||
|
queue.markFailure('k4', `fail-${attempt}`, now + attempt * 6 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.enqueue('k4', 'Dead Letter Demo', 4);
|
||||||
|
|
||||||
|
assert.deepEqual(queue.getSnapshot(Number.MAX_SAFE_INTEGER), {
|
||||||
|
pending: 0,
|
||||||
|
ready: 0,
|
||||||
|
deadLetter: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('anilist update queue persists and reloads from disk', () => {
|
test('anilist update queue persists and reloads from disk', () => {
|
||||||
const queueFile = createTempQueueFile();
|
const queueFile = createTempQueueFile();
|
||||||
const loggerState = createLogger();
|
const loggerState = createLogger();
|
||||||
|
|||||||
@@ -108,7 +108,8 @@ export function createAnilistUpdateQueue(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
|
enqueue(key: string, title: string, episode: number, season: number | null = null): void {
|
||||||
const existing = pending.find((item) => item.key === key);
|
const existing =
|
||||||
|
pending.find((item) => item.key === key) || deadLetter.find((item) => item.key === key);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -147,6 +148,9 @@ export function createAnilistUpdateQueue(
|
|||||||
if (!item) {
|
if (!item) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (item.attemptCount > 0 && item.nextAttemptAt > nowMs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
item.attemptCount += 1;
|
item.attemptCount += 1;
|
||||||
item.lastError = reason;
|
item.lastError = reason;
|
||||||
if (item.attemptCount >= MAX_ATTEMPTS) {
|
if (item.attemptCount >= MAX_ATTEMPTS) {
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
openJimaku: false,
|
openJimaku: false,
|
||||||
openYoutubePicker: false,
|
openYoutubePicker: false,
|
||||||
openPlaylistBrowser: false,
|
openPlaylistBrowser: false,
|
||||||
openCharacterDictionary: false,
|
|
||||||
replayCurrentSubtitle: false,
|
replayCurrentSubtitle: false,
|
||||||
playNextSubtitle: false,
|
playNextSubtitle: false,
|
||||||
shiftSubDelayPrevLine: false,
|
shiftSubDelayPrevLine: false,
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
|||||||
refreshKnownWords: false,
|
refreshKnownWords: false,
|
||||||
openRuntimeOptions: false,
|
openRuntimeOptions: false,
|
||||||
openSessionHelp: false,
|
openSessionHelp: false,
|
||||||
openCharacterDictionary: false,
|
|
||||||
openControllerSelect: false,
|
openControllerSelect: false,
|
||||||
openControllerDebug: false,
|
openControllerDebug: false,
|
||||||
openJimaku: 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 () => {
|
test('handleCliCommand dispatches mark-watched session action', async () => {
|
||||||
let request: unknown = null;
|
let request: unknown = null;
|
||||||
const { deps } = createDeps({
|
const { deps } = createDeps({
|
||||||
|
|||||||
@@ -384,7 +384,13 @@ export function handleCliCommand(
|
|||||||
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
|
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();
|
deps.toggleVisibleOverlay();
|
||||||
} else if (args.togglePrimarySubtitleBar) {
|
} else if (args.togglePrimarySubtitleBar) {
|
||||||
deps.togglePrimarySubtitleBar();
|
deps.togglePrimarySubtitleBar();
|
||||||
@@ -490,12 +496,6 @@ export function handleCliCommand(
|
|||||||
'openSessionHelp',
|
'openSessionHelp',
|
||||||
'Open session help failed',
|
'Open session help failed',
|
||||||
);
|
);
|
||||||
} else if (args.openCharacterDictionary) {
|
|
||||||
dispatchCliSessionAction(
|
|
||||||
{ actionId: 'openCharacterDictionary' },
|
|
||||||
'openCharacterDictionary',
|
|
||||||
'Open character dictionary failed',
|
|
||||||
);
|
|
||||||
} else if (args.openControllerSelect) {
|
} else if (args.openControllerSelect) {
|
||||||
dispatchCliSessionAction(
|
dispatchCliSessionAction(
|
||||||
{ actionId: 'openControllerSelect' },
|
{ actionId: 'openControllerSelect' },
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
|||||||
next.stats.toggleKey = 'F8';
|
next.stats.toggleKey = 'F8';
|
||||||
next.stats.markWatchedKey = 'F9';
|
next.stats.markWatchedKey = 'F9';
|
||||||
next.logging.level = 'debug';
|
next.logging.level = 'debug';
|
||||||
|
next.logging.rotation = 14;
|
||||||
|
next.logging.files.mpv = true;
|
||||||
next.youtube.primarySubLanguages = ['ja', 'en'];
|
next.youtube.primarySubLanguages = ['ja', 'en'];
|
||||||
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
|
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
|
||||||
next.subsync.replace = !prev.subsync.replace;
|
next.subsync.replace = !prev.subsync.replace;
|
||||||
@@ -56,6 +58,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
|
|||||||
'mpv.aniskipButtonKey',
|
'mpv.aniskipButtonKey',
|
||||||
'stats.markWatchedKey',
|
'stats.markWatchedKey',
|
||||||
'logging.level',
|
'logging.level',
|
||||||
|
'logging.rotation',
|
||||||
|
'logging.files',
|
||||||
'youtube.primarySubLanguages',
|
'youtube.primarySubLanguages',
|
||||||
'jimaku.maxEntryResults',
|
'jimaku.maxEntryResults',
|
||||||
'subsync.replace',
|
'subsync.replace',
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
|
|||||||
'stats.toggleKey',
|
'stats.toggleKey',
|
||||||
'stats.markWatchedKey',
|
'stats.markWatchedKey',
|
||||||
'logging.level',
|
'logging.level',
|
||||||
|
'logging.rotation',
|
||||||
|
'logging.files',
|
||||||
'youtube.primarySubLanguages',
|
'youtube.primarySubLanguages',
|
||||||
'jimaku',
|
'jimaku',
|
||||||
'subsync',
|
'subsync',
|
||||||
|
|||||||
@@ -106,6 +106,40 @@ test('buildHyprlandPlacementDispatches does not pin already floating overlay win
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('buildHyprlandPlacementDispatches can update placement without raising z-order', () => {
|
||||||
|
const buildDispatches = buildHyprlandPlacementDispatches as (
|
||||||
|
client: Parameters<typeof buildHyprlandPlacementDispatches>[0],
|
||||||
|
bounds: Parameters<typeof buildHyprlandPlacementDispatches>[1],
|
||||||
|
options: { promote: false },
|
||||||
|
) => string[][];
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
buildDispatches(
|
||||||
|
{
|
||||||
|
address: '0xabc',
|
||||||
|
floating: true,
|
||||||
|
pinned: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
},
|
||||||
|
{ promote: false },
|
||||||
|
),
|
||||||
|
[
|
||||||
|
['dispatch', 'movewindowpixel', 'exact 0 0,address:0xabc'],
|
||||||
|
['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xabc'],
|
||||||
|
['dispatch', 'setprop', 'address:0xabc rounding 0'],
|
||||||
|
['dispatch', 'setprop', 'address:0xabc border_size 0'],
|
||||||
|
['dispatch', 'setprop', 'address:0xabc no_shadow 1'],
|
||||||
|
['dispatch', 'setprop', 'address:0xabc no_blur 1'],
|
||||||
|
['dispatch', 'setprop', 'address:0xabc decorate 0'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows', () => {
|
test('buildHyprlandPlacementDispatches unpins previously pinned overlay windows', () => {
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
buildHyprlandPlacementDispatches({
|
buildHyprlandPlacementDispatches({
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user