mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-05-26 12:55:16 -07:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ff4d38e5be
|
|||
|
c7fc328194
|
|||
|
edb1da2993
|
|||
|
71ea5ef944
|
|||
|
b076e8800f
|
|||
|
10d9c38037
|
|||
|
c369841827
|
|||
|
c6537224f2
|
|||
|
6ba91780c1
|
|||
|
81830b3372
|
|||
|
3447103857
|
|||
|
bcbd0173e5
|
|||
|
0298a066ad
|
|||
| 799cce6991 | |||
| 6b2cb002ac | |||
| e84674e3b5 | |||
|
6ca5cede3e
|
@@ -0,0 +1,76 @@
|
||||
name: Docs Pages
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- 'v*'
|
||||
paths:
|
||||
- 'docs-site/**'
|
||||
- 'scripts/docs-versioning.ts'
|
||||
- 'scripts/build-versioned-docs.ts'
|
||||
- '.github/workflows/docs-pages.yml'
|
||||
- 'package.json'
|
||||
- 'bun.lock'
|
||||
|
||||
concurrency:
|
||||
group: docs-pages-production
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
if: ${{ github.ref_type != 'tag' || !contains(github.ref_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Guard stable docs tag shape
|
||||
id: tag_guard
|
||||
if: github.ref_type == 'tag'
|
||||
run: |
|
||||
if [[ ! "${{ github.ref_name }}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "::notice::Skipping non-stable docs tag ${{ github.ref_name }}"
|
||||
echo "stable_tag=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
echo "stable_tag=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Bun
|
||||
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||
uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: 1.3.5
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
cd docs-site && bun install --frozen-lockfile
|
||||
|
||||
- name: Cache versioned docs archives
|
||||
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: .tmp/docs-versioned-archive-cache
|
||||
key: docs-versioned-archives-${{ runner.os }}-${{ hashFiles('docs-site/.vitepress/**', 'docs-site/public/assets/fonts/**', 'docs-site/package.json', 'docs-site/bun.lock', 'scripts/build-versioned-docs.ts', 'scripts/docs-versioning.ts') }}
|
||||
|
||||
- name: Test docs
|
||||
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||
run: bun run docs:test
|
||||
|
||||
- name: Build versioned docs
|
||||
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||
run: bun run docs:build:versioned
|
||||
|
||||
- name: Deploy docs to Cloudflare Pages
|
||||
if: steps.tag_guard.outputs.stable_tag != 'false'
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
|
||||
command: pages deploy .tmp/docs-versioned-site --project-name "${{ vars.CLOUDFLARE_PAGES_PROJECT_NAME }}" --branch main
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop
|
||||
.PHONY: help deps build build-launcher install build-linux build-macos build-macos-unsigned clean install-linux install-macos install-windows uninstall uninstall-linux uninstall-macos uninstall-windows print-dirs pretty lint ensure-bun generate-config generate-example-config dev-start dev-start-macos dev-watch dev-watch-macos dev-toggle dev-stop docs-test docs-build docs-build-versioned docs-dev
|
||||
|
||||
APP_NAME := subminer
|
||||
THEME_SOURCE := assets/themes/subminer.rasi
|
||||
@@ -62,6 +62,10 @@ help:
|
||||
" dev-watch-macos Start watch loop with forced macOS tracker backend" \
|
||||
" dev-toggle Toggle overlay in a running local Electron app" \
|
||||
" dev-stop Stop a running local Electron app" \
|
||||
" docs-test Run docs tests" \
|
||||
" docs-build Build the docs site" \
|
||||
" docs-build-versioned Build production versioned docs site" \
|
||||
" docs-dev Start the docs dev server" \
|
||||
" install-linux Install Linux wrapper/theme/app artifacts" \
|
||||
" install-macos Install macOS wrapper/theme/app artifacts" \
|
||||
" install-windows Print Windows packaging/install guidance" \
|
||||
@@ -200,6 +204,18 @@ dev-toggle: ensure-bun
|
||||
dev-stop: ensure-bun
|
||||
@bun run electron . --stop
|
||||
|
||||
docs-test: ensure-bun
|
||||
@bun run docs:test
|
||||
|
||||
docs-build: ensure-bun
|
||||
@bun run docs:build
|
||||
|
||||
docs-build-versioned: ensure-bun
|
||||
@bun run docs:build:versioned
|
||||
|
||||
docs-dev: ensure-bun
|
||||
@bun run docs:dev
|
||||
|
||||
|
||||
install-linux: build-launcher
|
||||
@printf '%s\n' "[INFO] Installing Linux wrapper/theme artifacts"
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
"use strict";
|
||||
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||
};
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.resolveMacAppBundlePath = resolveMacAppBundlePath;
|
||||
exports.isMacApplicationsFolderBundle = isMacApplicationsFolderBundle;
|
||||
exports.isKnownLinuxPackageManagedAppImage = isKnownLinuxPackageManagedAppImage;
|
||||
exports.isNativeUpdaterSupported = isNativeUpdaterSupported;
|
||||
exports.configureAutoUpdater = configureAutoUpdater;
|
||||
exports.createElectronAppUpdater = createElectronAppUpdater;
|
||||
const node_fs_1 = require("node:fs");
|
||||
const node_child_process_1 = require("node:child_process");
|
||||
const node_os_1 = __importDefault(require("node:os"));
|
||||
const node_path_1 = __importDefault(require("node:path"));
|
||||
const node_util_1 = require("node:util");
|
||||
const electron_updater_1 = require("electron-updater");
|
||||
const release_assets_1 = require("./release-assets");
|
||||
const updaterErrorListeners = new WeakMap();
|
||||
const execFileAsync = (0, node_util_1.promisify)(node_child_process_1.execFile);
|
||||
function resolveMacAppBundlePath(execPath) {
|
||||
const marker = '.app/Contents/MacOS/';
|
||||
const markerIndex = execPath.indexOf(marker);
|
||||
if (markerIndex < 0)
|
||||
return null;
|
||||
return execPath.slice(0, markerIndex + '.app'.length);
|
||||
}
|
||||
async function readMacCodeSignature(appBundlePath) {
|
||||
try {
|
||||
const result = await execFileAsync('/usr/bin/codesign', ['-dv', '--verbose=4', appBundlePath], {
|
||||
encoding: 'utf8',
|
||||
});
|
||||
return `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
|
||||
}
|
||||
catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function realpathOrOriginal(filePath) {
|
||||
try {
|
||||
return (0, node_fs_1.realpathSync)(filePath);
|
||||
}
|
||||
catch {
|
||||
return filePath;
|
||||
}
|
||||
}
|
||||
function isSameOrInsideDirectory(parentPath, candidatePath) {
|
||||
const relative = node_path_1.default.relative(parentPath, candidatePath);
|
||||
return (relative === '' ||
|
||||
(relative.length > 0 && !relative.startsWith('..') && !node_path_1.default.isAbsolute(relative)));
|
||||
}
|
||||
function isMacApplicationsFolderBundle(appBundlePath, homeDir = node_os_1.default.homedir()) {
|
||||
const resolvedBundlePath = node_path_1.default.resolve(appBundlePath);
|
||||
return (isSameOrInsideDirectory('/Applications', resolvedBundlePath) ||
|
||||
isSameOrInsideDirectory(node_path_1.default.join(homeDir, 'Applications'), resolvedBundlePath));
|
||||
}
|
||||
function isKnownLinuxPackageManagedAppImage(appImagePath) {
|
||||
return realpathOrOriginal(appImagePath) === '/opt/SubMiner/SubMiner.AppImage';
|
||||
}
|
||||
async function isNativeUpdaterSupported(options) {
|
||||
if (!options.isPackaged) {
|
||||
options.log?.('Skipping native updater because this build is not packaged.');
|
||||
return false;
|
||||
}
|
||||
if (options.platform === 'linux') {
|
||||
options.log?.('Skipping native Linux updater because Linux tray checks use GitHub release assets.');
|
||||
return false;
|
||||
}
|
||||
if (options.platform !== 'darwin') {
|
||||
options.log?.('Skipping native updater because this platform uses GitHub metadata checks.');
|
||||
return false;
|
||||
}
|
||||
const appBundlePath = resolveMacAppBundlePath(options.execPath);
|
||||
if (!appBundlePath) {
|
||||
options.log?.('Skipping native macOS updater because the app bundle path could not be resolved.');
|
||||
return false;
|
||||
}
|
||||
if (!isMacApplicationsFolderBundle(appBundlePath, options.homeDir)) {
|
||||
options.log?.('Skipping native macOS updater because the app is not installed in an Applications folder.');
|
||||
return false;
|
||||
}
|
||||
const signature = await (options.readCodeSignature ?? readMacCodeSignature)(appBundlePath);
|
||||
if (!signature) {
|
||||
options.log?.('Skipping native macOS updater because the app code signature could not be read.');
|
||||
return false;
|
||||
}
|
||||
if (/Signature=adhoc\b/.test(signature) || /TeamIdentifier=not set\b/.test(signature)) {
|
||||
options.log?.('Skipping native macOS updater because this build is ad-hoc signed.');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
function configureAutoUpdater(updater, log = () => { }, channel = 'stable') {
|
||||
updater.autoDownload = false;
|
||||
// On macOS this avoids invoking Squirrel until the explicit restart/install step.
|
||||
updater.autoInstallOnAppQuit = false;
|
||||
updater.allowPrerelease = channel === 'prerelease';
|
||||
updater.allowDowngrade = false;
|
||||
updater.logger = {
|
||||
info: () => { },
|
||||
debug: () => { },
|
||||
warn: (message) => log(message),
|
||||
error: (message) => log(message),
|
||||
};
|
||||
const previousErrorListener = updaterErrorListeners.get(updater);
|
||||
if (previousErrorListener) {
|
||||
if (updater.off) {
|
||||
updater.off('error', previousErrorListener);
|
||||
}
|
||||
else {
|
||||
updater.removeListener?.('error', previousErrorListener);
|
||||
}
|
||||
}
|
||||
if (updater.on) {
|
||||
const errorListener = (error) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
log(`Updater error event: ${message}`);
|
||||
};
|
||||
updater.on('error', errorListener);
|
||||
updaterErrorListeners.set(updater, errorListener);
|
||||
}
|
||||
return updater;
|
||||
}
|
||||
function createElectronAppUpdater(options) {
|
||||
const getChannel = options.getChannel ?? (() => 'stable');
|
||||
const updater = configureAutoUpdater(options.updater ?? electron_updater_1.autoUpdater, options.log, getChannel());
|
||||
if (options.configureHttpExecutor) {
|
||||
// electron-updater has no public executor hook; keep the macOS cURL override localized.
|
||||
updater.httpExecutor = options.configureHttpExecutor();
|
||||
}
|
||||
if (options.disableDifferentialDownload !== undefined) {
|
||||
updater.disableDifferentialDownload = options.disableDifferentialDownload;
|
||||
}
|
||||
let nativeUpdaterSupported = null;
|
||||
async function getNativeUpdaterSupported() {
|
||||
if (!options.isNativeUpdaterSupported)
|
||||
return true;
|
||||
if (nativeUpdaterSupported === null) {
|
||||
nativeUpdaterSupported = Promise.resolve(options.isNativeUpdaterSupported());
|
||||
}
|
||||
return nativeUpdaterSupported;
|
||||
}
|
||||
return {
|
||||
async checkForUpdates(channel) {
|
||||
if (!options.isPackaged) {
|
||||
return {
|
||||
available: false,
|
||||
version: options.currentVersion,
|
||||
canUpdate: false,
|
||||
};
|
||||
}
|
||||
if (!(await getNativeUpdaterSupported())) {
|
||||
options.log('Skipping native app update check because native updater is unsupported.');
|
||||
return {
|
||||
available: false,
|
||||
version: options.currentVersion,
|
||||
canUpdate: false,
|
||||
};
|
||||
}
|
||||
configureAutoUpdater(updater, options.log, channel ?? getChannel());
|
||||
const result = await updater.checkForUpdates();
|
||||
const version = result?.updateInfo?.version ?? options.currentVersion;
|
||||
return {
|
||||
available: (0, release_assets_1.compareSemverLike)(version, options.currentVersion) > 0,
|
||||
version,
|
||||
canUpdate: true,
|
||||
};
|
||||
},
|
||||
async downloadUpdate() {
|
||||
if (!options.isPackaged) {
|
||||
options.log('Skipping app update download because this build is not packaged.');
|
||||
return;
|
||||
}
|
||||
if (!(await getNativeUpdaterSupported())) {
|
||||
options.log('Skipping app update download because native updater is unsupported.');
|
||||
return;
|
||||
}
|
||||
await updater.downloadUpdate();
|
||||
},
|
||||
async quitAndInstall() {
|
||||
if (!options.isPackaged) {
|
||||
options.log('Skipping app update install because this build is not packaged.');
|
||||
return;
|
||||
}
|
||||
if (!(await getNativeUpdaterSupported())) {
|
||||
options.log('Skipping app update install because native updater is unsupported.');
|
||||
return;
|
||||
}
|
||||
updater.quitAndInstall(false, true);
|
||||
},
|
||||
};
|
||||
}
|
||||
//# sourceMappingURL=app-updater.js.map
|
||||
@@ -15,6 +15,7 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"koffi": "^2.15.6",
|
||||
"libsql": "^0.5.22",
|
||||
"vscode-json-languageservice": "^5.7.2",
|
||||
"ws": "^8.19.0",
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -188,6 +189,8 @@
|
||||
|
||||
"@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
|
||||
|
||||
"@vscode/l10n": ["@vscode/l10n@0.0.18", "", {}, "sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ=="],
|
||||
|
||||
"@xhayper/discord-rpc": ["@xhayper/discord-rpc@1.3.3", "", { "dependencies": { "@discordjs/rest": "^2.6.1", "@vladfrangu/async_event_emitter": "^2.4.7", "discord-api-types": "^0.38.42", "ws": "^8.20.0" } }, "sha512-Ih48GHiua7TtZgKO+f0uZPhCeQqb84fY2qUys/oMh8UbUfiUkUJLVCmd/v2AK0/pV33euh0aqSXo7+9LiPSwGw=="],
|
||||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
|
||||
@@ -722,6 +725,14 @@
|
||||
|
||||
"verror": ["verror@1.10.1", "", { "dependencies": { "assert-plus": "^1.0.0", "core-util-is": "1.0.2", "extsprintf": "^1.2.0" } }, "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg=="],
|
||||
|
||||
"vscode-json-languageservice": ["vscode-json-languageservice@5.7.2", "", { "dependencies": { "@vscode/l10n": "^0.0.18", "jsonc-parser": "^3.3.1", "vscode-languageserver-textdocument": "^1.0.12", "vscode-languageserver-types": "^3.17.5", "vscode-uri": "^3.1.0" } }, "sha512-WtKRDtJfFEmLrgtu+ODexOHm/6/krRF0k6t+uvkKIKW1Jh9ZIyxZQwJJwB3qhrEgvAxa37zbUg+vn+UyUK/U2w=="],
|
||||
|
||||
"vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="],
|
||||
|
||||
"vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="],
|
||||
|
||||
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
|
||||
|
||||
"wcwidth": ["wcwidth@1.0.1", "", { "dependencies": { "defaults": "^1.0.3" } }, "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg=="],
|
||||
|
||||
"which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="],
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: config
|
||||
|
||||
- Settings: Changed the AniSkip button key setting to use click-to-learn key capture instead of raw text entry.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: config
|
||||
|
||||
- Updated the generated example config to use the same CSS declaration paths written by the Settings window for subtitle and sidebar appearance.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: config
|
||||
|
||||
- Reorganized the Configuration window into clearer Appearance, Behavior, Anki, input, and integration sections with learned keybinding controls and AnkiConnect-backed deck, field, and note-type pickers.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: settings
|
||||
|
||||
- Simplified configuration option rows by hiding raw config paths and placing the live/restart status beside each option title.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: config
|
||||
|
||||
- Fixed Configuration window search so it searches across all categories, narrows on multi-word terms, hides settings owned by richer editors, and no longer shows the Open File button.
|
||||
@@ -0,0 +1,6 @@
|
||||
type: added
|
||||
area: config
|
||||
|
||||
- Added a dedicated Configuration window with launcher entry points via `subminer --config` and `subminer config`.
|
||||
- Fixed the Configuration window preload so launcher-opened windows can initialize even when Electron sandboxing is active.
|
||||
- Kept config-window startup lightweight by skipping AniList token refresh and automatic update polling.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: docs
|
||||
area: docs
|
||||
|
||||
- Published stable docs at the site root with current development docs under `/main/`.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: subtitles
|
||||
|
||||
- Kept frequency highlighting for determiner-led noun compounds like `その場` while still filtering standalone determiners.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: launcher
|
||||
|
||||
- Suppressed Electron macOS menu diagnostics from `subminer config` launcher output.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: docs
|
||||
|
||||
- Fixed versioned docs navigation so archived pages keep local links under the selected version, the version switcher no longer nests targets under the current archive path, local dev version routes serve warmed archive files instead of redirecting to production or falling through to VitePress 404s, and internal README files do not break archived builds.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: config
|
||||
|
||||
- Config: Moved known-word and N+1 annotation colors to `subtitleStyle.knownWordColor` and `subtitleStyle.nPlusOneColor`; legacy Anki color keys are still accepted with warnings.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Added `subminer --version` and `subminer -v` to print the installed SubMiner app version.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: updater
|
||||
|
||||
- Fixed Linux automatic update checks to avoid Electron networking, preventing native Electron network-service crashes during video startup.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: added
|
||||
area: launcher
|
||||
|
||||
- Managed bundled mpv plugin startup options from SubMiner config.
|
||||
@@ -1,4 +1,6 @@
|
||||
type: fixed
|
||||
area: updates
|
||||
|
||||
- Kept signed macOS app updates on the native updater path while preventing eager Squirrel install checks before the user confirms restart.
|
||||
- Restored the standard macOS `electron-updater`/Squirrel update path and routed supplemental GitHub updater requests through Electron networking instead of Node fetch.
|
||||
- macOS update checks now skip local build-output apps outside Applications before touching Squirrel, and macOS tray checks no longer perform the supplemental GitHub asset lookup.
|
||||
- macOS `electron-updater` metadata and full ZIP downloads now use `/usr/bin/curl` under the hood to avoid the Electron network crash seen during tray update checks while preserving Squirrel installation.
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: config
|
||||
|
||||
- Defaulted the note-fields note type picker to the configured Anki deck's note type when available, then exact `Kiku`, then exact `Lapis`, otherwise leaving it blank for manual selection.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Kept the visible overlay and subtitle stream alive after restarting SubMiner from the mpv `y-r` shortcut by transporting Linux AppImage control args safely, restoring mpv subtitle visibility during shutdown, snapshotting subtitles before overlay suppression resumes, reapplying Linux overlay bounds after the restarted window maps, allowing Hyprland to resize the visible overlay window, and preserving user-paused playback while readiness gates clear.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: config
|
||||
|
||||
- Config: Primary and secondary subtitle appearance now use color controls plus CSS declaration editors, saved as `subtitleStyle.css` and `subtitleStyle.secondary.css`.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: config
|
||||
|
||||
- Migrated legacy subtitle hover token colors into `subtitleStyle.css` instead of leaving `hoverTokenColor` or `hoverTokenBackgroundColor` behind.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: config
|
||||
|
||||
- Migrated legacy primary and secondary subtitle appearance options into `subtitleStyle.css` automatically when loading config files.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: config
|
||||
|
||||
- Fixed live Configuration window saves so primary and secondary subtitle CSS declarations apply immediately to open video overlays.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: changed
|
||||
area: config
|
||||
|
||||
- Added `subtitleSidebar.css`, migrated legacy sidebar appearance fields into it, and updated subtitle font defaults to `Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP`.
|
||||
@@ -0,0 +1,4 @@
|
||||
type: fixed
|
||||
area: overlay
|
||||
|
||||
- Fixed Yomitan popups not opening when playback/overlay startup races the Yomitan extension load.
|
||||
+124
-109
@@ -7,10 +7,11 @@
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Overlay Auto-Start
|
||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
||||
// Visible Overlay Auto-Start
|
||||
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||
// SubMiner can still auto-start in the background when this is false.
|
||||
// ==========================================
|
||||
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
||||
"auto_start_overlay": true, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
|
||||
|
||||
// ==========================================
|
||||
// Texthooker Server
|
||||
@@ -18,7 +19,7 @@
|
||||
// ==========================================
|
||||
"texthooker": {
|
||||
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
||||
"openBrowser": false // Open browser setting. Values: true | false
|
||||
"openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false
|
||||
}, // Configure texthooker startup launch and browser opening behavior.
|
||||
|
||||
// ==========================================
|
||||
@@ -174,24 +175,24 @@
|
||||
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
|
||||
// ==========================================
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||
"triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
|
||||
"triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
|
||||
"mineSentence": "CommandOrControl+S", // Mine sentence setting.
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.
|
||||
"copySubtitle": "CommandOrControl+C", // Accelerator that copies the current subtitle line to the clipboard.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Accelerator that updates the last mined Anki card using the current clipboard contents.
|
||||
"triggerFieldGrouping": "CommandOrControl+G", // Accelerator that triggers Kiku field grouping on duplicate cards.
|
||||
"triggerSubsync": "Ctrl+Alt+S", // Accelerator that triggers subsync against the active subtitle file.
|
||||
"mineSentence": "CommandOrControl+S", // Accelerator that mines the current sentence as a new Anki card.
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Accelerator that mines consecutive sentences while the multi-mine window stays open.
|
||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Open session help setting.
|
||||
"openControllerSelect": "Alt+C", // Open controller select setting.
|
||||
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
|
||||
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
|
||||
"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.
|
||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
@@ -328,9 +329,9 @@
|
||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||
// ==========================================
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||
"defaultMode": "hover" // Default mode setting.
|
||||
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
|
||||
"autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false
|
||||
"defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover
|
||||
}, // Dual subtitle track options.
|
||||
|
||||
// ==========================================
|
||||
@@ -339,9 +340,9 @@
|
||||
// ==========================================
|
||||
"subsync": {
|
||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||
"alass_path": "", // Alass path setting.
|
||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
|
||||
}, // Subsync engine and executable paths.
|
||||
|
||||
@@ -350,7 +351,7 @@
|
||||
// Initial vertical subtitle position from the bottom.
|
||||
// ==========================================
|
||||
"subtitlePosition": {
|
||||
"yPercent": 10 // Y percent setting.
|
||||
"yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.
|
||||
}, // Initial vertical subtitle position from the bottom.
|
||||
|
||||
// ==========================================
|
||||
@@ -360,29 +361,31 @@
|
||||
// ==========================================
|
||||
"subtitleStyle": {
|
||||
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||
"color": "#cad3f5", // Color setting.
|
||||
"background-color": "transparent", // Background color setting.
|
||||
"font-size": "35px", // Font size setting.
|
||||
"font-weight": "600", // Font weight setting.
|
||||
"font-style": "normal", // Font style setting.
|
||||
"line-height": "1.35", // Line height setting.
|
||||
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||
"word-spacing": "0", // Word spacing setting.
|
||||
"font-kerning": "normal", // Font kerning setting.
|
||||
"text-rendering": "geometricPrecision", // Text rendering setting.
|
||||
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
|
||||
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
|
||||
"--subtitle-hover-token-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle hover token background color setting.
|
||||
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
|
||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
||||
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||
"fontSize": 35, // Font size setting.
|
||||
"fontColor": "#cad3f5", // Font color setting.
|
||||
"fontWeight": "600", // Font weight setting.
|
||||
"lineHeight": 1.35, // Line height setting.
|
||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||
"wordSpacing": 0, // Word spacing setting.
|
||||
"fontKerning": "normal", // Font kerning setting.
|
||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||
"fontStyle": "normal", // Font style setting.
|
||||
"backgroundColor": "transparent", // Background color setting.
|
||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
||||
"knownWordColor": "#a6da95", // Known word color setting.
|
||||
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||
"jlptColors": {
|
||||
"N1": "#ed8796", // N1 setting.
|
||||
"N2": "#f5a97f", // N2 setting.
|
||||
@@ -406,19 +409,21 @@
|
||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||
}, // Frequency dictionary setting.
|
||||
"secondary": {
|
||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
||||
"fontSize": 24, // Font size setting.
|
||||
"fontColor": "#cad3f5", // Font color setting.
|
||||
"lineHeight": 1.35, // Line height setting.
|
||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||
"wordSpacing": 0, // Word spacing setting.
|
||||
"fontKerning": "normal", // Font kerning setting.
|
||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||
"backgroundColor": "transparent", // Background color setting.
|
||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||
"fontWeight": "600", // Font weight setting.
|
||||
"fontStyle": "normal" // Font style setting.
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||
"color": "#cad3f5", // Color setting.
|
||||
"background-color": "transparent", // Background color setting.
|
||||
"font-size": "24px", // Font size setting.
|
||||
"font-weight": "600", // Font weight setting.
|
||||
"font-style": "normal", // Font style setting.
|
||||
"line-height": "1.35", // Line height setting.
|
||||
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||
"word-spacing": "0", // Word spacing setting.
|
||||
"font-kerning": "normal", // Font kerning setting.
|
||||
"text-rendering": "geometricPrecision", // Text rendering setting.
|
||||
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||
"backdrop-filter": "blur(6px)" // Backdrop filter setting.
|
||||
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
|
||||
} // Secondary setting.
|
||||
}, // Primary and secondary subtitle styling.
|
||||
|
||||
@@ -434,16 +439,18 @@
|
||||
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
||||
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
||||
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
|
||||
"opacity": 0.95, // Base opacity applied to the sidebar shell.
|
||||
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
|
||||
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
|
||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
|
||||
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
|
||||
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
|
||||
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
|
||||
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
|
||||
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||
"color": "#cad3f5", // Color setting.
|
||||
"background-color": "rgba(73, 77, 100, 0.9)", // Background color setting.
|
||||
"font-size": "16px", // Font size setting.
|
||||
"opacity": "0.95", // Opacity setting.
|
||||
"--subtitle-sidebar-max-width": "420px", // Subtitle sidebar max width setting.
|
||||
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
|
||||
"--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting.
|
||||
"--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting.
|
||||
"--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting.
|
||||
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
|
||||
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||
|
||||
// ==========================================
|
||||
@@ -454,9 +461,9 @@
|
||||
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
|
||||
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
|
||||
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
|
||||
"model": "openai/gpt-4o-mini", // Model setting.
|
||||
"model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider.
|
||||
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests.
|
||||
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests.
|
||||
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||
|
||||
@@ -469,7 +476,7 @@
|
||||
// ==========================================
|
||||
"ankiConnect": {
|
||||
"enabled": true, // Enable AnkiConnect integration. Values: true | false
|
||||
"url": "http://127.0.0.1:8765", // Url setting.
|
||||
"url": "http://127.0.0.1:8765", // Base URL of the AnkiConnect HTTP server.
|
||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||
"proxy": {
|
||||
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
||||
@@ -482,11 +489,11 @@
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Audio setting.
|
||||
"image": "Picture", // Image setting.
|
||||
"sentence": "Sentence", // Sentence setting.
|
||||
"miscInfo": "MiscInfo", // Misc info setting.
|
||||
"translation": "SelectionText" // Translation setting.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
||||
"translation": "SelectionText" // Card field that receives the current selection or translated text.
|
||||
}, // Fields setting.
|
||||
"ai": {
|
||||
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
||||
@@ -494,50 +501,49 @@
|
||||
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows.
|
||||
}, // Ai setting.
|
||||
"media": {
|
||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||
"generateImage": true, // Generate image setting. Values: true | false
|
||||
"imageType": "static", // Image type setting.
|
||||
"imageFormat": "jpg", // Image format setting.
|
||||
"imageQuality": 92, // Image quality setting.
|
||||
"animatedFps": 10, // Animated fps setting.
|
||||
"animatedMaxWidth": 640, // Animated max width setting.
|
||||
"animatedCrf": 35, // Animated crf setting.
|
||||
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
|
||||
"generateImage": true, // Generate screenshot or animated image for mined cards. Values: true | false
|
||||
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
|
||||
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
|
||||
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
|
||||
"animatedFps": 10, // Target frame rate for animated AVIF captures.
|
||||
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"audioPadding": 0.5, // Audio padding setting.
|
||||
"fallbackDuration": 3, // Fallback duration setting.
|
||||
"maxMediaDuration": 30 // Max media duration setting.
|
||||
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
}, // Media setting.
|
||||
"knownWords": {
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
||||
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||
"color": "#a6da95" // Color used for known-word highlights.
|
||||
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||
"overwriteImage": true, // Overwrite image setting. Values: true | false
|
||||
"mediaInsertMode": "append", // Media insert mode setting.
|
||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||
"notificationType": "osd", // Notification type setting.
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
|
||||
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
|
||||
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
|
||||
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||
}, // Behavior setting.
|
||||
"nPlusOne": {
|
||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
|
||||
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
|
||||
"minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||
}, // N plus one setting.
|
||||
"metadata": {
|
||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
||||
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
||||
}, // Metadata setting.
|
||||
"isLapis": {
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
||||
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
||||
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
|
||||
}, // Is lapis setting.
|
||||
"isKiku": {
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||
} // Is kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
@@ -546,7 +552,7 @@
|
||||
// Jimaku API configuration and defaults.
|
||||
// ==========================================
|
||||
"jimaku": {
|
||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
@@ -598,14 +604,23 @@
|
||||
|
||||
// ==========================================
|
||||
// MPV Launcher
|
||||
// Optional mpv.exe override for Windows playback entry points.
|
||||
// SubMiner-managed mpv launch and bundled plugin options.
|
||||
// Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.
|
||||
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
|
||||
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||
// ==========================================
|
||||
"mpv": {
|
||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||
}, // Optional mpv.exe override for Windows playback entry points.
|
||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
|
||||
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
|
||||
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
|
||||
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||
|
||||
// ==========================================
|
||||
// Jellyfin
|
||||
@@ -618,9 +633,9 @@
|
||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||
"username": "", // Default Jellyfin username used during CLI login.
|
||||
"deviceId": "subminer", // Device id setting.
|
||||
"clientName": "SubMiner", // Client name setting.
|
||||
"clientVersion": "0.1.0", // Client version setting.
|
||||
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||
@@ -648,7 +663,7 @@
|
||||
// ==========================================
|
||||
"discordPresence": {
|
||||
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal
|
||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||
|
||||
+355
-65
@@ -1,3 +1,7 @@
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs';
|
||||
import { extname, join, posix, resolve, sep } from 'node:path';
|
||||
import type { DefaultTheme, HeadConfig, TransformContext, UserConfig } from 'vitepress';
|
||||
|
||||
const DOCS_HOSTNAME = 'https://docs.subminer.moe';
|
||||
const PLAUSIBLE_PROXY_HOSTNAME = 'https://worker.sudacode.com';
|
||||
const PLAUSIBLE_SITE_SCRIPT_PATH = '/js/pa-h28Pn9ppgTJRmiSJlyPT6.js';
|
||||
@@ -7,20 +11,358 @@ const PLAUSIBLE_INIT_SCRIPT = [
|
||||
`plausible.init({ endpoint: '${PLAUSIBLE_ENDPOINT}' });`,
|
||||
].join('\n');
|
||||
|
||||
function pageToCanonicalHref(page: string): string | null {
|
||||
type DocsChannel = 'stable-root' | 'stable-archive' | 'main';
|
||||
|
||||
type VersionManifest = {
|
||||
latestStable: string;
|
||||
channels: Array<{ label: string; path: string }>;
|
||||
versions: Array<{ version: string; path: string }>;
|
||||
};
|
||||
|
||||
const base = normalizeBase(process.env.SUBMINER_DOCS_BASE ?? '/');
|
||||
const outDir = process.env.SUBMINER_DOCS_OUT_DIR;
|
||||
const docsSourceDir = process.env.SUBMINER_DOCS_SOURCE_DIR ?? process.cwd();
|
||||
const localArchiveDir = resolve(
|
||||
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR ??
|
||||
join(docsSourceDir, '..', '.tmp/docs-versioned-site'),
|
||||
);
|
||||
const channel = normalizeChannel(process.env.SUBMINER_DOCS_CHANNEL);
|
||||
const docsVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const latestStable = process.env.SUBMINER_DOCS_LATEST_STABLE ?? 'v0.14.0';
|
||||
const versionManifest = parseVersionManifest(process.env.SUBMINER_DOCS_VERSION_MANIFEST);
|
||||
const versionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN ?? 'production';
|
||||
|
||||
function normalizeBase(value: string): string {
|
||||
if (!value || value === '/') return '/';
|
||||
return `/${value.replace(/^\/+|\/+$/g, '')}/`;
|
||||
}
|
||||
|
||||
function normalizeChannel(value: string | undefined): DocsChannel {
|
||||
if (value === 'main' || value === 'stable-archive') return value;
|
||||
return 'stable-root';
|
||||
}
|
||||
|
||||
function parseVersionManifest(value: string | undefined): VersionManifest {
|
||||
if (!value) {
|
||||
return {
|
||||
latestStable,
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [{ version: latestStable, path: `/v/${latestStable.replace(/^v/, '')}/` }],
|
||||
};
|
||||
}
|
||||
|
||||
return JSON.parse(value) as VersionManifest;
|
||||
}
|
||||
|
||||
function withDocsBase(path: string): string {
|
||||
if (/^[a-z]+:\/\//i.test(path)) return path;
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
if (base === '/') return normalizedPath;
|
||||
return `${base.replace(/\/$/, '')}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function pageToRoute(page: string): string | null {
|
||||
if (page === '404.md') return null;
|
||||
|
||||
const route = page
|
||||
.replace(/(^|\/)index\.md$/, '')
|
||||
.replace(/\.md$/, '')
|
||||
.replace(/\/$/, '');
|
||||
return route ? `${DOCS_HOSTNAME}/${route}` : `${DOCS_HOSTNAME}/`;
|
||||
return route ? `/${route}` : '/';
|
||||
}
|
||||
|
||||
export default {
|
||||
function pageToCanonicalHref(page: string): string | null {
|
||||
const route = pageToRoute(page);
|
||||
if (!route) return null;
|
||||
|
||||
if (channel === 'main') {
|
||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||
}
|
||||
|
||||
if (channel === 'stable-archive' && docsVersion !== latestStable) {
|
||||
return `${DOCS_HOSTNAME}${canonicalRouteWithBase(route)}`;
|
||||
}
|
||||
|
||||
return route === '/' ? `${DOCS_HOSTNAME}/` : `${DOCS_HOSTNAME}${route}`;
|
||||
}
|
||||
|
||||
function canonicalRouteWithBase(route: string): string {
|
||||
const routeWithBase = withDocsBase(route);
|
||||
return route === '/' ? routeWithBase : routeWithBase.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
function transformPageHead({ page }: TransformContext): HeadConfig[] {
|
||||
const href = pageToCanonicalHref(page);
|
||||
const head: HeadConfig[] = href ? [['link', { rel: 'canonical', href }]] : [];
|
||||
|
||||
if (channel === 'main') {
|
||||
head.push(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||
}
|
||||
|
||||
return head;
|
||||
}
|
||||
|
||||
function linkToPagePath(link: string): string | null {
|
||||
if (!link.startsWith('/') || link.startsWith('/v/') || link.startsWith('/main/')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const withoutHash = link.split('#')[0] ?? '/';
|
||||
const withoutQuery = withoutHash.split('?')[0] ?? '/';
|
||||
const route = withoutQuery.replace(/^\/+|\/+$/g, '');
|
||||
return route ? `${route}.md` : 'index.md';
|
||||
}
|
||||
|
||||
function hasPageForLink(link: string): boolean {
|
||||
const pagePath = linkToPagePath(link);
|
||||
if (!pagePath) return true;
|
||||
return existsSync(join(docsSourceDir, pagePath));
|
||||
}
|
||||
|
||||
function filterNav(items: DefaultTheme.NavItem[]): DefaultTheme.NavItem[] {
|
||||
return items
|
||||
.map((item) => {
|
||||
if ('items' in item && item.items) {
|
||||
return { ...item, items: filterNav(item.items as DefaultTheme.NavItem[]) };
|
||||
}
|
||||
if ('link' in item && item.link && !hasPageForLink(item.link)) {
|
||||
return null;
|
||||
}
|
||||
return item;
|
||||
})
|
||||
.filter((item): item is DefaultTheme.NavItem => Boolean(item));
|
||||
}
|
||||
|
||||
function filterSidebar(items: DefaultTheme.SidebarItem[]): DefaultTheme.SidebarItem[] {
|
||||
return items
|
||||
.map((item) => {
|
||||
const filteredChildren = item.items ? filterSidebar(item.items) : undefined;
|
||||
if (item.link && !hasPageForLink(item.link)) return null;
|
||||
if (item.items && filteredChildren?.length === 0 && !item.link) return null;
|
||||
return { ...item, items: filteredChildren };
|
||||
})
|
||||
.filter((item): item is DefaultTheme.SidebarItem => Boolean(item));
|
||||
}
|
||||
|
||||
function versionSwitchLink(path: string): string {
|
||||
if (/^[a-z]+:\/\//i.test(path)) return path;
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
if (versionLinkOrigin === 'local') return localVersionSwitchLink(normalizedPath);
|
||||
return `${DOCS_HOSTNAME}${normalizedPath}`;
|
||||
}
|
||||
|
||||
function localVersionSwitchLink(path: string): string {
|
||||
if (base === '/') return path;
|
||||
|
||||
const basePath = base.replace(/\/$/, '');
|
||||
const targetPath = path === '/' ? '/' : path.replace(/\/$/, '');
|
||||
const relativePath = posix.relative(basePath, targetPath) || '.';
|
||||
|
||||
return path.endsWith('/') ? `${relativePath}/` : relativePath;
|
||||
}
|
||||
|
||||
function shouldHandleLocalVersionRoute(pathname: string): boolean {
|
||||
if (base !== '/' || channel !== 'stable-root') return false;
|
||||
return /^\/main(?:\/|$)/.test(pathname) || /^\/v\/[^/]+(?:\/|$)/.test(pathname);
|
||||
}
|
||||
|
||||
function contentTypeForPath(path: string): string {
|
||||
switch (extname(path)) {
|
||||
case '.css':
|
||||
return 'text/css; charset=utf-8';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.ico':
|
||||
return 'image/x-icon';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
return 'image/jpeg';
|
||||
case '.js':
|
||||
case '.mjs':
|
||||
return 'text/javascript; charset=utf-8';
|
||||
case '.json':
|
||||
case '.jsonc':
|
||||
return 'application/json; charset=utf-8';
|
||||
case '.mp4':
|
||||
return 'video/mp4';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.ttf':
|
||||
return 'font/ttf';
|
||||
case '.webm':
|
||||
return 'video/webm';
|
||||
case '.woff':
|
||||
return 'font/woff';
|
||||
case '.woff2':
|
||||
return 'font/woff2';
|
||||
case '.xml':
|
||||
return 'application/xml; charset=utf-8';
|
||||
default:
|
||||
return 'text/html; charset=utf-8';
|
||||
}
|
||||
}
|
||||
|
||||
function isFile(path: string): boolean {
|
||||
try {
|
||||
return statSync(path).isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function archiveFileForPathname(pathname: string): string | null {
|
||||
if (!shouldHandleLocalVersionRoute(pathname)) return null;
|
||||
|
||||
const routePath = decodeURIComponent(pathname).replace(/^\/+/, '');
|
||||
const filePath = resolve(localArchiveDir, routePath);
|
||||
if (filePath !== localArchiveDir && !filePath.startsWith(`${localArchiveDir}${sep}`)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = pathname.endsWith('/')
|
||||
? [join(filePath, 'index.html')]
|
||||
: extname(filePath)
|
||||
? [filePath]
|
||||
: [`${filePath}.html`, join(filePath, 'index.html')];
|
||||
|
||||
return candidates.find(isFile) ?? null;
|
||||
}
|
||||
|
||||
function serveLocalArchiveRoute(pathname: string, response: DevServerResponse): boolean {
|
||||
if (versionLinkOrigin !== 'local') return false;
|
||||
|
||||
const filePath = archiveFileForPathname(pathname);
|
||||
if (!filePath) return false;
|
||||
|
||||
response.statusCode = 200;
|
||||
response.setHeader('Content-Type', contentTypeForPath(filePath));
|
||||
response.end(readFileSync(filePath));
|
||||
return true;
|
||||
}
|
||||
|
||||
type DevServerResponse = {
|
||||
statusCode: number;
|
||||
setHeader(name: string, value: string): void;
|
||||
end(chunk?: string | Uint8Array): void;
|
||||
};
|
||||
|
||||
const versionItems = [
|
||||
{
|
||||
text: `Latest stable (${versionManifest.latestStable})`,
|
||||
link: versionSwitchLink('/'),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
},
|
||||
...versionManifest.channels
|
||||
.filter((entry) => entry.label !== 'Latest stable')
|
||||
.map((entry) => ({
|
||||
text: entry.label,
|
||||
link: versionSwitchLink(entry.path),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
})),
|
||||
...versionManifest.versions.map((entry) => ({
|
||||
text: entry.version,
|
||||
link: versionSwitchLink(entry.path),
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
})),
|
||||
];
|
||||
|
||||
const nav: DefaultTheme.NavItem[] = [
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: 'Get Started', link: '/installation' },
|
||||
{ text: 'Mining', link: '/mining-workflow' },
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Changelog', link: '/changelog' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
{ text: docsVersion ?? (channel === 'main' ? 'main' : latestStable), items: versionItems },
|
||||
];
|
||||
|
||||
const sidebar: DefaultTheme.SidebarItem[] = [
|
||||
{
|
||||
text: 'Getting Started',
|
||||
items: [
|
||||
{ text: 'Overview', link: '/' },
|
||||
{ text: 'Installation', link: '/installation' },
|
||||
{ text: 'Usage', link: '/usage' },
|
||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Reference',
|
||||
items: [
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Keyboard Shortcuts', link: '/shortcuts' },
|
||||
{ text: 'Subtitle Annotations', link: '/subtitle-annotations' },
|
||||
{ text: 'Subtitle Sidebar', link: '/subtitle-sidebar' },
|
||||
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Integrations',
|
||||
items: [
|
||||
{ text: 'MPV Plugin', link: '/mpv-plugin' },
|
||||
{ text: 'Anki', link: '/anki-integration' },
|
||||
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
||||
{ text: 'YouTube', link: '/youtube-integration' },
|
||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Development',
|
||||
items: [
|
||||
{ text: 'Building & Testing', link: '/development' },
|
||||
{ text: 'Architecture', link: '/architecture' },
|
||||
{ text: 'IPC + Runtime Contracts', link: '/ipc-contracts' },
|
||||
{ text: 'WebSocket + Texthooker API', link: '/websocket-texthooker-api' },
|
||||
{ text: 'Changelog', link: '/changelog' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const config: UserConfig = {
|
||||
title: 'SubMiner Docs',
|
||||
description:
|
||||
'SubMiner: an MPV immersion-mining overlay with Yomitan and AnkiConnect integration.',
|
||||
base,
|
||||
...(outDir ? { outDir } : {}),
|
||||
vite: {
|
||||
plugins: [
|
||||
{
|
||||
name: 'subminer-docs-local-version-redirects',
|
||||
configureServer(server) {
|
||||
server.middlewares.use((request, response, next) => {
|
||||
const requestUrl = new URL(request.url ?? '/', 'http://localhost');
|
||||
if (serveLocalArchiveRoute(requestUrl.pathname, response)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldHandleLocalVersionRoute(requestUrl.pathname)) {
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
response.statusCode = 302;
|
||||
response.setHeader(
|
||||
'Location',
|
||||
`${DOCS_HOSTNAME}${requestUrl.pathname}${requestUrl.search}`,
|
||||
);
|
||||
response.end();
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
head: [
|
||||
['link', { rel: 'preconnect', href: PLAUSIBLE_PROXY_HOSTNAME }],
|
||||
[
|
||||
@@ -31,13 +373,13 @@ export default {
|
||||
},
|
||||
],
|
||||
['script', {}, PLAUSIBLE_INIT_SCRIPT],
|
||||
['link', { rel: 'icon', href: '/favicon.ico', sizes: 'any' }],
|
||||
['link', { rel: 'icon', href: withDocsBase('/favicon.ico'), sizes: 'any' }],
|
||||
[
|
||||
'link',
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
href: '/favicon-32x32.png',
|
||||
href: withDocsBase('/favicon-32x32.png'),
|
||||
sizes: '32x32',
|
||||
},
|
||||
],
|
||||
@@ -46,7 +388,7 @@ export default {
|
||||
{
|
||||
rel: 'icon',
|
||||
type: 'image/png',
|
||||
href: '/favicon-16x16.png',
|
||||
href: withDocsBase('/favicon-16x16.png'),
|
||||
sizes: '16x16',
|
||||
},
|
||||
],
|
||||
@@ -54,7 +396,7 @@ export default {
|
||||
'link',
|
||||
{
|
||||
rel: 'apple-touch-icon',
|
||||
href: '/apple-touch-icon.png',
|
||||
href: withDocsBase('/apple-touch-icon.png'),
|
||||
sizes: '180x180',
|
||||
},
|
||||
],
|
||||
@@ -70,12 +412,9 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
transformHead({ page }) {
|
||||
const href = pageToCanonicalHref(page);
|
||||
return href ? [['link', { rel: 'canonical', href }]] : [];
|
||||
},
|
||||
transformHead: transformPageHead,
|
||||
lastUpdated: true,
|
||||
srcExclude: ['subagents/**'],
|
||||
srcExclude: ['subagents/**', 'README.md'],
|
||||
markdown: {
|
||||
theme: {
|
||||
light: 'catppuccin-latte',
|
||||
@@ -88,59 +427,8 @@ export default {
|
||||
dark: '/assets/SubMiner.png',
|
||||
},
|
||||
siteTitle: 'SubMiner Docs',
|
||||
nav: [
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: 'Get Started', link: '/installation' },
|
||||
{ text: 'Mining', link: '/mining-workflow' },
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Changelog', link: '/changelog' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
text: 'Getting Started',
|
||||
items: [
|
||||
{ text: 'Overview', link: '/' },
|
||||
{ text: 'Installation', link: '/installation' },
|
||||
{ text: 'Usage', link: '/usage' },
|
||||
{ text: 'Mining Workflow', link: '/mining-workflow' },
|
||||
{ text: 'Launcher Script', link: '/launcher-script' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Reference',
|
||||
items: [
|
||||
{ text: 'Configuration', link: '/configuration' },
|
||||
{ text: 'Keyboard Shortcuts', link: '/shortcuts' },
|
||||
{ text: 'Subtitle Annotations', link: '/subtitle-annotations' },
|
||||
{ text: 'Subtitle Sidebar', link: '/subtitle-sidebar' },
|
||||
{ text: 'Immersion Tracking', link: '/immersion-tracking' },
|
||||
{ text: 'Troubleshooting', link: '/troubleshooting' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Integrations',
|
||||
items: [
|
||||
{ text: 'MPV Plugin', link: '/mpv-plugin' },
|
||||
{ text: 'Anki', link: '/anki-integration' },
|
||||
{ text: 'Jellyfin', link: '/jellyfin-integration' },
|
||||
{ text: 'YouTube', link: '/youtube-integration' },
|
||||
{ text: 'Jimaku', link: '/jimaku-integration' },
|
||||
{ text: 'AniList', link: '/anilist-integration' },
|
||||
{ text: 'Character Dictionary', link: '/character-dictionary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
text: 'Development',
|
||||
items: [
|
||||
{ text: 'Building & Testing', link: '/development' },
|
||||
{ text: 'Architecture', link: '/architecture' },
|
||||
{ text: 'IPC + Runtime Contracts', link: '/ipc-contracts' },
|
||||
{ text: 'WebSocket + Texthooker API', link: '/websocket-texthooker-api' },
|
||||
{ text: 'Changelog', link: '/changelog' },
|
||||
],
|
||||
},
|
||||
],
|
||||
nav: filterNav(nav),
|
||||
sidebar: filterSidebar(sidebar),
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
@@ -159,3 +447,5 @@ export default {
|
||||
socialLinks: [{ icon: 'github', link: 'https://github.com/ksyasuda/SubMiner' }],
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { useRoute, useData } from 'vitepress';
|
||||
import { computed } from 'vue';
|
||||
import { formatStatusLineFilePath } from '../status-line';
|
||||
|
||||
const route = useRoute();
|
||||
const { page, frontmatter } = useData();
|
||||
@@ -12,8 +13,7 @@ const mode = computed(() => {
|
||||
});
|
||||
|
||||
const filePath = computed(() => {
|
||||
const path = route.path;
|
||||
return path === '/' ? 'index.md' : `${path.replace(/^\//, '')}.md`;
|
||||
return formatStatusLineFilePath(route.path);
|
||||
});
|
||||
|
||||
const section = computed(() => {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { formatStatusLineFilePath } from './status-line';
|
||||
|
||||
test('status line file path formats root home as index markdown', () => {
|
||||
expect(formatStatusLineFilePath('/')).toBe('index.md');
|
||||
});
|
||||
|
||||
test('status line file path formats version archive home without trailing slash', () => {
|
||||
expect(formatStatusLineFilePath('/v/0.12.0/')).toBe('v/0.12.0.md');
|
||||
});
|
||||
|
||||
test('status line file path keeps normal docs routes as markdown files', () => {
|
||||
expect(formatStatusLineFilePath('/v/0.12.0/configuration')).toBe(
|
||||
'v/0.12.0/configuration.md',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export function formatStatusLineFilePath(routePath: string): string {
|
||||
if (routePath === '/') return 'index.md';
|
||||
return `${routePath.replace(/^\/|\/$/g, '')}.md`;
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'M PLUS 1';
|
||||
src: url('/assets/fonts/Mplus1-Medium.ttf') format('truetype');
|
||||
src: url('../../public/assets/fonts/Mplus1-Medium.ttf') format('truetype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
@font-face {
|
||||
font-family: 'Manrope Default';
|
||||
src: url('/assets/fonts/manrope-latin-600-normal.ttf') format('truetype');
|
||||
src: url('../../public/assets/fonts/manrope-latin-600-normal.ttf') format('truetype');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
|
||||
+12
-5
@@ -30,9 +30,16 @@ bun run docs:dev
|
||||
## Cloudflare Pages
|
||||
|
||||
- Git repo: `ksyasuda/SubMiner`
|
||||
- Root directory: `docs-site`
|
||||
- Build command: `bun run docs:build`
|
||||
- Build output directory: `.vitepress/dist`
|
||||
- Build watch paths: `docs-site/*`
|
||||
- Production branch: `main`
|
||||
- Automatic production and preview deployments: disabled
|
||||
- Custom domain: `docs.subminer.moe` attached to Production
|
||||
- Deployment path: GitHub Actions direct upload with Wrangler
|
||||
|
||||
Cloudflare Pages watch paths use a single `*` wildcard for monorepo subdirectories. `docs-site/*` matches nested files under the docs site; `docs-site/**` can cause docs-only pushes to be skipped.
|
||||
The public docs root is stable-only:
|
||||
|
||||
- `/` serves the latest stable release docs.
|
||||
- `/main/` serves development docs from `main` and is marked `noindex,follow`.
|
||||
- `/v/<version>/` serves stable release archives.
|
||||
- Prerelease tags do not update the docs site.
|
||||
|
||||
Keep Cloudflare Git auto-deploy disabled. The production deploy is `.github/workflows/docs-pages.yml`, which uploads `.tmp/docs-versioned-site` with `--branch main` so tag-triggered runs update Production instead of creating preview deployments.
|
||||
|
||||
+68
-30
@@ -4,9 +4,9 @@ outline: [2, 3]
|
||||
|
||||
# Configuration
|
||||
|
||||
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
|
||||
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
|
||||
When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress';
|
||||
</script>
|
||||
|
||||
## Quick Start
|
||||
|
||||
@@ -35,9 +35,38 @@ For most users, start with this minimal configuration:
|
||||
|
||||
Then customize as needed using the sections below.
|
||||
|
||||
## Settings
|
||||
|
||||
SubMiner includes a dedicated **Settings** window accessible from the tray menu, the app `--config` flag, or launcher commands such as `subminer --config` and `subminer config`. It is the primary way to configure SubMiner — all changes are written directly to `config.jsonc`, so manual file editing is not required for most users.
|
||||
|
||||
The Settings window groups options by workflow instead of mirroring the raw config-file shape:
|
||||
|
||||
- Appearance
|
||||
- Behavior
|
||||
- Mining & Anki
|
||||
- Playback & Sources
|
||||
- Input
|
||||
- Integrations
|
||||
- Tracking & App
|
||||
- Advanced
|
||||
|
||||
Each field still writes to its current `config.jsonc` path. For example, subtitle hover pause appears under **Behavior** / playback behavior, but saves to `subtitleStyle.autoPauseVideoOnHover`. Anki-aware fields can query AnkiConnect for deck names, note types, and field names, and keybinding fields use click-to-learn controls instead of raw text boxes.
|
||||
|
||||
The Settings window preserves existing JSONC comments, trailing commas, unrelated keys, and unsupported legacy options. Resetting a field removes the explicit config path so the built-in default applies.
|
||||
|
||||
Secret fields do not display stored values. They show whether a value is configured; entering a new value writes it, and reset clears the explicit path. Prefer command-based secret options such as `ai.apiKeyCommand` when available.
|
||||
|
||||
Saving validates the candidate config before writing. Live-reloadable changes are applied immediately; other changes return a restart-required banner in the window.
|
||||
|
||||
## Configuration File
|
||||
|
||||
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example configuration file with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
||||
The Settings window writes to `config.jsonc` directly, so most users do not need to edit the file by hand. The config file and the option reference below are provided for advanced use, scripting, or cases where you prefer editing config directly.
|
||||
|
||||
Settings are stored in `$XDG_CONFIG_HOME/SubMiner/config.jsonc` (or `~/.config/SubMiner/config.jsonc` when `XDG_CONFIG_HOME` is unset).
|
||||
On Windows, the default path is `%APPDATA%\SubMiner\config.jsonc`.
|
||||
When both files exist, SubMiner prefers `config.jsonc` over `config.json`.
|
||||
|
||||
See [config.example.jsonc](/config.example.jsonc) for a comprehensive example with all available options, default values, and detailed comments. Only include the options you want to customize in your config file.
|
||||
|
||||
Generate a fresh default config from the centralized config registry:
|
||||
|
||||
@@ -297,25 +326,29 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
```json
|
||||
{
|
||||
"subtitleStyle": {
|
||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||
"fontSize": 35,
|
||||
"fontColor": "#cad3f5",
|
||||
"fontWeight": "600",
|
||||
"lineHeight": 1.35,
|
||||
"letterSpacing": "-0.01em",
|
||||
"wordSpacing": 0,
|
||||
"fontKerning": "normal",
|
||||
"textRendering": "geometricPrecision",
|
||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
||||
"fontStyle": "normal",
|
||||
"backgroundColor": "transparent",
|
||||
"backdropFilter": "blur(6px)",
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP",
|
||||
"font-size": "35px",
|
||||
"font-weight": "600",
|
||||
"line-height": "1.35",
|
||||
"letter-spacing": "-0.01em",
|
||||
"word-spacing": "0",
|
||||
"font-kerning": "normal",
|
||||
"text-rendering": "geometricPrecision",
|
||||
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)",
|
||||
"font-style": "normal",
|
||||
"backdrop-filter": "blur(6px)"
|
||||
},
|
||||
"secondary": {
|
||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
||||
"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"
|
||||
"backgroundColor": "transparent",
|
||||
"css": {
|
||||
"font-family": "Inter, Noto Sans, Helvetica Neue, sans-serif",
|
||||
"font-size": "24px",
|
||||
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -326,6 +359,7 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| `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`) |
|
||||
| `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 |
|
||||
| `fontWeight` | string | CSS font-weight, e.g. `"bold"`, `"normal"`, `"600"` (default: `"600"`) |
|
||||
| `fontStyle` | string | `"normal"` or `"italic"` (default: `"normal"`) |
|
||||
| `backgroundColor` | string | Any CSS color, including `"transparent"` (default: `"transparent"`) |
|
||||
@@ -337,6 +371,8 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| `hoverTokenBackgroundColor` | string | CSS color used for hovered subtitle token background highlight; `hoverBackground` is accepted as an alias |
|
||||
| `nameMatchEnabled` | boolean | Enable subtitle token coloring for matches from the SubMiner character dictionary (`true` by default) |
|
||||
| `nameMatchColor` | string | Hex color used for subtitle tokens matched from the SubMiner character dictionary (default: `#f5bde6`) |
|
||||
| `knownWordColor` | string | Hex color used for known-word subtitle highlights (default: `#a6da95`) |
|
||||
| `nPlusOneColor` | string | Hex color used for the single N+1 target subtitle highlight (default: `#c6a0f6`) |
|
||||
| `frequencyDictionary.enabled` | boolean | Enable frequency highlighting from dictionary lookups (`false` 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.topX` | number | Only color tokens whose frequency rank is `<= topX` (`1000` by default) |
|
||||
@@ -344,10 +380,13 @@ See `config.example.jsonc` for detailed configuration options.
|
||||
| `frequencyDictionary.matchMode` | string | `"headword"` or `"surface"` (`"headword"` by default) |
|
||||
| `frequencyDictionary.singleColor` | string | Color used for all highlighted tokens in single mode |
|
||||
| `frequencyDictionary.bandedColors` | string[] | Array of five hex colors used for ranked bands in banded mode |
|
||||
| `nPlusOneColor` | string | Existing n+1 highlight color (default: `#c6a0f6`) |
|
||||
| `knownWordColor` | string | Existing known-word highlight color (default: `#a6da95`) |
|
||||
| `jlptColors` | object | JLPT level underline colors object (`N1`..`N5`) |
|
||||
| `secondary` | object | Override any of the above for secondary subtitles (optional) |
|
||||
| `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
|
||||
`subtitleStyle.css`, `subtitleStyle.secondary.css`, and `subtitleSidebar.css`. The generated example
|
||||
uses that same CSS declaration shape; existing top-level style keys such as `fontSize` and
|
||||
`textShadow` remain supported for hand-written or older configs.
|
||||
|
||||
Frequency dictionary highlighting uses the same dictionary file format as JLPT bundle lookups (`term_meta_bank_*.json` under discovered dictionary directories). A token is highlighted when it has a positive integer `frequencyRank` (lower is more common) and the rank is within `topX`.
|
||||
|
||||
@@ -937,11 +976,10 @@ This example is intentionally compact. The option table below documents availabl
|
||||
| `behavior.highlightWord` | `true`, `false` | Highlight the word in sentence context (default: `true`) |
|
||||
| `ankiConnect.knownWords.highlightEnabled` | `true`, `false` | Enable fast local highlighting for words already known in Anki (default: `false`) |
|
||||
| `ankiConnect.knownWords.addMinedWordsImmediately` | `true`, `false` | Add words from successful mines into the local known-word cache immediately (default: `true`) |
|
||||
| `ankiConnect.knownWords.color` | hex color string | Text color for tokens already found in the local known-word cache (default: `"#a6da95"`). |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"`, `"surface"` | Matching strategy for known-word highlighting (default: `"headword"`). `headword` uses token headwords; `surface` uses visible subtitle text. |
|
||||
| `ankiConnect.knownWords.refreshMinutes` | number | Minutes between known-word cache refreshes (default: `1440`) |
|
||||
| `ankiConnect.knownWords.decks` | object | Deck→fields mapping used for known-word cache query scope (e.g. `{ "Kaishi 1.5k": ["Word", "Word Reading"] }`). |
|
||||
| `ankiConnect.nPlusOne.nPlusOne` | hex color string | Text color for the single target token to study when exactly one unknown candidate exists in a sentence (default: `"#c6a0f6"`). |
|
||||
| `ankiConnect.nPlusOne.enabled` | `true`, `false` | Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Independent from `knownWords.highlightEnabled`. Requires known-word cache data (default: `false`). |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | number | Minimum number of words required in a sentence before single unknown-word N+1 highlighting can trigger (default: `3`). |
|
||||
| `behavior.notificationType` | `"osd"`, `"system"`, `"both"`, `"none"` | Notification type on card update (default: `"osd"`) |
|
||||
| `behavior.autoUpdateNewCards` | `true`, `false` | Automatically update cards on creation (default: `true`) |
|
||||
@@ -983,9 +1021,9 @@ Known-word cache policy:
|
||||
|
||||
- Initial sync runs when the integration starts if the cache is missing or stale.
|
||||
- `ankiConnect.knownWords.refreshMinutes` controls the minimum time between refreshes; between refreshes, cached words are reused without querying Anki.
|
||||
- `ankiConnect.nPlusOne.nPlusOne` sets the color for the single target token when exactly one eligible unknown word exists.
|
||||
- `subtitleStyle.nPlusOneColor` sets the color for the single target token when exactly one eligible unknown word exists.
|
||||
- `ankiConnect.nPlusOne.minSentenceWords` sets the minimum token count required in a sentence for N+1 highlighting (default: `3`).
|
||||
- `ankiConnect.knownWords.color` sets the known-word highlight color for tokens already in Anki.
|
||||
- `subtitleStyle.knownWordColor` sets the known-word highlight color for tokens already in Anki.
|
||||
- `ankiConnect.knownWords.decks` accepts an object keyed by deck name. If omitted or empty, it falls back to the legacy `ankiConnect.deck` single-deck scope.
|
||||
- Cache state is persisted to `known-words-cache.json` under the app `userData` directory.
|
||||
- The cache is automatically invalidated when the configured scope changes (for example, when deck changes).
|
||||
@@ -1022,12 +1060,12 @@ To refresh roughly once per day, set:
|
||||
`deleteDuplicateInAuto` controls whether `auto` mode deletes the duplicate after merge (default: `true`). In `manual` mode, the popup asks each time whether to delete the duplicate.
|
||||
When the manual merge popup opens, SubMiner pauses playback and closes any open Yomitan popup first so the merge flow can take focus.
|
||||
|
||||
<video controls playsinline preload="metadata" poster="/assets/kiku-integration-poster.jpg" style="width: 100%; max-width: 960px;">
|
||||
<source :src="'/assets/kiku-integration.webm'" type="video/webm" />
|
||||
<video controls playsinline preload="metadata" :poster="withBase('/assets/kiku-integration-poster.jpg')" style="width: 100%; max-width: 960px;">
|
||||
<source :src="withBase('/assets/kiku-integration.webm')" type="video/webm" />
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
<a :href="'/assets/kiku-integration.webm'" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
||||
<a :href="withBase('/assets/kiku-integration.webm')" target="_blank" rel="noreferrer">Open demo in a new tab</a>
|
||||
|
||||
## External Integrations
|
||||
|
||||
@@ -1229,7 +1267,7 @@ Jellyfin integration is optional and disabled by default. When enabled, SubMiner
|
||||
| `directPlayContainers` | string[] | Container allowlist for direct play decisions |
|
||||
| `transcodeVideoCodec` | string | Preferred transcode video codec fallback (default: `h264`) |
|
||||
|
||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup.
|
||||
Jellyfin auth session (`accessToken` + `userId`) is stored in local encrypted storage after login/setup. The legacy `jellyfin.accessToken` and `jellyfin.userId` config keys are not resolver-backed settings in the current runtime. The Settings window also hides low-level client identity and default library fields (`deviceId`, `clientName`, `clientVersion`, and `defaultLibraryId`) so normal setup stays focused on server, auth, playback, and remote-control behavior.
|
||||
|
||||
- On Linux, token storage defaults to `gnome-libsecret` for `safeStorage`. Override with `--password-store=<backend>` on launcher/app invocations when needed.
|
||||
|
||||
|
||||
+16
-14
@@ -3,6 +3,8 @@
|
||||
Short recordings of SubMiner's key features and integrations from real playback sessions.
|
||||
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress';
|
||||
|
||||
const v = '20260301-1';
|
||||
</script>
|
||||
|
||||
@@ -10,11 +12,11 @@ const v = '20260301-1';
|
||||
|
||||
Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner automatically attaches the sentence, a timing-accurate audio clip, a screenshot, and a translation.
|
||||
|
||||
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
|
||||
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
||||
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
||||
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
||||
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
||||
<video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/minecard.webm?v=${v}`)" type="video/webm" />
|
||||
<source :src="withBase(`/assets/minecard.mp4?v=${v}`)" type="video/mp4" />
|
||||
<a :href="withBase(`/assets/minecard.webm?v=${v}`)" target="_blank" rel="noreferrer">
|
||||
<img :src="withBase(`/assets/minecard.webp?v=${v}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
||||
</a>
|
||||
</video>
|
||||
|
||||
@@ -25,9 +27,9 @@ Mine vocabulary cards from Yomitan or directly from subtitle lines. SubMiner aut
|
||||
|
||||
Search and download subtitles from Jimaku, then automatically synchronize them with alass or ffsubsync — all from within SubMiner.
|
||||
|
||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/subtitle-sync-poster.jpg?v=${v}`">
|
||||
<source :src="`/assets/demos/subtitle-sync.webm?v=${v}`" type="video/webm" />
|
||||
<source :src="`/assets/demos/subtitle-sync.mp4?v=${v}`" type="video/mp4" />
|
||||
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/subtitle-sync-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/demos/subtitle-sync.webm?v=${v}`)" type="video/webm" />
|
||||
<source :src="withBase(`/assets/demos/subtitle-sync.mp4?v=${v}`)" type="video/mp4" />
|
||||
</video> -->
|
||||
|
||||
::: info VIDEO COMING SOON
|
||||
@@ -37,9 +39,9 @@ Search and download subtitles from Jimaku, then automatically synchronize them w
|
||||
|
||||
Browse your Jellyfin library, cast to devices, and launch playback directly from SubMiner. Watch progress syncs back to your Jellyfin server.
|
||||
|
||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/jellyfin-poster.jpg?v=${v}`">
|
||||
<source :src="`/assets/demos/jellyfin.webm?v=${v}`" type="video/webm" />
|
||||
<source :src="`/assets/demos/jellyfin.mp4?v=${v}`" type="video/mp4" />
|
||||
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/jellyfin-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/demos/jellyfin.webm?v=${v}`)" type="video/webm" />
|
||||
<source :src="withBase(`/assets/demos/jellyfin.mp4?v=${v}`)" type="video/mp4" />
|
||||
</video> -->
|
||||
|
||||
::: info VIDEO COMING SOON
|
||||
@@ -49,9 +51,9 @@ Browse your Jellyfin library, cast to devices, and launch playback directly from
|
||||
|
||||
Open subtitles in an external texthooker page for use with browser-based tools and extensions alongside the overlay.
|
||||
|
||||
<!-- <video controls playsinline preload="metadata" :poster="`/assets/demos/texthooker-poster.jpg?v=${v}`">
|
||||
<source :src="`/assets/demos/texthooker.webm?v=${v}`" type="video/webm" />
|
||||
<source :src="`/assets/demos/texthooker.mp4?v=${v}`" type="video/mp4" />
|
||||
<!-- <video controls playsinline preload="metadata" :poster="withBase(`/assets/demos/texthooker-poster.jpg?v=${v}`)">
|
||||
<source :src="withBase(`/assets/demos/texthooker.webm?v=${v}`)" type="video/webm" />
|
||||
<source :src="withBase(`/assets/demos/texthooker.mp4?v=${v}`)" type="video/mp4" />
|
||||
</video> -->
|
||||
|
||||
::: info VIDEO COMING SOON
|
||||
|
||||
+20
-11
@@ -113,6 +113,14 @@ bun run docs:test
|
||||
bun run docs:build
|
||||
```
|
||||
|
||||
For production docs routing, run the versioned build:
|
||||
|
||||
```bash
|
||||
bun run docs:build:versioned
|
||||
```
|
||||
|
||||
The versioned build writes `.tmp/docs-versioned-site` with latest stable docs at `/`, development docs at `/main/`, and stable archives under `/v/<version>/`. Prerelease tags are skipped. Public assets from `docs-site/public/assets` are shared from root `/assets/` so large demo media is not duplicated into every version archive; generated VitePress CSS and JS assets stay under each version route. Stale `.tmp/docs-versioned-archive-cache` generations are pruned after a successful build, and intermediate `.tmp/docs-versioned-build` workspaces are removed.
|
||||
|
||||
Focused commands:
|
||||
|
||||
```bash
|
||||
@@ -154,6 +162,7 @@ bun run format:check:src
|
||||
- `make pretty` runs the maintained Prettier allowlist only (`format:src`).
|
||||
- `bun run format:check:src` checks the same scoped set without writing changes.
|
||||
- `bun run format` remains the broad repo-wide Prettier command; use it intentionally.
|
||||
|
||||
## Config Generation
|
||||
|
||||
```bash
|
||||
@@ -197,17 +206,17 @@ Use Cloudflare's single `*` wildcard syntax for watch paths. `docs-site/*` cover
|
||||
|
||||
Run `make help` for a full list of targets. Key ones:
|
||||
|
||||
| Target | Description |
|
||||
| ---------------------- | ---------------------------------------------------------------- |
|
||||
| `make build` | Build platform package for detected OS |
|
||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
||||
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||
| `make generate-config` | Generate default config from centralized registry |
|
||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||
| `make build-macos` | Convenience wrapper for signed macOS packaging |
|
||||
| `make build-macos-unsigned` | Convenience wrapper for unsigned macOS packaging |
|
||||
| Target | Description |
|
||||
| --------------------------- | ----------------------------------------------------------------- |
|
||||
| `make build` | Build platform package for detected OS |
|
||||
| `make build-launcher` | Generate Bun launcher wrapper at `dist/launcher/subminer` |
|
||||
| `make install` | Install platform artifacts (wrapper, theme, AppImage/app bundle) |
|
||||
| `make deps` | Install JS dependencies (root + stats + texthooker-ui) |
|
||||
| `make pretty` | Run scoped Prettier formatting for maintained source/config files |
|
||||
| `make generate-config` | Generate default config from centralized registry |
|
||||
| `make build-linux` | Convenience wrapper for Linux packaging |
|
||||
| `make build-macos` | Convenience wrapper for signed macOS packaging |
|
||||
| `make build-macos-unsigned` | Convenience wrapper for unsigned macOS packaging |
|
||||
|
||||
## Contributor Notes
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ const installationContents = readFileSync(new URL('./installation.md', import.me
|
||||
const mpvPluginContents = readFileSync(new URL('./mpv-plugin.md', import.meta.url), 'utf8');
|
||||
const developmentContents = readFileSync(new URL('./development.md', import.meta.url), 'utf8');
|
||||
const changelogContents = readFileSync(new URL('./changelog.md', import.meta.url), 'utf8');
|
||||
const docsPackageContents = readFileSync(new URL('./package.json', import.meta.url), 'utf8');
|
||||
const ankiIntegrationContents = readFileSync(
|
||||
new URL('./anki-integration.md', import.meta.url),
|
||||
'utf8',
|
||||
@@ -37,13 +38,13 @@ test('docs reflect current launcher and release surfaces', () => {
|
||||
|
||||
expect(mpvPluginContents).toContain('\\\\.\\pipe\\subminer-socket');
|
||||
|
||||
expect(readmeContents).toContain('Root directory: `docs-site`');
|
||||
expect(readmeContents).toContain('Build output directory: `.vitepress/dist`');
|
||||
expect(readmeContents).toContain('Build watch paths: `docs-site/*`');
|
||||
expect(readmeContents).toContain('Automatic production and preview deployments: disabled');
|
||||
expect(readmeContents).toContain('/main/');
|
||||
expect(readmeContents).toContain('GitHub Actions direct upload with Wrangler');
|
||||
expect(developmentContents).not.toContain('../subminer-docs');
|
||||
expect(developmentContents).toContain('bun run docs:build');
|
||||
expect(developmentContents).toContain('bun run docs:test');
|
||||
expect(developmentContents).toContain('Build watch paths: `docs-site/*`');
|
||||
expect(developmentContents).toContain('bun run docs:build:versioned');
|
||||
expect(developmentContents).not.toContain('test:subtitle:dist');
|
||||
expect(developmentContents).toContain('bun run build:win');
|
||||
|
||||
@@ -57,6 +58,15 @@ test('docs reflect current launcher and release surfaces', () => {
|
||||
expect(changelogContents).toContain('v0.5.1 (2026-03-09)');
|
||||
});
|
||||
|
||||
test('docs dev server links version navigation to local dev routes', () => {
|
||||
expect(docsPackageContents).toContain('scripts/build-versioned-docs.ts');
|
||||
expect(docsPackageContents).toContain(
|
||||
'SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts',
|
||||
);
|
||||
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_LINK_ORIGIN=local');
|
||||
expect(docsPackageContents).toContain('SUBMINER_DOCS_VERSION_MANIFEST');
|
||||
});
|
||||
|
||||
test('docs changelog keeps the current minor release headings aligned with the root changelog', () => {
|
||||
const docsHeadings = extractCurrentMinorHeadings(changelogContents);
|
||||
expect(docsHeadings.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -7,18 +7,18 @@ const docsIndexContents = readFileSync(docsIndexPath, 'utf8');
|
||||
test('docs demo media uses shared cache-busting asset version token', () => {
|
||||
expect(docsIndexContents).toMatch(/const demoAssetVersion = ['"][^'"]+['"]/);
|
||||
expect(docsIndexContents).toContain(
|
||||
':poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`"',
|
||||
':poster="withBase(`/assets/minecard-poster.jpg?v=${demoAssetVersion}`)"',
|
||||
);
|
||||
expect(docsIndexContents).toContain(
|
||||
'<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />',
|
||||
'<source :src="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" type="video/webm" />',
|
||||
);
|
||||
expect(docsIndexContents).toContain(
|
||||
'<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />',
|
||||
'<source :src="withBase(`/assets/minecard.mp4?v=${demoAssetVersion}`)" type="video/mp4" />',
|
||||
);
|
||||
expect(docsIndexContents).toContain(
|
||||
'<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">',
|
||||
'<a :href="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" target="_blank" rel="noreferrer">',
|
||||
);
|
||||
expect(docsIndexContents).toContain(
|
||||
'<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />',
|
||||
'<img :src="withBase(`/assets/minecard.webp?v=${demoAssetVersion}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />',
|
||||
);
|
||||
});
|
||||
|
||||
+7
-5
@@ -86,6 +86,8 @@ features:
|
||||
---
|
||||
|
||||
<script setup>
|
||||
import { withBase } from 'vitepress';
|
||||
|
||||
const demoAssetVersion = '20260223-2';
|
||||
</script>
|
||||
|
||||
@@ -135,11 +137,11 @@ const demoAssetVersion = '20260223-2';
|
||||
<span class="demo-window__dot"></span>
|
||||
<span class="demo-window__title">subminer -- playback</span>
|
||||
</div>
|
||||
<video controls playsinline preload="metadata" :poster="`/assets/minecard-poster.jpg?v=${demoAssetVersion}`">
|
||||
<source :src="`/assets/minecard.webm?v=${demoAssetVersion}`" type="video/webm" />
|
||||
<source :src="`/assets/minecard.mp4?v=${demoAssetVersion}`" type="video/mp4" />
|
||||
<a :href="`/assets/minecard.webm?v=${demoAssetVersion}`" target="_blank" rel="noreferrer">
|
||||
<img :src="`/assets/minecard.webp?v=${demoAssetVersion}`" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
||||
<video controls playsinline preload="metadata" :poster="withBase(`/assets/minecard-poster.jpg?v=${demoAssetVersion}`)">
|
||||
<source :src="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" type="video/webm" />
|
||||
<source :src="withBase(`/assets/minecard.mp4?v=${demoAssetVersion}`)" type="video/mp4" />
|
||||
<a :href="withBase(`/assets/minecard.webm?v=${demoAssetVersion}`)" target="_blank" rel="noreferrer">
|
||||
<img :src="withBase(`/assets/minecard.webp?v=${demoAssetVersion}`)" alt="SubMiner demo Animated fallback" style="width: 100%; height: auto;" />
|
||||
</a>
|
||||
</video>
|
||||
</div>
|
||||
|
||||
@@ -174,9 +174,9 @@ subminer -u
|
||||
subminer --update
|
||||
```
|
||||
|
||||
SubMiner verifies launcher and Linux rofi theme downloads against `SHA256SUMS.txt`. If the launcher is installed in a protected path such as `/usr/local/bin/subminer`, SubMiner does not elevate itself; it shows the exact `sudo curl ... && sudo chmod +x ...` command to run instead.
|
||||
SubMiner verifies AppImage, launcher, and Linux rofi theme downloads against `SHA256SUMS.txt`. If the AppImage or launcher is installed in a protected path, SubMiner does not elevate itself; it shows the exact sudo command to run instead.
|
||||
|
||||
On Linux, `subminer -u` performs this update from the launcher process, so it does not need to start or IPC into the tray app.
|
||||
On Linux, `subminer -u` performs the AppImage update from the launcher process, so it does not need to start or IPC into the tray app.
|
||||
|
||||
### From Source
|
||||
|
||||
@@ -206,6 +206,8 @@ Download the **DMG** artifact from [GitHub Releases](https://github.com/ksyasuda
|
||||
|
||||
A **ZIP** artifact is also available as a fallback — unzip and drag `SubMiner.app` into `/Applications`.
|
||||
|
||||
After the first updater-enabled install, tray update checks can update the macOS app automatically through Electron's standard macOS updater. The updater uses the release ZIP as its payload even when the DMG remains the normal first-install artifact.
|
||||
|
||||
Install dependencies using Homebrew:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -99,6 +99,7 @@ Use `subminer <subcommand> -h` for command-specific help.
|
||||
| `-r, --recursive` | Search directories recursively |
|
||||
| `-R, --rofi` | Use rofi instead of fzf |
|
||||
| `--setup` | Open first-run setup popup manually |
|
||||
| `-v, --version` | Print installed SubMiner version |
|
||||
| `-u, --update` | Check for SubMiner updates and update the app/launcher when possible |
|
||||
| `--start` | Explicitly start overlay after mpv launches |
|
||||
| `-S, --start-overlay` | Explicitly start overlay after mpv launches |
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
"description": "In-repo VitePress documentation site for SubMiner",
|
||||
"packageManager": "bun@1.3.5",
|
||||
"scripts": {
|
||||
"docs:dev": "VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||
"docs:dev": "SUBMINER_DOCS_VERSION_LINK_ORIGIN=local bun run ../scripts/build-versioned-docs.ts && SUBMINER_DOCS_VERSION_LINK_ORIGIN=local SUBMINER_DOCS_VERSION_MANIFEST=\"$(bun run ../scripts/print-docs-version-manifest.ts)\" VITE_EXTRA_EXTENSIONS=jsonc vitepress dev --host 0.0.0.0 --port 5173 --strictPort",
|
||||
"docs:build": "VITE_EXTRA_EXTENSIONS=jsonc vitepress build",
|
||||
"docs:preview": "VITE_EXTRA_EXTENSIONS=jsonc vitepress preview --host 0.0.0.0 --port 4173 --strictPort",
|
||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts"
|
||||
"test": "bun test plausible.test.ts index.assets.test.ts docs-sync.test.ts seo.test.ts .vitepress/theme/status-line.test.ts ../scripts/docs-versioning.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@catppuccin/vitepress": "^0.1.2",
|
||||
|
||||
@@ -4,9 +4,11 @@ import { readFileSync } from 'node:fs';
|
||||
const docsConfigPath = new URL('./.vitepress/config.ts', import.meta.url);
|
||||
const docsThemePath = new URL('./.vitepress/theme/index.ts', import.meta.url);
|
||||
const docsPackagePath = new URL('./package.json', import.meta.url);
|
||||
const versionedBuildPath = new URL('../scripts/build-versioned-docs.ts', import.meta.url);
|
||||
const docsConfigContents = readFileSync(docsConfigPath, 'utf8');
|
||||
const docsThemeContents = readFileSync(docsThemePath, 'utf8');
|
||||
const docsPackageContents = readFileSync(docsPackagePath, 'utf8');
|
||||
const versionedBuildContents = readFileSync(versionedBuildPath, 'utf8');
|
||||
|
||||
test('docs site loads the docs.subminer.moe Plausible script through the analytics proxy', () => {
|
||||
expect(docsConfigContents).toContain("const DOCS_HOSTNAME = 'https://docs.subminer.moe'");
|
||||
@@ -34,3 +36,33 @@ test('docs site loads the docs.subminer.moe Plausible script through the analyti
|
||||
expect(docsThemeContents).not.toContain('initPlausibleTracker');
|
||||
expect(docsPackageContents).not.toContain('@plausible-analytics/tracker');
|
||||
});
|
||||
|
||||
test('versioned docs reuse current VitePress internals for old page snapshots', () => {
|
||||
expect(versionedBuildContents).toContain("cpSync(join(currentDocsSite, '.vitepress')");
|
||||
expect(versionedBuildContents).toContain('overlayCurrentVitePress(snapshotDocsSite)');
|
||||
});
|
||||
|
||||
test('versioned docs build reports archive cache hits and rebuilds', () => {
|
||||
expect(versionedBuildContents).toContain(
|
||||
'console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`)',
|
||||
);
|
||||
expect(versionedBuildContents).toContain('console.info(`[docs] cache hit ${version}`)');
|
||||
expect(versionedBuildContents).toContain('console.info(`[docs] rebuilding archive ${version}`)');
|
||||
});
|
||||
|
||||
test('versioned docs build deduplicates public assets and prunes stale workspaces', () => {
|
||||
expect(versionedBuildContents).toContain('dedupeVersionedPublicAssets({');
|
||||
expect(versionedBuildContents).toContain('pruneArchiveCacheGenerations({');
|
||||
expect(versionedBuildContents).toContain('rmSync(buildRoot, { recursive: true, force: true });');
|
||||
});
|
||||
|
||||
test('versioned docs archive cache key ignores generated and test-only files', () => {
|
||||
expect(versionedBuildContents).toContain('isSharedInternalsHashIgnoredPath(path)');
|
||||
expect(versionedBuildContents).toContain('|| /\\.test\\.[cm]?[jt]s$/.test(path)');
|
||||
expect(versionedBuildContents).toContain('process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN');
|
||||
expect(versionedBuildContents).not.toContain('hash.update(String(stat.mode))');
|
||||
});
|
||||
|
||||
test('docs builds exclude the internal README from VitePress page entries', () => {
|
||||
expect(docsConfigContents).toContain("srcExclude: ['subagents/**', 'README.md']");
|
||||
});
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
{
|
||||
|
||||
// ==========================================
|
||||
// Overlay Auto-Start
|
||||
// When overlay connects to mpv, automatically show overlay and hide mpv subtitles.
|
||||
// Visible Overlay Auto-Start
|
||||
// Show the visible subtitle overlay automatically after managed mpv playback starts SubMiner.
|
||||
// SubMiner can still auto-start in the background when this is false.
|
||||
// ==========================================
|
||||
"auto_start_overlay": false, // When overlay connects to mpv, automatically show overlay and hide mpv subtitles. Values: true | false
|
||||
"auto_start_overlay": true, // Show the visible subtitle overlay automatically when the bundled mpv plugin starts SubMiner. Values: true | false
|
||||
|
||||
// ==========================================
|
||||
// Texthooker Server
|
||||
@@ -18,7 +19,7 @@
|
||||
// ==========================================
|
||||
"texthooker": {
|
||||
"launchAtStartup": false, // Launch texthooker server automatically when SubMiner starts. Values: true | false
|
||||
"openBrowser": false // Open browser setting. Values: true | false
|
||||
"openBrowser": false // Open the texthooker page in the default browser when the server starts. Values: true | false
|
||||
}, // Configure texthooker startup launch and browser opening behavior.
|
||||
|
||||
// ==========================================
|
||||
@@ -174,24 +175,24 @@
|
||||
// Hot-reload: shortcut changes apply live and update the session help modal on reopen.
|
||||
// ==========================================
|
||||
"shortcuts": {
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Toggle visible overlay global setting.
|
||||
"copySubtitle": "CommandOrControl+C", // Copy subtitle setting.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Copy subtitle multiple setting.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Update last card from clipboard setting.
|
||||
"triggerFieldGrouping": "CommandOrControl+G", // Trigger field grouping setting.
|
||||
"triggerSubsync": "Ctrl+Alt+S", // Trigger subsync setting.
|
||||
"mineSentence": "CommandOrControl+S", // Mine sentence setting.
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Mine sentence multiple setting.
|
||||
"toggleVisibleOverlayGlobal": "Alt+Shift+O", // Global accelerator that toggles overlay visibility from anywhere on the system. Use null to disable.
|
||||
"copySubtitle": "CommandOrControl+C", // Accelerator that copies the current subtitle line to the clipboard.
|
||||
"copySubtitleMultiple": "CommandOrControl+Shift+C", // Accelerator that copies consecutive subtitle lines while the multi-copy window stays open.
|
||||
"updateLastCardFromClipboard": "CommandOrControl+V", // Accelerator that updates the last mined Anki card using the current clipboard contents.
|
||||
"triggerFieldGrouping": "CommandOrControl+G", // Accelerator that triggers Kiku field grouping on duplicate cards.
|
||||
"triggerSubsync": "Ctrl+Alt+S", // Accelerator that triggers subsync against the active subtitle file.
|
||||
"mineSentence": "CommandOrControl+S", // Accelerator that mines the current sentence as a new Anki card.
|
||||
"mineSentenceMultiple": "CommandOrControl+Shift+S", // Accelerator that mines consecutive sentences while the multi-mine window stays open.
|
||||
"multiCopyTimeoutMs": 3000, // Timeout for multi-copy/mine modes.
|
||||
"toggleSecondarySub": "CommandOrControl+Shift+V", // Toggle secondary sub setting.
|
||||
"markAudioCard": "CommandOrControl+Shift+A", // Mark audio card setting.
|
||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Open character dictionary setting.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Open runtime options setting.
|
||||
"openJimaku": "Ctrl+Shift+J", // Open jimaku setting.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Open session help setting.
|
||||
"openControllerSelect": "Alt+C", // Open controller select setting.
|
||||
"openControllerDebug": "Alt+Shift+C", // Open controller debug setting.
|
||||
"toggleSubtitleSidebar": "Backslash" // Toggle subtitle sidebar setting.
|
||||
"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.
|
||||
"openCharacterDictionary": "CommandOrControl+Alt+A", // Accelerator that opens the character dictionary modal.
|
||||
"openRuntimeOptions": "CommandOrControl+Shift+O", // Accelerator that opens the runtime options modal.
|
||||
"openJimaku": "Ctrl+Shift+J", // Accelerator that opens the Jimaku subtitle search modal.
|
||||
"openSessionHelp": "CommandOrControl+Slash", // Accelerator that opens the session help / keybinding cheatsheet.
|
||||
"openControllerSelect": "Alt+C", // Accelerator that opens the controller selection and learn-mode modal.
|
||||
"openControllerDebug": "Alt+Shift+C", // Accelerator that opens the controller debug modal with live axis/button readouts.
|
||||
"toggleSubtitleSidebar": "Backslash" // Accelerator that toggles the subtitle sidebar visibility.
|
||||
}, // Overlay keyboard shortcuts. Set a shortcut to null to disable.
|
||||
|
||||
// ==========================================
|
||||
@@ -328,9 +329,9 @@
|
||||
// Hot-reload: defaultMode updates live while SubMiner is running.
|
||||
// ==========================================
|
||||
"secondarySub": {
|
||||
"secondarySubLanguages": [], // Secondary sub languages setting.
|
||||
"autoLoadSecondarySub": false, // Auto load secondary sub setting. Values: true | false
|
||||
"defaultMode": "hover" // Default mode setting.
|
||||
"secondarySubLanguages": [], // Language code priority list used to auto-select a secondary subtitle track when available.
|
||||
"autoLoadSecondarySub": false, // Automatically load a matching secondary subtitle when the primary subtitle loads. Values: true | false
|
||||
"defaultMode": "hover" // Default visibility mode for the secondary subtitle bar. Values: hidden | visible | hover
|
||||
}, // Dual subtitle track options.
|
||||
|
||||
// ==========================================
|
||||
@@ -339,9 +340,9 @@
|
||||
// ==========================================
|
||||
"subsync": {
|
||||
"defaultMode": "auto", // Subsync default mode. Values: auto | manual
|
||||
"alass_path": "", // Alass path setting.
|
||||
"ffsubsync_path": "", // Ffsubsync path setting.
|
||||
"ffmpeg_path": "", // Ffmpeg path setting.
|
||||
"alass_path": "", // Optional absolute path to the alass binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"ffsubsync_path": "", // Optional absolute path to the ffsubsync binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"ffmpeg_path": "", // Optional absolute path to the ffmpeg binary used by subsync. Leave empty to auto-discover from PATH.
|
||||
"replace": true // Replace the active subtitle file when sync completes. Values: true | false
|
||||
}, // Subsync engine and executable paths.
|
||||
|
||||
@@ -350,7 +351,7 @@
|
||||
// Initial vertical subtitle position from the bottom.
|
||||
// ==========================================
|
||||
"subtitlePosition": {
|
||||
"yPercent": 10 // Y percent setting.
|
||||
"yPercent": 10 // Vertical position of the subtitle overlay expressed as a percentage from the bottom of the screen.
|
||||
}, // Initial vertical subtitle position from the bottom.
|
||||
|
||||
// ==========================================
|
||||
@@ -360,29 +361,31 @@
|
||||
// ==========================================
|
||||
"subtitleStyle": {
|
||||
"primaryDefaultMode": "visible", // Default primary subtitle bar visibility mode. hidden hides it, visible shows it, hover reveals it on hover. Values: hidden | visible | hover
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||
"color": "#cad3f5", // Color setting.
|
||||
"background-color": "transparent", // Background color setting.
|
||||
"font-size": "35px", // Font size setting.
|
||||
"font-weight": "600", // Font weight setting.
|
||||
"font-style": "normal", // Font style setting.
|
||||
"line-height": "1.35", // Line height setting.
|
||||
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||
"word-spacing": "0", // Word spacing setting.
|
||||
"font-kerning": "normal", // Font kerning setting.
|
||||
"text-rendering": "geometricPrecision", // Text rendering setting.
|
||||
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||
"backdrop-filter": "blur(6px)", // Backdrop filter setting.
|
||||
"--subtitle-hover-token-color": "#f4dbd6", // Subtitle hover token color setting.
|
||||
"--subtitle-hover-token-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle hover token background color setting.
|
||||
}, // CSS declaration object applied to primary subtitles after normal subtitle style defaults.
|
||||
"enableJlpt": false, // Enable JLPT vocabulary level underlines. When disabled, JLPT tagging lookup and underlines are skipped. Values: true | false
|
||||
"preserveLineBreaks": false, // Preserve line breaks in visible overlay subtitle rendering. When false, line breaks are flattened to spaces for a single-line flow. Values: true | false
|
||||
"autoPauseVideoOnHover": true, // Automatically pause mpv playback while hovering subtitle text, then resume on leave. Values: true | false
|
||||
"autoPauseVideoOnYomitanPopup": true, // Automatically pause mpv playback while Yomitan popup is open, then resume when popup closes. Values: true | false
|
||||
"hoverTokenColor": "#f4dbd6", // Hex color used for hovered subtitle token highlight in mpv.
|
||||
"hoverTokenBackgroundColor": "rgba(54, 58, 79, 0.84)", // CSS color used for hovered subtitle token background highlight in mpv.
|
||||
"nameMatchEnabled": true, // Enable subtitle token coloring for matches from the SubMiner character dictionary. Values: true | false
|
||||
"nameMatchColor": "#f5bde6", // Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.
|
||||
"fontFamily": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||
"fontSize": 35, // Font size setting.
|
||||
"fontColor": "#cad3f5", // Font color setting.
|
||||
"fontWeight": "600", // Font weight setting.
|
||||
"lineHeight": 1.35, // Line height setting.
|
||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||
"wordSpacing": 0, // Word spacing setting.
|
||||
"fontKerning": "normal", // Font kerning setting.
|
||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||
"fontStyle": "normal", // Font style setting.
|
||||
"backgroundColor": "transparent", // Background color setting.
|
||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||
"nPlusOneColor": "#c6a0f6", // N plus one color setting.
|
||||
"knownWordColor": "#a6da95", // Known word color setting.
|
||||
"nPlusOneColor": "#c6a0f6", // Color used for the single N+1 target token subtitle highlight.
|
||||
"knownWordColor": "#a6da95", // Color used for known-word subtitle highlights.
|
||||
"jlptColors": {
|
||||
"N1": "#ed8796", // N1 setting.
|
||||
"N2": "#f5a97f", // N2 setting.
|
||||
@@ -406,19 +409,21 @@
|
||||
] // Five colors used for rank bands when mode is `banded` (from most common to least within topX).
|
||||
}, // Frequency dictionary setting.
|
||||
"secondary": {
|
||||
"fontFamily": "Inter, Noto Sans, Helvetica Neue, sans-serif", // Font family setting.
|
||||
"fontSize": 24, // Font size setting.
|
||||
"fontColor": "#cad3f5", // Font color setting.
|
||||
"lineHeight": 1.35, // Line height setting.
|
||||
"letterSpacing": "-0.01em", // Letter spacing setting.
|
||||
"wordSpacing": 0, // Word spacing setting.
|
||||
"fontKerning": "normal", // Font kerning setting.
|
||||
"textRendering": "geometricPrecision", // Text rendering setting.
|
||||
"textShadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||
"backgroundColor": "transparent", // Background color setting.
|
||||
"backdropFilter": "blur(6px)", // Backdrop filter setting.
|
||||
"fontWeight": "600", // Font weight setting.
|
||||
"fontStyle": "normal" // Font style setting.
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||
"color": "#cad3f5", // Color setting.
|
||||
"background-color": "transparent", // Background color setting.
|
||||
"font-size": "24px", // Font size setting.
|
||||
"font-weight": "600", // Font weight setting.
|
||||
"font-style": "normal", // Font style setting.
|
||||
"line-height": "1.35", // Line height setting.
|
||||
"letter-spacing": "-0.01em", // Letter spacing setting.
|
||||
"word-spacing": "0", // Word spacing setting.
|
||||
"font-kerning": "normal", // Font kerning setting.
|
||||
"text-rendering": "geometricPrecision", // Text rendering setting.
|
||||
"text-shadow": "0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)", // Text shadow setting.
|
||||
"backdrop-filter": "blur(6px)" // Backdrop filter setting.
|
||||
} // CSS declaration object applied to secondary subtitles after normal subtitle style defaults.
|
||||
} // Secondary setting.
|
||||
}, // Primary and secondary subtitle styling.
|
||||
|
||||
@@ -434,16 +439,18 @@
|
||||
"toggleKey": "Backslash", // KeyboardEvent.code used to toggle the subtitle sidebar open and closed.
|
||||
"pauseVideoOnHover": false, // Pause mpv while hovering the subtitle sidebar, then resume on leave. Values: true | false
|
||||
"autoScroll": true, // Auto-scroll the active subtitle cue into view while playback advances. Values: true | false
|
||||
"maxWidth": 420, // Maximum sidebar width in CSS pixels.
|
||||
"opacity": 0.95, // Base opacity applied to the sidebar shell.
|
||||
"backgroundColor": "rgba(73, 77, 100, 0.9)", // Background color for the subtitle sidebar shell.
|
||||
"textColor": "#cad3f5", // Default cue text color in the subtitle sidebar.
|
||||
"fontFamily": "\"M PLUS 1\", \"Noto Sans CJK JP\", sans-serif", // Font family used for subtitle sidebar cue text.
|
||||
"fontSize": 16, // Base font size for subtitle sidebar cue text in CSS pixels.
|
||||
"timestampColor": "#a5adcb", // Timestamp color in the subtitle sidebar.
|
||||
"activeLineColor": "#f5bde6", // Text color for the active subtitle cue.
|
||||
"activeLineBackgroundColor": "rgba(138, 173, 244, 0.22)", // Background color for the active subtitle cue.
|
||||
"hoverLineBackgroundColor": "rgba(54, 58, 79, 0.84)" // Background color for hovered subtitle cues.
|
||||
"css": {
|
||||
"font-family": "Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP", // Font family setting.
|
||||
"color": "#cad3f5", // Color setting.
|
||||
"background-color": "rgba(73, 77, 100, 0.9)", // Background color setting.
|
||||
"font-size": "16px", // Font size setting.
|
||||
"opacity": "0.95", // Opacity setting.
|
||||
"--subtitle-sidebar-max-width": "420px", // Subtitle sidebar max width setting.
|
||||
"--subtitle-sidebar-timestamp-color": "#a5adcb", // Subtitle sidebar timestamp color setting.
|
||||
"--subtitle-sidebar-active-line-color": "#f5bde6", // Subtitle sidebar active line color setting.
|
||||
"--subtitle-sidebar-active-background-color": "rgba(138, 173, 244, 0.22)", // Subtitle sidebar active background color setting.
|
||||
"--subtitle-sidebar-hover-background-color": "rgba(54, 58, 79, 0.84)" // Subtitle sidebar hover background color setting.
|
||||
} // CSS declaration object applied to the subtitle sidebar. Includes color, background-color, and all font properties.
|
||||
}, // Parsed-subtitle sidebar cue list styling, behavior, and toggle key.
|
||||
|
||||
// ==========================================
|
||||
@@ -454,9 +461,9 @@
|
||||
"enabled": false, // Enable shared OpenAI-compatible AI provider features. Values: true | false
|
||||
"apiKey": "", // Static API key for the shared OpenAI-compatible AI provider.
|
||||
"apiKeyCommand": "", // Shell command used to resolve the shared AI provider API key.
|
||||
"model": "openai/gpt-4o-mini", // Model setting.
|
||||
"model": "openai/gpt-4o-mini", // Default model identifier requested from the shared AI provider.
|
||||
"baseUrl": "https://openrouter.ai/api", // Base URL for the shared OpenAI-compatible AI provider.
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // System prompt setting.
|
||||
"systemPrompt": "You are a translation engine. Return only the translated text with no explanations.", // Default system prompt sent with shared AI provider requests.
|
||||
"requestTimeoutMs": 15000 // Timeout in milliseconds for shared AI provider requests.
|
||||
}, // Canonical OpenAI-compatible provider transport settings shared by Anki and YouTube subtitle fixing.
|
||||
|
||||
@@ -469,7 +476,7 @@
|
||||
// ==========================================
|
||||
"ankiConnect": {
|
||||
"enabled": true, // Enable AnkiConnect integration. Values: true | false
|
||||
"url": "http://127.0.0.1:8765", // Url setting.
|
||||
"url": "http://127.0.0.1:8765", // Base URL of the AnkiConnect HTTP server.
|
||||
"pollingRate": 3000, // Polling interval in milliseconds.
|
||||
"proxy": {
|
||||
"enabled": true, // Enable local AnkiConnect-compatible proxy for push-based auto-enrichment. Values: true | false
|
||||
@@ -482,11 +489,11 @@
|
||||
], // Tags to add to cards mined or updated by SubMiner. Provide an empty array to disable automatic tagging.
|
||||
"fields": {
|
||||
"word": "Expression", // Card field for the mined word or expression text.
|
||||
"audio": "ExpressionAudio", // Audio setting.
|
||||
"image": "Picture", // Image setting.
|
||||
"sentence": "Sentence", // Sentence setting.
|
||||
"miscInfo": "MiscInfo", // Misc info setting.
|
||||
"translation": "SelectionText" // Translation setting.
|
||||
"audio": "ExpressionAudio", // Card field that receives generated sentence audio.
|
||||
"image": "Picture", // Card field that receives the captured screenshot or animated image.
|
||||
"sentence": "Sentence", // Card field that receives the source sentence text.
|
||||
"miscInfo": "MiscInfo", // Card field that receives the miscellaneous info pattern (see ankiConnect.metadata.pattern).
|
||||
"translation": "SelectionText" // Card field that receives the current selection or translated text.
|
||||
}, // Fields setting.
|
||||
"ai": {
|
||||
"enabled": false, // Enable AI provider usage for Anki translation/enrichment flows. Values: true | false
|
||||
@@ -494,50 +501,49 @@
|
||||
"systemPrompt": "" // Optional system prompt override for Anki AI translation/enrichment flows.
|
||||
}, // Ai setting.
|
||||
"media": {
|
||||
"generateAudio": true, // Generate audio setting. Values: true | false
|
||||
"generateImage": true, // Generate image setting. Values: true | false
|
||||
"imageType": "static", // Image type setting.
|
||||
"imageFormat": "jpg", // Image format setting.
|
||||
"imageQuality": 92, // Image quality setting.
|
||||
"animatedFps": 10, // Animated fps setting.
|
||||
"animatedMaxWidth": 640, // Animated max width setting.
|
||||
"animatedCrf": 35, // Animated crf setting.
|
||||
"generateAudio": true, // Generate sentence audio for mined cards. Values: true | false
|
||||
"generateImage": true, // Generate screenshot or animated image for mined cards. Values: true | false
|
||||
"imageType": "static", // Image capture type: "static" for a single still frame, "avif" for an animated AVIF. Values: static | avif
|
||||
"imageFormat": "jpg", // Encoding format used when imageType is "static". Values: jpg | png | webp
|
||||
"imageQuality": 92, // Quality (0-100) used for lossy static image encoders.
|
||||
"animatedFps": 10, // Target frame rate for animated AVIF captures.
|
||||
"animatedMaxWidth": 640, // Maximum width applied to animated AVIF captures.
|
||||
"animatedCrf": 35, // Animated AVIF CRF quality target. Lower values produce larger, higher-quality files.
|
||||
"syncAnimatedImageToWordAudio": true, // For animated AVIF images, prepend a frozen first frame matching the existing word-audio duration so motion starts with sentence audio. Values: true | false
|
||||
"audioPadding": 0.5, // Audio padding setting.
|
||||
"fallbackDuration": 3, // Fallback duration setting.
|
||||
"maxMediaDuration": 30 // Max media duration setting.
|
||||
"audioPadding": 0.5, // Seconds of padding appended to both ends of generated sentence audio.
|
||||
"fallbackDuration": 3, // Fallback clip duration in seconds when subtitle timing data is unavailable.
|
||||
"maxMediaDuration": 30 // Maximum allowed media clip duration in seconds.
|
||||
}, // Media setting.
|
||||
"knownWords": {
|
||||
"highlightEnabled": false, // Enable fast local highlighting for words already known in Anki. Values: true | false
|
||||
"refreshMinutes": 1440, // Minutes between known-word cache refreshes.
|
||||
"addMinedWordsImmediately": true, // Immediately append newly mined card words into the known-word cache. Values: true | false
|
||||
"matchMode": "headword", // Known-word matching strategy for subtitle annotations. Values: headword | surface
|
||||
"decks": {}, // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||
"color": "#a6da95" // Color used for known-word highlights.
|
||||
"decks": {} // Decks and fields for known-word cache. Object mapping deck names to arrays of field names to extract, e.g. { "Kaishi 1.5k": ["Word", "Word Reading"] }.
|
||||
}, // Known words setting.
|
||||
"behavior": {
|
||||
"overwriteAudio": true, // Overwrite audio setting. Values: true | false
|
||||
"overwriteImage": true, // Overwrite image setting. Values: true | false
|
||||
"mediaInsertMode": "append", // Media insert mode setting.
|
||||
"highlightWord": true, // Highlight word setting. Values: true | false
|
||||
"notificationType": "osd", // Notification type setting.
|
||||
"overwriteAudio": true, // When updating an existing card, overwrite the audio field instead of skipping it. Values: true | false
|
||||
"overwriteImage": true, // When updating an existing card, overwrite the image field instead of skipping it. Values: true | false
|
||||
"mediaInsertMode": "append", // Whether new media is appended after or prepended before existing field contents on update. Values: append | prepend
|
||||
"highlightWord": true, // Bold the mined word inside the sentence field on the saved Anki card. Values: true | false
|
||||
"notificationType": "osd", // Notification surface used to announce mining and update outcomes. Values: osd | system | both | none
|
||||
"autoUpdateNewCards": true // Automatically update newly added cards. Values: true | false
|
||||
}, // Behavior setting.
|
||||
"nPlusOne": {
|
||||
"minSentenceWords": 3, // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||
"nPlusOne": "#c6a0f6" // Color used for the single N+1 target token highlight.
|
||||
"enabled": false, // Enable N+1 subtitle highlighting (highlights the one unknown word in a sentence). Requires known-word cache data. Values: true | false
|
||||
"minSentenceWords": 3 // Minimum sentence word count required for N+1 targeting (default: 3).
|
||||
}, // N plus one setting.
|
||||
"metadata": {
|
||||
"pattern": "[SubMiner] %f (%t)" // Pattern setting.
|
||||
"pattern": "[SubMiner] %f (%t)" // Template used to render the miscInfo field. Placeholders include %f (filename) and %t (timestamp).
|
||||
}, // Metadata setting.
|
||||
"isLapis": {
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"sentenceCardModel": "Japanese sentences" // Sentence card model setting.
|
||||
"enabled": false, // Enable Lapis-specific mining behaviors and sentence card model targeting. Values: true | false
|
||||
"sentenceCardModel": "Lapis" // Note type name used by Lapis sentence cards.
|
||||
}, // Is lapis setting.
|
||||
"isKiku": {
|
||||
"enabled": false, // Enabled setting. Values: true | false
|
||||
"enabled": false, // Enable Kiku-specific mining behaviors (duplicate handling, field grouping). Values: true | false
|
||||
"fieldGrouping": "disabled", // Kiku duplicate-card field grouping mode. Values: auto | manual | disabled
|
||||
"deleteDuplicateInAuto": true // Delete duplicate in auto setting. Values: true | false
|
||||
"deleteDuplicateInAuto": true // When Kiku field grouping is "auto", delete the duplicate source card after grouping completes. Values: true | false
|
||||
} // Is kiku setting.
|
||||
}, // Automatic Anki updates and media generation options.
|
||||
|
||||
@@ -546,7 +552,7 @@
|
||||
// Jimaku API configuration and defaults.
|
||||
// ==========================================
|
||||
"jimaku": {
|
||||
"apiBaseUrl": "https://jimaku.cc", // Api base url setting.
|
||||
"apiBaseUrl": "https://jimaku.cc", // Base URL of the Jimaku subtitle search API.
|
||||
"languagePreference": "ja", // Preferred language used in Jimaku search. Values: ja | en | none
|
||||
"maxEntryResults": 10 // Maximum Jimaku search results returned.
|
||||
}, // Jimaku API configuration and defaults.
|
||||
@@ -598,14 +604,23 @@
|
||||
|
||||
// ==========================================
|
||||
// MPV Launcher
|
||||
// Optional mpv.exe override for Windows playback entry points.
|
||||
// SubMiner-managed mpv launch and bundled plugin options.
|
||||
// Set mpv.socketPath to the IPC socket used by the launcher, Electron app, and bundled plugin.
|
||||
// autoStartSubMiner starts SubMiner in the background; auto_start_overlay only controls visible overlay display.
|
||||
// Set mpv.launchMode to choose normal, maximized, or fullscreen SubMiner-managed mpv playback.
|
||||
// Leave mpv.executablePath blank to auto-discover mpv.exe from SUBMINER_MPV_PATH or PATH.
|
||||
// ==========================================
|
||||
"mpv": {
|
||||
"executablePath": "", // Optional absolute path to mpv.exe for Windows launch flows. Leave empty to auto-discover from SUBMINER_MPV_PATH or PATH.
|
||||
"launchMode": "normal" // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||
}, // Optional mpv.exe override for Windows playback entry points.
|
||||
"launchMode": "normal", // Default window state for SubMiner-managed mpv launches. Values: normal | maximized | fullscreen
|
||||
"socketPath": "/tmp/subminer-socket", // mpv IPC socket path used by SubMiner-managed playback and the bundled mpv plugin.
|
||||
"backend": "auto", // Window tracking backend passed to the bundled mpv plugin. Auto detects the current platform. Values: auto | hyprland | sway | x11 | macos | windows
|
||||
"autoStartSubMiner": true, // Start SubMiner in the background when SubMiner-managed mpv loads a file. Values: true | false
|
||||
"pauseUntilOverlayReady": true, // Pause mpv on visible-overlay auto-start until SubMiner signals subtitle tokenization readiness. Values: true | false
|
||||
"subminerBinaryPath": "", // Optional SubMiner app binary path passed to the bundled mpv plugin. Leave empty to use the launcher-detected app path.
|
||||
"aniskipEnabled": true, // Enable AniSkip intro detection and skip markers in the bundled mpv plugin. Values: true | false
|
||||
"aniskipButtonKey": "TAB" // mpv key used to trigger the AniSkip button while the skip marker is visible.
|
||||
}, // SubMiner-managed mpv launch and bundled plugin options.
|
||||
|
||||
// ==========================================
|
||||
// Jellyfin
|
||||
@@ -618,9 +633,9 @@
|
||||
"serverUrl": "", // Base Jellyfin server URL (for example: http://localhost:8096).
|
||||
"recentServers": [], // Recently authenticated Jellyfin server URLs shown in setup.
|
||||
"username": "", // Default Jellyfin username used during CLI login.
|
||||
"deviceId": "subminer", // Device id setting.
|
||||
"clientName": "SubMiner", // Client name setting.
|
||||
"clientVersion": "0.1.0", // Client version setting.
|
||||
"deviceId": "subminer", // Stable device identifier sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientName": "SubMiner", // Client name sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"clientVersion": "0.1.0", // Client version sent on the Jellyfin authentication handshake; primarily internal.
|
||||
"defaultLibraryId": "", // Optional default Jellyfin library ID for item listing.
|
||||
"remoteControlEnabled": true, // Enable Jellyfin remote cast control mode. Values: true | false
|
||||
"remoteControlAutoConnect": true, // Auto-connect to the configured remote control target. Values: true | false
|
||||
@@ -648,7 +663,7 @@
|
||||
// ==========================================
|
||||
"discordPresence": {
|
||||
"enabled": true, // Enable optional Discord Rich Presence updates. Values: true | false
|
||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal".
|
||||
"presenceStyle": "default", // Presence card text preset: "default" (clean bilingual), "meme" (Mining and crafting), "japanese" (fully JP), or "minimal". Values: default | meme | japanese | minimal
|
||||
"updateIntervalMs": 3000, // Minimum interval between presence payload updates.
|
||||
"debounceMs": 750 // Debounce delay used to collapse bursty presence updates.
|
||||
}, // Optional Discord Rich Presence activity card updates for current playback/study session.
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { TransformContext } from 'vitepress';
|
||||
import docsConfig from './.vitepress/config';
|
||||
|
||||
const docsSiteDir = fileURLToPath(new URL('.', import.meta.url));
|
||||
|
||||
function makeTransformContext(page: string): TransformContext {
|
||||
return {
|
||||
page,
|
||||
@@ -31,6 +37,391 @@ test('docs pages emit stable self-referential canonical URLs', async () => {
|
||||
expect(JSON.stringify(rootHead).toLowerCase()).not.toContain('noindex');
|
||||
});
|
||||
|
||||
test('main docs canonical uses /main/ and emits noindex', async () => {
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'main';
|
||||
process.env.SUBMINER_DOCS_BASE = '/main/';
|
||||
const { default: mainDocsConfig } = await import('./.vitepress/config?main-docs');
|
||||
|
||||
const head = await mainDocsConfig.transformHead?.(makeTransformContext('usage.md'));
|
||||
const rootHead = await mainDocsConfig.transformHead?.(makeTransformContext('index.md'));
|
||||
|
||||
expect(head).toContainEqual([
|
||||
'link',
|
||||
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/usage' },
|
||||
]);
|
||||
expect(rootHead).toContainEqual([
|
||||
'link',
|
||||
{ rel: 'canonical', href: 'https://docs.subminer.moe/main/' },
|
||||
]);
|
||||
expect(head).toContainEqual(['meta', { name: 'robots', content: 'noindex,follow' }]);
|
||||
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
});
|
||||
|
||||
test('latest stable archive canonical points to root equivalent', async () => {
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = '/v/0.14.0/';
|
||||
process.env.SUBMINER_DOCS_VERSION = 'v0.14.0';
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
const { default: latestArchiveConfig } = await import('./.vitepress/config?latest-archive');
|
||||
|
||||
const head = await latestArchiveConfig.transformHead?.(makeTransformContext('usage.md'));
|
||||
|
||||
expect(head).toContainEqual([
|
||||
'link',
|
||||
{ rel: 'canonical', href: 'https://docs.subminer.moe/usage' },
|
||||
]);
|
||||
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
});
|
||||
|
||||
test('stable archive theme links stay on the selected version', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
process.chdir(docsSiteDir);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = '/v/0.12.0/';
|
||||
process.env.SUBMINER_DOCS_VERSION = 'v0.12.0';
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'production';
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||
latestStable: 'v0.14.0',
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [
|
||||
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||
{ version: 'v0.12.0', path: '/v/0.12.0/' },
|
||||
],
|
||||
});
|
||||
try {
|
||||
const { default: archiveConfig } = await import('./.vitepress/config?stable-archive-links');
|
||||
|
||||
const nav = archiveConfig.themeConfig?.nav as Array<{
|
||||
text: string;
|
||||
link?: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const sidebar = archiveConfig.themeConfig?.sidebar as Array<{
|
||||
text: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const configurationNav = nav.find((item) => item.text === 'Configuration');
|
||||
const versionNav = nav.find((item) => item.text === 'v0.12.0');
|
||||
const referenceSidebar = sidebar.find((item) => item.text === 'Reference');
|
||||
const configurationSidebar = referenceSidebar?.items?.find(
|
||||
(item) => item.text === 'Configuration',
|
||||
);
|
||||
|
||||
expect(configurationNav?.link).toBe('/configuration');
|
||||
expect(configurationSidebar?.link).toBe('/configuration');
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'Latest stable (v0.14.0)',
|
||||
link: 'https://docs.subminer.moe/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'main',
|
||||
link: 'https://docs.subminer.moe/main/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.14.0',
|
||||
link: 'https://docs.subminer.moe/v/0.14.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.12.0',
|
||||
link: 'https://docs.subminer.moe/v/0.12.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(archiveConfig.themeConfig?.logo).toEqual({
|
||||
light: '/assets/SubMiner.png',
|
||||
dark: '/assets/SubMiner.png',
|
||||
});
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
}
|
||||
});
|
||||
|
||||
test('local stable archive version links stay on the dev server', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
process.chdir(docsSiteDir);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = 'stable-archive';
|
||||
process.env.SUBMINER_DOCS_BASE = '/v/0.10.0/';
|
||||
process.env.SUBMINER_DOCS_VERSION = 'v0.10.0';
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = 'v0.14.0';
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||
latestStable: 'v0.14.0',
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [
|
||||
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||
{ version: 'v0.10.0', path: '/v/0.10.0/' },
|
||||
],
|
||||
});
|
||||
try {
|
||||
const { default: archiveConfig } = await import('./.vitepress/config?local-archive-links');
|
||||
|
||||
const nav = archiveConfig.themeConfig?.nav as Array<{
|
||||
text: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const versionNav = nav.find((item) => item.text === 'v0.10.0');
|
||||
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'Latest stable (v0.14.0)',
|
||||
link: '../../',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'main',
|
||||
link: '../../main/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.14.0',
|
||||
link: '../0.14.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.10.0',
|
||||
link: './',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
}
|
||||
});
|
||||
|
||||
test('dev docs version links use local targets for version route testing', async () => {
|
||||
const previousCwd = process.cwd();
|
||||
const previousChannel = process.env.SUBMINER_DOCS_CHANNEL;
|
||||
const previousBase = process.env.SUBMINER_DOCS_BASE;
|
||||
const previousVersion = process.env.SUBMINER_DOCS_VERSION;
|
||||
const previousLatest = process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
const previousManifest = process.env.SUBMINER_DOCS_VERSION_MANIFEST;
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
process.chdir(docsSiteDir);
|
||||
delete process.env.SUBMINER_DOCS_CHANNEL;
|
||||
delete process.env.SUBMINER_DOCS_BASE;
|
||||
delete process.env.SUBMINER_DOCS_VERSION;
|
||||
delete process.env.SUBMINER_DOCS_LATEST_STABLE;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = JSON.stringify({
|
||||
latestStable: 'v0.14.0',
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [
|
||||
{ version: 'v0.14.0', path: '/v/0.14.0/' },
|
||||
{ version: 'v0.12.0', path: '/v/0.12.0/' },
|
||||
{ version: 'v0.11.2', path: '/v/0.11.2/' },
|
||||
],
|
||||
});
|
||||
try {
|
||||
const { default: devConfig } = await import('./.vitepress/config?dev-version-links');
|
||||
|
||||
const nav = devConfig.themeConfig?.nav as Array<{
|
||||
text: string;
|
||||
items?: Array<{ text: string; link: string }>;
|
||||
}>;
|
||||
const versionNav = nav.find((item) => item.text === 'v0.14.0');
|
||||
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'Latest stable (v0.14.0)',
|
||||
link: '/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'main',
|
||||
link: '/main/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items).toContainEqual({
|
||||
text: 'v0.12.0',
|
||||
link: '/v/0.12.0/',
|
||||
target: '_self',
|
||||
noIcon: true,
|
||||
});
|
||||
expect(versionNav?.items?.map((item) => item.text)).toEqual([
|
||||
'Latest stable (v0.14.0)',
|
||||
'main',
|
||||
'v0.14.0',
|
||||
'v0.12.0',
|
||||
'v0.11.2',
|
||||
]);
|
||||
} finally {
|
||||
process.chdir(previousCwd);
|
||||
process.env.SUBMINER_DOCS_CHANNEL = previousChannel;
|
||||
process.env.SUBMINER_DOCS_BASE = previousBase;
|
||||
process.env.SUBMINER_DOCS_VERSION = previousVersion;
|
||||
process.env.SUBMINER_DOCS_LATEST_STABLE = previousLatest;
|
||||
process.env.SUBMINER_DOCS_VERSION_MANIFEST = previousManifest;
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
}
|
||||
});
|
||||
|
||||
test('dev server redirects unserved version routes to production docs', () => {
|
||||
let routeHandler:
|
||||
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
|
||||
| undefined;
|
||||
const fakeServer = {
|
||||
middlewares: {
|
||||
use(handler: typeof routeHandler) {
|
||||
routeHandler = handler;
|
||||
},
|
||||
},
|
||||
};
|
||||
const plugins = Array.isArray(docsConfig.vite?.plugins)
|
||||
? docsConfig.vite.plugins
|
||||
: [docsConfig.vite?.plugins].filter(Boolean);
|
||||
const redirectPlugin = plugins.find(
|
||||
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
|
||||
Boolean(plugin) &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
plugin.name === 'subminer-docs-local-version-redirects' &&
|
||||
'configureServer' in plugin,
|
||||
);
|
||||
expect(redirectPlugin).toBeDefined();
|
||||
redirectPlugin?.configureServer(fakeServer as never);
|
||||
|
||||
const response = new DevRedirectResponse();
|
||||
let nextCalled = false;
|
||||
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(response.statusCode).toBe(302);
|
||||
expect(response.headers.location).toBe('https://docs.subminer.moe/v/0.14.0/?from=dev');
|
||||
|
||||
const rootResponse = new DevRedirectResponse();
|
||||
routeHandler?.({ url: '/configuration' }, rootResponse, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
expect(rootResponse.ended).toBe(false);
|
||||
expect(nextCalled).toBe(true);
|
||||
});
|
||||
|
||||
test('dev server serves local archive files for local version links', async () => {
|
||||
const previousVersionLinkOrigin = process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN;
|
||||
const previousArchiveDir = process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR;
|
||||
const archiveDir = mkdtempSync(join(tmpdir(), 'subminer-docs-archive-'));
|
||||
mkdirSync(join(archiveDir, 'v/0.14.0'), { recursive: true });
|
||||
writeFileSync(join(archiveDir, 'v/0.14.0/index.html'), '<h1>local archive</h1>');
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = 'local';
|
||||
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = archiveDir;
|
||||
try {
|
||||
const { default: localDevConfig } = await import('./.vitepress/config?local-dev-redirects');
|
||||
let routeHandler:
|
||||
| ((req: { url?: string }, res: DevRedirectResponse, next: () => void) => void)
|
||||
| undefined;
|
||||
const fakeServer = {
|
||||
middlewares: {
|
||||
use(handler: typeof routeHandler) {
|
||||
routeHandler = handler;
|
||||
},
|
||||
},
|
||||
};
|
||||
const plugins = Array.isArray(localDevConfig.vite?.plugins)
|
||||
? localDevConfig.vite.plugins
|
||||
: [localDevConfig.vite?.plugins].filter(Boolean);
|
||||
const redirectPlugin = plugins.find(
|
||||
(plugin): plugin is { name: string; configureServer: (server: never) => void } =>
|
||||
Boolean(plugin) &&
|
||||
typeof plugin === 'object' &&
|
||||
'name' in plugin &&
|
||||
plugin.name === 'subminer-docs-local-version-redirects' &&
|
||||
'configureServer' in plugin,
|
||||
);
|
||||
redirectPlugin?.configureServer(fakeServer as never);
|
||||
|
||||
const response = new DevRedirectResponse();
|
||||
let nextCalled = false;
|
||||
routeHandler?.({ url: '/v/0.14.0/?from=dev' }, response, () => {
|
||||
nextCalled = true;
|
||||
});
|
||||
|
||||
expect(nextCalled).toBe(false);
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toBe('text/html; charset=utf-8');
|
||||
expect(response.headers.location).toBeUndefined();
|
||||
expect(response.body).toBe('<h1>local archive</h1>');
|
||||
} finally {
|
||||
process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN = previousVersionLinkOrigin;
|
||||
process.env.SUBMINER_DOCS_LOCAL_ARCHIVE_DIR = previousArchiveDir;
|
||||
rmSync(archiveDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
class DevRedirectResponse {
|
||||
statusCode = 200;
|
||||
headers: Record<string, string> = {};
|
||||
ended = false;
|
||||
body = '';
|
||||
|
||||
setHeader(name: string, value: string) {
|
||||
this.headers[name.toLowerCase()] = value;
|
||||
}
|
||||
|
||||
end(chunk?: string | Uint8Array) {
|
||||
if (chunk) {
|
||||
this.body = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk);
|
||||
}
|
||||
this.ended = true;
|
||||
}
|
||||
}
|
||||
|
||||
test('docs sitemap excludes duplicate README page from indexable URLs', async () => {
|
||||
const items = [{ url: '' }, { url: 'README' }, { url: 'usage' }];
|
||||
|
||||
|
||||
@@ -15,8 +15,8 @@ N+1 highlighting identifies sentences where you know every word except one, maki
|
||||
1. SubMiner queries your Anki decks for existing `Expression` / `Word` field values.
|
||||
2. The results are cached locally (`known-words-cache.json`) and refreshed on a configurable interval.
|
||||
3. When a subtitle line appears, each token is checked against the cache.
|
||||
4. If exactly one unknown word remains in the sentence, it is highlighted with `nPlusOneColor` (default: `#c6a0f6`).
|
||||
5. Already-known tokens can optionally display in `knownWordColor` (default: `#a6da95`).
|
||||
4. If exactly one unknown word remains in the sentence, it is highlighted with `subtitleStyle.nPlusOneColor` (default: `#c6a0f6`).
|
||||
5. Already-known tokens can optionally display in `subtitleStyle.knownWordColor` (default: `#a6da95`).
|
||||
|
||||
**Key settings:**
|
||||
|
||||
@@ -27,8 +27,8 @@ N+1 highlighting identifies sentences where you know every word except one, maki
|
||||
| `ankiConnect.knownWords.decks` | `{}` | Deck→fields map for known-word cache queries (legacy fallback: `ankiConnect.deck`) |
|
||||
| `ankiConnect.knownWords.matchMode` | `"headword"` | `"headword"` (dictionary form) or `"surface"` (raw text) |
|
||||
| `ankiConnect.nPlusOne.minSentenceWords` | `3` | Minimum tokens in a sentence for N+1 to trigger |
|
||||
| `ankiConnect.nPlusOne.nPlusOne` | `#c6a0f6` | Color for the single unknown target word |
|
||||
| `ankiConnect.knownWords.color` | `#a6da95` | Color for already-known tokens |
|
||||
| `subtitleStyle.nPlusOneColor` | `#c6a0f6` | Color for the single unknown target word |
|
||||
| `subtitleStyle.knownWordColor` | `#a6da95` | Color for already-known tokens |
|
||||
|
||||
::: tip
|
||||
Set `refreshMinutes` to `1440` (24 hours) for daily sync if your Anki collection is large.
|
||||
|
||||
@@ -78,6 +78,8 @@ subminer -S video.mkv # Same as above via --start-overlay
|
||||
subminer https://youtu.be/... # Play a YouTube URL
|
||||
subminer ytsearch:"jp news" # Play first YouTube search result
|
||||
subminer --setup # Open first-run setup popup
|
||||
subminer --version # Print installed SubMiner version
|
||||
subminer -v # Same as above
|
||||
subminer --log-level debug video.mkv # Enable verbose logs for launch/debugging
|
||||
subminer --log-level warn video.mkv # Set logging level explicitly
|
||||
subminer --args '--fs=opengl-hq --ytdl-format=bestvideo*+bestaudio/best' video.mkv # Pass extra mpv args
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
8. If `docs-site/` changed, also run:
|
||||
`bun run docs:test`
|
||||
`bun run docs:build`
|
||||
`bun run docs:build:versioned`
|
||||
9. Commit release prep.
|
||||
10. Tag the commit: `git tag v<version>`.
|
||||
11. Push commit + tag.
|
||||
@@ -66,6 +67,7 @@
|
||||
7. Push commit + tag.
|
||||
|
||||
Prerelease tags publish a GitHub prerelease only. They do not update `CHANGELOG.md`, `docs-site/changelog.md`, or the AUR package, and they do not consume `changes/*.md` fragments. The final stable release is still the point where `bun run changelog:build` consumes fragments into `CHANGELOG.md` and regenerates stable release notes.
|
||||
Prerelease tags also do not update `https://docs.subminer.moe/`.
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -81,8 +83,13 @@ Notes:
|
||||
- If you need to repair a published release body (for example, a prior version’s section was omitted), regenerate notes from `CHANGELOG.md` and re-edit the release with `gh release edit --notes-file`.
|
||||
- Prerelease tags are handled by `.github/workflows/prerelease.yml`, which always publishes a GitHub prerelease with all current release platforms and never runs the AUR sync job.
|
||||
- Tagged release workflow now also attempts to update `subminer-bin` on the AUR after GitHub Release publication.
|
||||
- Stable release tags update `https://docs.subminer.moe/` and `https://docs.subminer.moe/v/<version>/` through `.github/workflows/docs-pages.yml`; `/main/` continues to show development docs from `main`.
|
||||
- Keep Cloudflare Pages Git auto-deploy disabled for `docs.subminer.moe`. Production docs are direct-uploaded by Wrangler from GitHub Actions with `--branch main`.
|
||||
- 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.
|
||||
- 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 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. 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.
|
||||
- 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.
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
# Config Settings Window
|
||||
|
||||
read_when: changing config UI, config save behavior, or config docs
|
||||
|
||||
## Intent
|
||||
|
||||
Add a dedicated Electron settings window for editing canonical config values without exposing the historical layout mistakes in `config.jsonc`.
|
||||
|
||||
The UI groups options by workflow:
|
||||
|
||||
- Viewing
|
||||
- Mining & Anki
|
||||
- Playback & Sources
|
||||
- Input
|
||||
- Integrations
|
||||
- Tracking & App
|
||||
- Advanced
|
||||
|
||||
Each field maps back to its current raw config path. The presentation layer must stay separate from generated config-template sections.
|
||||
|
||||
## Sources
|
||||
|
||||
- Canonical defaults: `DEFAULT_CONFIG`
|
||||
- Existing option descriptions/enums: `CONFIG_OPTION_REGISTRY`
|
||||
- UI registry: `src/config/settings/registry.ts`
|
||||
- JSONC save path: `src/config/settings/jsonc-edit.ts`
|
||||
- Window runtime: `src/main/runtime/config-settings-window.ts`
|
||||
|
||||
## Save Contract
|
||||
|
||||
Settings writes use `jsonc-parser.modify`, not `JSON.stringify`.
|
||||
|
||||
Required behavior:
|
||||
|
||||
- Preserve comments, trailing commas, unrelated keys, and hidden legacy keys.
|
||||
- Reset removes the explicit path so defaults resolve normally.
|
||||
- Validate the candidate config before writing.
|
||||
- Reject warnings caused by modified fields.
|
||||
- Preserve unrelated existing warnings and return them in the snapshot.
|
||||
- Write atomically, reload `ConfigService`, classify with existing hot-reload logic, and apply live changes where supported.
|
||||
- Never return secret values to the renderer; snapshots only expose configured/not-configured state.
|
||||
|
||||
## Hidden Compatibility Keys
|
||||
|
||||
Do not expose these as first-class controls:
|
||||
|
||||
- `ankiConnect.deck`
|
||||
- Legacy top-level Anki migration fields such as `wordField`, `audioField`, media-generation aliases, and behavior aliases
|
||||
- Legacy `ankiConnect.nPlusOne.*` aliases except canonical `nPlusOne.nPlusOne` and `nPlusOne.minSentenceWords`
|
||||
- Deprecated Lapis sentence-card fields
|
||||
- `youtubeSubgen.primarySubLanguages`
|
||||
- `anilist.characterDictionary.refreshTtlHours`
|
||||
- `anilist.characterDictionary.evictionPolicy`
|
||||
- `jellyfin.accessToken`
|
||||
- `jellyfin.userId`
|
||||
- `controller.buttonIndices` as a normal editable field
|
||||
|
||||
## Verification
|
||||
|
||||
Minimum targeted checks:
|
||||
|
||||
- `bun test src/config/settings/registry.test.ts src/config/settings/jsonc-edit.test.ts src/settings/settings-model.test.ts src/main/runtime/config-settings-window.test.ts`
|
||||
- `bun run test:config`
|
||||
- `bun run typecheck`
|
||||
- `bun run build`
|
||||
|
||||
If docs changed:
|
||||
|
||||
- `bun run docs:test`
|
||||
- `bun run docs:build`
|
||||
@@ -567,9 +567,11 @@ export function buildSubminerScriptOpts(
|
||||
logLevel: LogLevel = 'info',
|
||||
extraParts: string[] = [],
|
||||
): string {
|
||||
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
|
||||
const hasSocketPath = extraParts.some((part) => part.startsWith('subminer-socket_path='));
|
||||
const parts = [
|
||||
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
|
||||
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
|
||||
...(hasBinaryPath ? [] : [`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`]),
|
||||
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
|
||||
...extraParts.map(sanitizeScriptOptValue),
|
||||
];
|
||||
if (logLevel !== 'info') {
|
||||
|
||||
@@ -3,7 +3,14 @@ import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
export function runAppPassthroughCommand(context: LauncherCommandContext): boolean {
|
||||
const { args, appPath } = context;
|
||||
if (!args.appPassthrough || !appPath) {
|
||||
if (!appPath) {
|
||||
return false;
|
||||
}
|
||||
if (args.configSettings) {
|
||||
runAppCommandWithInherit(appPath, ['--config']);
|
||||
return true;
|
||||
}
|
||||
if (!args.appPassthrough) {
|
||||
return false;
|
||||
}
|
||||
runAppCommandWithInherit(appPath, args.appArgs);
|
||||
|
||||
@@ -38,9 +38,14 @@ function createContext(overrides: Partial<LauncherCommandContext> = {}): Launche
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
pluginRuntimeConfig: {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
appPath: '/tmp/subminer.app',
|
||||
launcherJellyfinConfig: {},
|
||||
|
||||
@@ -13,6 +13,7 @@ interface MpvCommandDeps {
|
||||
appPath: string,
|
||||
args: LauncherCommandContext['args'],
|
||||
runtimePluginPath?: string | null,
|
||||
runtimePluginConfig?: LauncherCommandContext['pluginRuntimeConfig'],
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -49,7 +50,7 @@ export async function runMpvPostAppCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: MpvCommandDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
const { args, appPath, scriptPath, mpvSocketPath } = context;
|
||||
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig } = context;
|
||||
if (!args.mpvIdle) {
|
||||
return false;
|
||||
}
|
||||
@@ -62,6 +63,11 @@ export async function runMpvPostAppCommand(
|
||||
appPath,
|
||||
args,
|
||||
resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
{
|
||||
...pluginRuntimeConfig,
|
||||
backend: args.backend,
|
||||
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
);
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
if (!ready) {
|
||||
|
||||
@@ -52,6 +52,8 @@ function createContext(): LauncherCommandContext {
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
version: false,
|
||||
configSettings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
@@ -70,9 +72,14 @@ function createContext(): LauncherCommandContext {
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
pluginRuntimeConfig: {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
appPath: '/tmp/SubMiner.AppImage',
|
||||
launcherJellyfinConfig: {},
|
||||
@@ -138,7 +145,7 @@ test('youtube playback launches overlay with app-owned youtube flow args', async
|
||||
assert.equal(receivedStartMpvOptions[0]?.disableYoutubeSubtitleAutoLoad, true);
|
||||
});
|
||||
|
||||
test('plugin auto-start playback marks background app for cleanup when mpv exits', async () => {
|
||||
test('plugin auto-start playback leaves app lifetime to managed-playback owner', async () => {
|
||||
const context = createContext();
|
||||
context.args = {
|
||||
...context.args,
|
||||
@@ -147,9 +154,14 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
||||
};
|
||||
context.pluginRuntimeConfig = {
|
||||
socketPath: '/tmp/subminer.sock',
|
||||
binaryPath: '',
|
||||
backend: 'auto',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: false,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
};
|
||||
const appPath = context.appPath ?? '';
|
||||
state.appPath = appPath;
|
||||
@@ -162,7 +174,7 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
||||
mpvProc.exitCode = null;
|
||||
mpvProc.killed = false;
|
||||
mpvProc.kill = () => true;
|
||||
let cleanupSawManagedOverlay = false;
|
||||
let cleanupSawManagedOverlay = true;
|
||||
|
||||
try {
|
||||
await runPlaybackCommandWithDeps(context, {
|
||||
@@ -188,7 +200,7 @@ test('plugin auto-start playback marks background app for cleanup when mpv exits
|
||||
getMpvProc: () => mpvProc as NonNullable<typeof state.mpvProc>,
|
||||
});
|
||||
|
||||
assert.equal(cleanupSawManagedOverlay, true);
|
||||
assert.equal(cleanupSawManagedOverlay, false);
|
||||
} finally {
|
||||
state.appPath = '';
|
||||
state.overlayManagedByLauncher = false;
|
||||
|
||||
@@ -7,7 +7,6 @@ import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
||||
import {
|
||||
cleanupPlaybackSession,
|
||||
launchAppCommandDetached,
|
||||
markOverlayManagedByLauncher,
|
||||
resolveLauncherRuntimePluginPath,
|
||||
startMpv,
|
||||
startOverlay,
|
||||
@@ -238,6 +237,11 @@ export async function runPlaybackCommandWithDeps(
|
||||
startPaused: shouldPauseUntilOverlayReady || isAppOwnedYoutubeFlow,
|
||||
disableYoutubeSubtitleAutoLoad: isAppOwnedYoutubeFlow,
|
||||
runtimePluginPath: resolveLauncherRuntimePluginPath({ appPath, scriptPath }),
|
||||
runtimePluginConfig: {
|
||||
...pluginRuntimeConfig,
|
||||
backend: args.backend,
|
||||
texthookerEnabled: args.useTexthooker && pluginRuntimeConfig.texthookerEnabled,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
@@ -263,7 +267,6 @@ export async function runPlaybackCommandWithDeps(
|
||||
: [],
|
||||
);
|
||||
} else if (pluginAutoStartEnabled) {
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
if (ready) {
|
||||
deps.log('info', args.logLevel, 'MPV IPC socket ready, relying on mpv plugin auto-start');
|
||||
} else {
|
||||
|
||||
@@ -5,8 +5,8 @@ import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||
import { parseLauncherMpvConfig } from './config/mpv-config.js';
|
||||
import { readExternalYomitanProfilePath } from './config.js';
|
||||
import {
|
||||
getPluginConfigCandidates,
|
||||
parsePluginRuntimeConfigContent,
|
||||
buildPluginRuntimeScriptOptParts,
|
||||
parsePluginRuntimeConfigFromMainConfig,
|
||||
} from './config/plugin-runtime-config.js';
|
||||
import { getDefaultSocketPath } from './types.js';
|
||||
|
||||
@@ -86,10 +86,24 @@ test('parseLauncherMpvConfig reads launch mode preference', () => {
|
||||
mpv: {
|
||||
launchMode: ' maximized ',
|
||||
executablePath: 'ignored-here',
|
||||
socketPath: '/tmp/custom.sock',
|
||||
backend: 'x11',
|
||||
autoStartSubMiner: false,
|
||||
pauseUntilOverlayReady: false,
|
||||
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(parsed.launchMode, 'maximized');
|
||||
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
||||
assert.equal(parsed.backend, 'x11');
|
||||
assert.equal(parsed.autoStartSubMiner, false);
|
||||
assert.equal(parsed.pauseUntilOverlayReady, false);
|
||||
assert.equal(parsed.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(parsed.aniskipEnabled, false);
|
||||
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||
});
|
||||
|
||||
test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
||||
@@ -102,39 +116,102 @@ test('parseLauncherMpvConfig ignores invalid launch mode values', () => {
|
||||
assert.equal(parsed.launchMode, undefined);
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigContent reads socket path and startup gate options', () => {
|
||||
const parsed = parsePluginRuntimeConfigContent(`
|
||||
# comment
|
||||
socket_path = /tmp/custom.sock # trailing comment
|
||||
auto_start = yes
|
||||
auto_start_visible_overlay = true
|
||||
auto_start_pause_until_ready = 1
|
||||
`);
|
||||
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
||||
test('parsePluginRuntimeConfigFromMainConfig maps config.jsonc values over plugin defaults', () => {
|
||||
const parsed = parsePluginRuntimeConfigFromMainConfig({
|
||||
auto_start_overlay: false,
|
||||
texthooker: {
|
||||
launchAtStartup: false,
|
||||
},
|
||||
mpv: {
|
||||
socketPath: '/tmp/config.sock',
|
||||
backend: 'sway',
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
subminerBinaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(parsed.socketPath, '/tmp/config.sock');
|
||||
assert.equal(parsed.backend, 'sway');
|
||||
assert.equal(parsed.autoStart, true);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, true);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigContent falls back to disabled startup gate options', () => {
|
||||
const parsed = parsePluginRuntimeConfigContent(`
|
||||
auto_start = maybe
|
||||
auto_start_visible_overlay = no
|
||||
auto_start_pause_until_ready = off
|
||||
`);
|
||||
assert.equal(parsed.autoStart, false);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
assert.equal(parsed.binaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(parsed.texthookerEnabled, false);
|
||||
assert.equal(parsed.aniskipEnabled, false);
|
||||
assert.equal(parsed.aniskipButtonKey, 'F8');
|
||||
});
|
||||
|
||||
test('getPluginConfigCandidates resolves Windows mpv script-opts path', () => {
|
||||
test('parsePluginRuntimeConfigFromMainConfig defaults to background-only managed startup', () => {
|
||||
const parsed = parsePluginRuntimeConfigFromMainConfig(null);
|
||||
|
||||
assert.equal(parsed.autoStart, true);
|
||||
assert.equal(parsed.autoStartVisibleOverlay, false);
|
||||
assert.equal(parsed.autoStartPauseUntilReady, true);
|
||||
assert.equal(parsed.texthookerEnabled, false);
|
||||
assert.equal(parsed.aniskipEnabled, true);
|
||||
assert.equal(parsed.aniskipButtonKey, 'TAB');
|
||||
});
|
||||
|
||||
test('buildPluginRuntimeScriptOptParts emits config values that override plugin defaults', () => {
|
||||
assert.deepEqual(
|
||||
getPluginConfigCandidates({
|
||||
platform: 'win32',
|
||||
homeDir: 'C:\\Users\\tester',
|
||||
appDataDir: 'C:\\Users\\tester\\AppData\\Roaming',
|
||||
}),
|
||||
['C:\\Users\\tester\\AppData\\Roaming\\mpv\\script-opts\\subminer.conf'],
|
||||
buildPluginRuntimeScriptOptParts(
|
||||
{
|
||||
socketPath: '/tmp/config.sock',
|
||||
binaryPath: '/opt/SubMiner/SubMiner.AppImage',
|
||||
backend: 'x11',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8',
|
||||
},
|
||||
'/fallback/SubMiner.AppImage',
|
||||
),
|
||||
[
|
||||
'subminer-binary_path=/opt/SubMiner/SubMiner.AppImage',
|
||||
'subminer-socket_path=/tmp/config.sock',
|
||||
'subminer-backend=x11',
|
||||
'subminer-auto_start=yes',
|
||||
'subminer-auto_start_visible_overlay=no',
|
||||
'subminer-auto_start_pause_until_ready=yes',
|
||||
'subminer-texthooker_enabled=no',
|
||||
'subminer-aniskip_enabled=no',
|
||||
'subminer-aniskip_button_key=F8',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('buildPluginRuntimeScriptOptParts strips script-option delimiters from string values', () => {
|
||||
assert.deepEqual(
|
||||
buildPluginRuntimeScriptOptParts(
|
||||
{
|
||||
socketPath: '/tmp/config.sock,subminer-auto_start=no\nother=yes',
|
||||
binaryPath: '/opt/SubMiner,\nSubMiner.AppImage',
|
||||
backend: 'x11',
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: false,
|
||||
autoStartPauseUntilReady: true,
|
||||
texthookerEnabled: false,
|
||||
aniskipEnabled: false,
|
||||
aniskipButtonKey: 'F8,\nF9',
|
||||
},
|
||||
'/fallback/SubMiner.AppImage',
|
||||
),
|
||||
[
|
||||
'subminer-binary_path=/opt/SubMiner SubMiner.AppImage',
|
||||
'subminer-socket_path=/tmp/config.sock subminer-auto_start=no other=yes',
|
||||
'subminer-backend=x11',
|
||||
'subminer-auto_start=yes',
|
||||
'subminer-auto_start_visible_overlay=no',
|
||||
'subminer-auto_start_pause_until_ready=yes',
|
||||
'subminer-texthooker_enabled=no',
|
||||
'subminer-aniskip_enabled=no',
|
||||
'subminer-aniskip_button_key=F8 F9',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -159,6 +159,41 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
assert.equal(parsed.logLevel, 'warn');
|
||||
});
|
||||
|
||||
test('applyInvocationsToArgs maps bare config invocation to settings window', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: null,
|
||||
configInvocation: {
|
||||
action: undefined,
|
||||
},
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
dictionaryCandidates: false,
|
||||
dictionarySelect: false,
|
||||
dictionaryAnilistId: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
texthookerOpenBrowser: false,
|
||||
});
|
||||
|
||||
assert.equal(parsed.configSettings, true);
|
||||
assert.equal(parsed.configPath, false);
|
||||
});
|
||||
|
||||
test('applyInvocationsToArgs maps texthooker browser-open request', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
|
||||
@@ -118,7 +118,7 @@ export function createDefaultArgs(
|
||||
const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]);
|
||||
|
||||
const parsed: Args = {
|
||||
backend: 'auto',
|
||||
backend: mpvConfig.backend ?? 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: '',
|
||||
@@ -156,7 +156,9 @@ export function createDefaultArgs(
|
||||
statsCleanupLifetime: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
version: false,
|
||||
update: false,
|
||||
configSettings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
@@ -219,6 +221,8 @@ export function applyRootOptionsToArgs(
|
||||
if (typeof options.passwordStore === 'string') parsed.passwordStore = options.passwordStore;
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.update === true) parsed.update = true;
|
||||
if (options.version === true) parsed.version = true;
|
||||
if (options.config === true) parsed.configSettings = true;
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
if (typeof options.args === 'string') parsed.mpvArgs = options.args;
|
||||
@@ -306,8 +310,9 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
if (invocations.configInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
|
||||
}
|
||||
const action = (invocations.configInvocation.action || 'path').toLowerCase();
|
||||
if (action === 'path') parsed.configPath = true;
|
||||
const action = (invocations.configInvocation.action || '').toLowerCase();
|
||||
if (!action) parsed.configSettings = true;
|
||||
else if (action === 'path') parsed.configPath = true;
|
||||
else if (action === 'show') parsed.configShow = true;
|
||||
else fail(`Unknown config action: ${invocations.configInvocation.action}`);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ export interface JellyfinInvocation {
|
||||
}
|
||||
|
||||
export interface CommandActionInvocation {
|
||||
action: string;
|
||||
action?: string;
|
||||
logLevel?: string;
|
||||
}
|
||||
|
||||
@@ -57,6 +57,8 @@ function applyRootOptions(program: Command): void {
|
||||
.option('-p, --profile <profile>', 'MPV profile')
|
||||
.option('--start', 'Explicitly start overlay')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.option('-v, --version', 'Show SubMiner version')
|
||||
.option('--config', 'Open configuration window')
|
||||
.option('-u, --update', 'Check for updates')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||
@@ -292,9 +294,9 @@ export function parseCliPrograms(
|
||||
commandProgram
|
||||
.command('config')
|
||||
.description('Config helpers')
|
||||
.argument('[action]', 'path|show', 'path')
|
||||
.argument('[action]', 'path|show')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string, options: Record<string, unknown>) => {
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
configInvocation = {
|
||||
action,
|
||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||
|
||||
@@ -1,6 +1,29 @@
|
||||
import { parseMpvLaunchMode } from '../../src/shared/mpv-launch-mode.js';
|
||||
import type { Backend } from '../types.js';
|
||||
import type { LauncherMpvConfig } from '../types.js';
|
||||
|
||||
function parseBackend(value: unknown): Backend | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
normalized === 'auto' ||
|
||||
normalized === 'hyprland' ||
|
||||
normalized === 'sway' ||
|
||||
normalized === 'x11' ||
|
||||
normalized === 'macos' ||
|
||||
normalized === 'windows'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function parseNonEmptyString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : undefined;
|
||||
}
|
||||
|
||||
export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherMpvConfig {
|
||||
const mpvRaw = root.mpv;
|
||||
if (!mpvRaw || typeof mpvRaw !== 'object') return {};
|
||||
@@ -8,5 +31,15 @@ export function parseLauncherMpvConfig(root: Record<string, unknown>): LauncherM
|
||||
|
||||
return {
|
||||
launchMode: parseMpvLaunchMode(mpv.launchMode),
|
||||
socketPath: parseNonEmptyString(mpv.socketPath),
|
||||
backend: parseBackend(mpv.backend),
|
||||
autoStartSubMiner:
|
||||
typeof mpv.autoStartSubMiner === 'boolean' ? mpv.autoStartSubMiner : undefined,
|
||||
pauseUntilOverlayReady:
|
||||
typeof mpv.pauseUntilOverlayReady === 'boolean' ? mpv.pauseUntilOverlayReady : undefined,
|
||||
subminerBinaryPath:
|
||||
typeof mpv.subminerBinaryPath === 'string' ? mpv.subminerBinaryPath.trim() : undefined,
|
||||
aniskipEnabled: typeof mpv.aniskipEnabled === 'boolean' ? mpv.aniskipEnabled : undefined,
|
||||
aniskipButtonKey: parseNonEmptyString(mpv.aniskipButtonKey),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,126 +1,76 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { log } from '../log.js';
|
||||
import type { LogLevel, PluginRuntimeConfig } from '../types.js';
|
||||
import type { Backend, LogLevel, PluginRuntimeConfig } from '../types.js';
|
||||
import { DEFAULT_SOCKET_PATH } from '../types.js';
|
||||
import { buildSubminerPluginRuntimeScriptOptParts } from '../../src/shared/subminer-plugin-script-opts.js';
|
||||
import { parseLauncherMpvConfig } from './mpv-config.js';
|
||||
import { readLauncherMainConfigObject } from './shared-config-reader.js';
|
||||
|
||||
function getPlatformPath(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 {
|
||||
return platform === 'win32' ? path.win32 : path.posix;
|
||||
function rootObject(root: Record<string, unknown> | null, key: string): Record<string, unknown> {
|
||||
const value = root?.[key];
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function getPluginConfigCandidates(options?: {
|
||||
platform?: NodeJS.Platform;
|
||||
homeDir?: string;
|
||||
xdgConfigHome?: string;
|
||||
appDataDir?: string;
|
||||
}): string[] {
|
||||
const platform = options?.platform ?? process.platform;
|
||||
const homeDir = options?.homeDir ?? os.homedir();
|
||||
const platformPath = getPlatformPath(platform);
|
||||
function booleanOrDefault(value: unknown, fallback: boolean): boolean {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
const appDataDir =
|
||||
options?.appDataDir?.trim() ||
|
||||
process.env.APPDATA?.trim() ||
|
||||
platformPath.join(homeDir, 'AppData', 'Roaming');
|
||||
return [platformPath.join(appDataDir, 'mpv', 'script-opts', 'subminer.conf')];
|
||||
function nonEmptyStringOrDefault(value: unknown, fallback: string): string {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? trimmed : fallback;
|
||||
}
|
||||
|
||||
function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (
|
||||
normalized === 'auto' ||
|
||||
normalized === 'hyprland' ||
|
||||
normalized === 'sway' ||
|
||||
normalized === 'x11' ||
|
||||
normalized === 'macos' ||
|
||||
normalized === 'windows'
|
||||
) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const xdgConfigHome =
|
||||
options?.xdgConfigHome?.trim() ||
|
||||
process.env.XDG_CONFIG_HOME ||
|
||||
platformPath.join(homeDir, '.config');
|
||||
return Array.from(
|
||||
new Set([
|
||||
platformPath.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
platformPath.join(homeDir, '.config', 'mpv', 'script-opts', 'subminer.conf'),
|
||||
]),
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function parsePluginRuntimeConfigContent(
|
||||
content: string,
|
||||
logLevel: LogLevel = 'warn',
|
||||
export function parsePluginRuntimeConfigFromMainConfig(
|
||||
root: Record<string, unknown> | null,
|
||||
): PluginRuntimeConfig {
|
||||
const runtimeConfig: PluginRuntimeConfig = {
|
||||
socketPath: DEFAULT_SOCKET_PATH,
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
const mpvConfig = root ? parseLauncherMpvConfig(root) : {};
|
||||
const texthooker = rootObject(root, 'texthooker');
|
||||
|
||||
return {
|
||||
socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH,
|
||||
binaryPath: mpvConfig.subminerBinaryPath ?? '',
|
||||
backend: validBackendOrDefault(mpvConfig.backend, 'auto'),
|
||||
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
|
||||
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
|
||||
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
|
||||
texthookerEnabled: booleanOrDefault(texthooker.launchAtStartup, false),
|
||||
aniskipEnabled: booleanOrDefault(mpvConfig.aniskipEnabled, true),
|
||||
aniskipButtonKey: nonEmptyStringOrDefault(mpvConfig.aniskipButtonKey, 'TAB'),
|
||||
};
|
||||
}
|
||||
|
||||
const parseBooleanValue = (key: string, value: string): boolean => {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['yes', 'true', '1', 'on'].includes(normalized)) return true;
|
||||
if (['no', 'false', '0', 'off'].includes(normalized)) return false;
|
||||
log('warn', logLevel, `Invalid boolean value for ${key}: "${value}". Using false.`);
|
||||
return false;
|
||||
};
|
||||
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
||||
const keyValueMatch = trimmed.match(/^([a-z0-9_-]+)\s*=\s*(.+)$/i);
|
||||
if (!keyValueMatch) continue;
|
||||
const key = (keyValueMatch[1] || '').toLowerCase();
|
||||
const value = (keyValueMatch[2] || '').split('#', 1)[0]?.trim() || '';
|
||||
if (!value) continue;
|
||||
|
||||
if (key === 'socket_path') {
|
||||
runtimeConfig.socketPath = value;
|
||||
continue;
|
||||
}
|
||||
if (key === 'auto_start') {
|
||||
runtimeConfig.autoStart = parseBooleanValue('auto_start', value);
|
||||
continue;
|
||||
}
|
||||
if (key === 'auto_start_visible_overlay') {
|
||||
runtimeConfig.autoStartVisibleOverlay = parseBooleanValue(
|
||||
'auto_start_visible_overlay',
|
||||
value,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (key === 'auto_start_pause_until_ready') {
|
||||
runtimeConfig.autoStartPauseUntilReady = parseBooleanValue(
|
||||
'auto_start_pause_until_ready',
|
||||
value,
|
||||
);
|
||||
}
|
||||
}
|
||||
return runtimeConfig;
|
||||
export function buildPluginRuntimeScriptOptParts(
|
||||
runtimeConfig: PluginRuntimeConfig,
|
||||
fallbackAppPath: string,
|
||||
): string[] {
|
||||
return buildSubminerPluginRuntimeScriptOptParts(runtimeConfig, fallbackAppPath);
|
||||
}
|
||||
|
||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||
const candidates = getPluginConfigCandidates();
|
||||
const defaults: PluginRuntimeConfig = {
|
||||
socketPath: DEFAULT_SOCKET_PATH,
|
||||
autoStart: true,
|
||||
autoStartVisibleOverlay: true,
|
||||
autoStartPauseUntilReady: true,
|
||||
};
|
||||
|
||||
for (const configPath of candidates) {
|
||||
if (!fs.existsSync(configPath)) continue;
|
||||
try {
|
||||
const parsed = parsePluginRuntimeConfigContent(fs.readFileSync(configPath, 'utf8'));
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}`,
|
||||
);
|
||||
return parsed;
|
||||
} catch {
|
||||
log('warn', logLevel, `Failed to read ${configPath}; using launcher defaults`);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject());
|
||||
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath}, auto_start=${defaults.autoStart}, auto_start_visible_overlay=${defaults.autoStartVisibleOverlay}, auto_start_pause_until_ready=${defaults.autoStartPauseUntilReady})`,
|
||||
`Using mpv plugin settings from SubMiner config: socket_path=${parsed.socketPath}, backend=${parsed.backend}, auto_start=${parsed.autoStart}, auto_start_visible_overlay=${parsed.autoStartVisibleOverlay}, auto_start_pause_until_ready=${parsed.autoStartPauseUntilReady}, texthooker_enabled=${parsed.texthookerEnabled}, aniskip_enabled=${parsed.aniskipEnabled}, aniskip_button_key=${parsed.aniskipButtonKey}`,
|
||||
);
|
||||
return defaults;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
+133
-15
@@ -99,6 +99,30 @@ test('config discovery ignores lowercase subminer candidate', () => {
|
||||
assert.equal(resolved, expected);
|
||||
});
|
||||
|
||||
test('version flag prints installed app version without requiring app binary', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const result = runLauncher(['--version'], makeTestEnv(homeDir, xdgConfigHome));
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/);
|
||||
assert.equal(result.stderr, '');
|
||||
});
|
||||
});
|
||||
|
||||
test('short version flag prints installed app version without requiring app binary', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const result = runLauncher(['-v'], makeTestEnv(homeDir, xdgConfigHome));
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout.trim(), /^SubMiner \d+\.\d+\.\d+/);
|
||||
assert.equal(result.stderr, '');
|
||||
});
|
||||
});
|
||||
|
||||
test('config path prefers jsonc over json for same directory', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
@@ -133,10 +157,10 @@ test('mpv socket command returns socket path from plugin runtime config', () =>
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const expectedSocket = path.join(root, 'custom', 'subminer.sock');
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${expectedSocket}\n`,
|
||||
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||
JSON.stringify({ mpv: { socketPath: expectedSocket } }),
|
||||
);
|
||||
|
||||
const result = runLauncher(['mpv', 'socket'], makeTestEnv(homeDir, xdgConfigHome));
|
||||
@@ -151,10 +175,10 @@ test('mpv status exits non-zero when socket is not ready', () => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const socketPath = path.join(root, 'missing.sock');
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\n`,
|
||||
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||
JSON.stringify({ mpv: { socketPath } }),
|
||||
);
|
||||
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
|
||||
|
||||
@@ -208,6 +232,82 @@ test('doctor refresh-known-words forwards app refresh command without requiring
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher config option forwards app configuration window command', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['--config'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher config command forwards app configuration window command', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
const capturePath = path.join(root, 'captured-args.txt');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
SUBMINER_TEST_CAPTURE: capturePath,
|
||||
};
|
||||
const result = runLauncher(['config'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--config\n');
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher config command suppresses known Electron macOS menu diagnostics', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
const xdgConfigHome = path.join(root, 'xdg');
|
||||
const appPath = path.join(root, 'fake-subminer.sh');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'printf "%s\\n" "2026-05-17 02:59:52.141 SubMiner[29060:305323] representedObject is not a WeakPtrToElectronMenuModelAsNSObject" >&2',
|
||||
'printf "%s\\n" "real stderr line" >&2',
|
||||
'exit 0',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
const env = {
|
||||
...makeTestEnv(homeDir, xdgConfigHome),
|
||||
SUBMINER_APPIMAGE_PATH: appPath,
|
||||
};
|
||||
const result = runLauncher(['config'], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(result.stderr, 'real stderr line\n');
|
||||
});
|
||||
});
|
||||
|
||||
test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
@@ -221,7 +321,6 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(videoPath, 'fake video content');
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||
@@ -236,8 +335,15 @@ test('launcher forwards --args to mpv as parsed tokens', { timeout: 15000 }, ()
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\nauto_start=no\nauto_start_visible_overlay=no\nauto_start_pause_until_ready=no\n`,
|
||||
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||
JSON.stringify({
|
||||
auto_start_overlay: false,
|
||||
mpv: {
|
||||
socketPath,
|
||||
autoStartSubMiner: false,
|
||||
pauseUntilOverlayReady: false,
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
@@ -301,7 +407,6 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(videoPath, 'fake video content');
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||
@@ -316,8 +421,15 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
|
||||
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||
JSON.stringify({
|
||||
auto_start_overlay: true,
|
||||
mpv: {
|
||||
socketPath,
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
@@ -371,7 +483,6 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
||||
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'SubMiner', 'setup-state.json'),
|
||||
JSON.stringify({
|
||||
@@ -385,8 +496,15 @@ test('launcher routes youtube urls through regular playback startup', { timeout:
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\nauto_start=yes\nauto_start_visible_overlay=yes\nauto_start_pause_until_ready=yes\n`,
|
||||
path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'),
|
||||
JSON.stringify({
|
||||
auto_start_overlay: true,
|
||||
mpv: {
|
||||
socketPath,
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import path from 'node:path';
|
||||
import packageJson from '../package.json';
|
||||
import {
|
||||
loadLauncherJellyfinConfig,
|
||||
loadLauncherMpvConfig,
|
||||
@@ -20,6 +21,11 @@ import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
import { runUpdateCommand } from './commands/update-command.js';
|
||||
|
||||
const APP_VERSION =
|
||||
typeof packageJson.version === 'string' && packageJson.version.trim()
|
||||
? packageJson.version
|
||||
: 'unknown';
|
||||
|
||||
function createCommandContext(
|
||||
args: ReturnType<typeof parseArgs>,
|
||||
scriptPath: string,
|
||||
@@ -56,6 +62,12 @@ async function main(): Promise<void> {
|
||||
const launcherConfig = loadLauncherYoutubeSubgenConfig();
|
||||
const launcherMpvConfig = loadLauncherMpvConfig();
|
||||
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig, launcherMpvConfig);
|
||||
|
||||
if (args.version) {
|
||||
console.log(`SubMiner ${APP_VERSION}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
|
||||
const appPath = findAppBinary(scriptPath);
|
||||
|
||||
|
||||
@@ -114,6 +114,36 @@ test('runAppCommandCaptureOutput strips ELECTRON_RUN_AS_NODE from app child env'
|
||||
}
|
||||
});
|
||||
|
||||
test('runAppCommandCaptureOutput transports Linux AppImage args through environment', () => {
|
||||
if (process.platform !== 'linux') return;
|
||||
const { dir } = createTempSocketPath();
|
||||
const appPath = path.join(dir, 'SubMiner.AppImage');
|
||||
fs.writeFileSync(
|
||||
appPath,
|
||||
[
|
||||
'#!/bin/sh',
|
||||
'printf "args:%s\\n" "$*"',
|
||||
'printf "argc:%s\\n" "$SUBMINER_APP_ARGC"',
|
||||
'printf "arg0:%s\\n" "$SUBMINER_APP_ARG_0"',
|
||||
'printf "arg1:%s\\n" "$SUBMINER_APP_ARG_1"',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.chmodSync(appPath, 0o755);
|
||||
|
||||
try {
|
||||
const result = runAppCommandCaptureOutput(appPath, ['--app-ping', '--socket']);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.match(result.stdout, /^args:\n/m);
|
||||
assert.match(result.stdout, /^argc:2\n/m);
|
||||
assert.match(result.stdout, /^arg0:--app-ping\n/m);
|
||||
assert.match(result.stdout, /^arg1:--socket\n/m);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('parseMpvArgString preserves empty quoted tokens', () => {
|
||||
assert.deepEqual(parseMpvArgString('--title "" --force-media-title \'\' --pause'), [
|
||||
'--title',
|
||||
@@ -529,6 +559,8 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
stats: false,
|
||||
doctor: false,
|
||||
doctorRefreshKnownWords: false,
|
||||
version: false,
|
||||
configSettings: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
|
||||
+86
-22
@@ -8,10 +8,11 @@ import {
|
||||
detectInstalledMpvPlugin,
|
||||
type InstalledMpvPluginDetection,
|
||||
} from '../src/main/runtime/first-run-setup-plugin.js';
|
||||
import type { LogLevel, Backend, Args, MpvTrack } 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 { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
||||
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
|
||||
import { nowMs } from './time.js';
|
||||
import {
|
||||
commandExists,
|
||||
@@ -38,6 +39,7 @@ export const state = {
|
||||
type SpawnTarget = {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | 'resolve'>;
|
||||
@@ -45,6 +47,8 @@ type PathModule = Pick<typeof path, 'join' | 'extname' | 'delimiter' | 'sep' | '
|
||||
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_COMMAND_SETTLE_TIMEOUT_MS = 700;
|
||||
const TRANSPORTED_APP_ARGC_ENV = 'SUBMINER_APP_ARGC';
|
||||
const TRANSPORTED_APP_ARG_PREFIX = 'SUBMINER_APP_ARG_';
|
||||
|
||||
export interface LauncherRuntimePluginPlan {
|
||||
scriptPath: string | null;
|
||||
@@ -849,6 +853,7 @@ export async function startMpv(
|
||||
startPaused?: boolean;
|
||||
disableYoutubeSubtitleAutoLoad?: boolean;
|
||||
runtimePluginPath?: string | null;
|
||||
runtimePluginConfig?: PluginRuntimeConfig;
|
||||
},
|
||||
): Promise<void> {
|
||||
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
||||
@@ -916,13 +921,13 @@ export async function startMpv(
|
||||
options?.disableYoutubeSubtitleAutoLoad === true
|
||||
? ['subminer-auto_start_pause_until_ready=no']
|
||||
: [];
|
||||
const scriptOpts = buildSubminerScriptOpts(
|
||||
appPath,
|
||||
socketPath,
|
||||
aniSkipMetadata,
|
||||
args.logLevel,
|
||||
extraScriptOpts,
|
||||
);
|
||||
const runtimeScriptOpts = options?.runtimePluginConfig
|
||||
? buildPluginRuntimeScriptOptParts(options.runtimePluginConfig, appPath)
|
||||
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata, args.logLevel, [
|
||||
...runtimeScriptOpts,
|
||||
...extraScriptOpts,
|
||||
]);
|
||||
if (aniSkipMetadata) {
|
||||
log(
|
||||
'debug',
|
||||
@@ -1007,7 +1012,7 @@ export async function startOverlay(
|
||||
const target = resolveAppSpawnTarget(appPath, overlayArgs);
|
||||
state.overlayProc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
attachAppProcessLogging(state.overlayProc);
|
||||
markOverlayManagedByLauncher(appPath);
|
||||
@@ -1144,7 +1149,7 @@ function stopManagedOverlayApp(args: Args): void {
|
||||
const target = resolveAppSpawnTarget(state.appPath, stopArgs);
|
||||
const result = spawnSync(target.command, target.args, {
|
||||
stdio: 'ignore',
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
if (result.error) {
|
||||
log('warn', args.logLevel, `Failed to stop SubMiner overlay: ${result.error.message}`);
|
||||
@@ -1161,13 +1166,40 @@ function stopManagedOverlayApp(args: Args): void {
|
||||
}
|
||||
}
|
||||
|
||||
function buildAppEnv(baseEnv: NodeJS.ProcessEnv = process.env): NodeJS.ProcessEnv {
|
||||
function clearTransportedAppArgs(env: Record<string, string | undefined>): void {
|
||||
for (const key of Object.keys(env)) {
|
||||
if (key === TRANSPORTED_APP_ARGC_ENV || /^SUBMINER_APP_ARG_\d+$/.test(key)) {
|
||||
delete env[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function buildTransportedAppArgsEnv(appArgs: string[]): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
[TRANSPORTED_APP_ARGC_ENV]: String(appArgs.length),
|
||||
};
|
||||
appArgs.forEach((arg, index) => {
|
||||
env[`${TRANSPORTED_APP_ARG_PREFIX}${index}`] = arg;
|
||||
});
|
||||
return env;
|
||||
}
|
||||
|
||||
function shouldTransportAppArgsForAppImage(appPath: string): boolean {
|
||||
return process.platform === 'linux' && /\.AppImage$/i.test(appPath);
|
||||
}
|
||||
|
||||
function buildAppEnv(
|
||||
baseEnv: NodeJS.ProcessEnv = process.env,
|
||||
extraEnv: NodeJS.ProcessEnv = {},
|
||||
): NodeJS.ProcessEnv {
|
||||
const env: Record<string, string | undefined> = {
|
||||
...baseEnv,
|
||||
SUBMINER_APP_LOG: getAppLogPath(),
|
||||
SUBMINER_MPV_LOG: getMpvLogPath(),
|
||||
};
|
||||
delete env.ELECTRON_RUN_AS_NODE;
|
||||
clearTransportedAppArgs(env);
|
||||
Object.assign(env, extraEnv);
|
||||
const layers = env.VK_INSTANCE_LAYERS;
|
||||
if (typeof layers === 'string' && layers.trim().length > 0) {
|
||||
const filtered = layers
|
||||
@@ -1229,6 +1261,14 @@ function appendCapturedAppOutput(kind: 'STDOUT' | 'STDERR', chunk: string): void
|
||||
}
|
||||
}
|
||||
|
||||
const KNOWN_ELECTRON_MENU_DIAGNOSTIC =
|
||||
'representedObject is not a WeakPtrToElectronMenuModelAsNSObject';
|
||||
|
||||
function filterKnownElectronDiagnostics(chunk: string): string {
|
||||
const lines = chunk.match(/[^\n]*\n|[^\n]+/g) ?? [];
|
||||
return lines.filter((line) => !line.includes(KNOWN_ELECTRON_MENU_DIAGNOSTIC)).join('');
|
||||
}
|
||||
|
||||
function attachAppProcessLogging(
|
||||
proc: ReturnType<typeof spawn>,
|
||||
options?: {
|
||||
@@ -1243,8 +1283,12 @@ function attachAppProcessLogging(
|
||||
if (options?.mirrorStdout) process.stdout.write(chunk);
|
||||
});
|
||||
proc.stderr?.on('data', (chunk: string) => {
|
||||
appendCapturedAppOutput('STDERR', chunk);
|
||||
if (options?.mirrorStderr) process.stderr.write(chunk);
|
||||
const filteredChunk = filterKnownElectronDiagnostics(chunk);
|
||||
if (!filteredChunk) {
|
||||
return;
|
||||
}
|
||||
appendCapturedAppOutput('STDERR', filteredChunk);
|
||||
if (options?.mirrorStderr) process.stderr.write(filteredChunk);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1260,7 +1304,7 @@ function runSyncAppCommand(
|
||||
} {
|
||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||
const result = spawnSync(target.command, target.args, {
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
encoding: 'utf8',
|
||||
});
|
||||
if (result.stdout) {
|
||||
@@ -1268,13 +1312,16 @@ function runSyncAppCommand(
|
||||
if (mirrorOutput) process.stdout.write(result.stdout);
|
||||
}
|
||||
if (result.stderr) {
|
||||
appendCapturedAppOutput('STDERR', result.stderr);
|
||||
if (mirrorOutput) process.stderr.write(result.stderr);
|
||||
const filteredStderr = filterKnownElectronDiagnostics(result.stderr);
|
||||
if (filteredStderr) {
|
||||
appendCapturedAppOutput('STDERR', filteredStderr);
|
||||
if (mirrorOutput) process.stderr.write(filteredStderr);
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: result.status ?? 1,
|
||||
stdout: result.stdout ?? '',
|
||||
stderr: result.stderr ?? '',
|
||||
stderr: result.stderr ? filterKnownElectronDiagnostics(result.stderr) : '',
|
||||
error: result.error ?? undefined,
|
||||
};
|
||||
}
|
||||
@@ -1290,6 +1337,13 @@ function maybeCaptureAppArgs(appArgs: string[]): boolean {
|
||||
}
|
||||
|
||||
function resolveAppSpawnTarget(appPath: string, appArgs: string[]): SpawnTarget {
|
||||
if (shouldTransportAppArgsForAppImage(appPath)) {
|
||||
return {
|
||||
command: appPath,
|
||||
args: [],
|
||||
env: buildTransportedAppArgsEnv(appArgs),
|
||||
};
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
return { command: appPath, args: appArgs };
|
||||
}
|
||||
@@ -1304,7 +1358,7 @@ export function runAppCommandWithInherit(appPath: string, appArgs: string[]): vo
|
||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
||||
proc.once('error', (error) => {
|
||||
@@ -1323,7 +1377,7 @@ export function runAppCommandSilently(appPath: string, appArgs: string[]): void
|
||||
const target = resolveAppSpawnTarget(appPath, appArgs);
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
attachAppProcessLogging(proc);
|
||||
proc.once('error', (error) => {
|
||||
@@ -1374,7 +1428,7 @@ export function runAppCommandAttached(
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
attachAppProcessLogging(proc, { mirrorStdout: true, mirrorStderr: true });
|
||||
proc.once('error', (error) => {
|
||||
@@ -1445,7 +1499,7 @@ export function launchAppCommandDetached(
|
||||
const proc = spawn(target.command, target.args, {
|
||||
stdio: ['ignore', stdoutFd, stderrFd],
|
||||
detached: true,
|
||||
env: buildAppEnv(),
|
||||
env: buildAppEnv(process.env, target.env),
|
||||
});
|
||||
proc.once('error', (error) => {
|
||||
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
|
||||
@@ -1462,6 +1516,7 @@ export function launchMpvIdleDetached(
|
||||
appPath: string,
|
||||
args: Args,
|
||||
runtimePluginPath?: string | null,
|
||||
runtimePluginConfig?: PluginRuntimeConfig,
|
||||
): Promise<void> {
|
||||
return (async () => {
|
||||
await terminateTrackedDetachedMpv(args.logLevel);
|
||||
@@ -1483,8 +1538,17 @@ export function launchMpvIdleDetached(
|
||||
mpvArgs.push(...parseMpvArgString(args.mpvArgs));
|
||||
}
|
||||
mpvArgs.push('--idle=yes');
|
||||
const runtimeScriptOpts = runtimePluginConfig
|
||||
? buildPluginRuntimeScriptOptParts(runtimePluginConfig, appPath)
|
||||
: [`subminer-binary_path=${appPath}`, `subminer-socket_path=${socketPath}`];
|
||||
mpvArgs.push(
|
||||
`--script-opts=${buildSubminerScriptOpts(appPath, socketPath, null, args.logLevel)}`,
|
||||
`--script-opts=${buildSubminerScriptOpts(
|
||||
appPath,
|
||||
socketPath,
|
||||
null,
|
||||
args.logLevel,
|
||||
runtimeScriptOpts,
|
||||
)}`,
|
||||
);
|
||||
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
|
||||
mpvArgs.push(`--input-ipc-server=${socketPath}`);
|
||||
|
||||
@@ -57,6 +57,12 @@ test('parseArgs captures mpv args string', () => {
|
||||
assert.equal(parsed.mpvArgs, '--pause=yes --title="movie night"');
|
||||
});
|
||||
|
||||
test('parseArgs maps root config window option', () => {
|
||||
const parsed = parseArgs(['--config'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.configSettings, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps root update flags without conflicting with jellyfin username', () => {
|
||||
const shortParsed = parseArgs(['-u'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--update'], 'subminer', {});
|
||||
@@ -69,6 +75,17 @@ test('parseArgs maps root update flags without conflicting with jellyfin usernam
|
||||
assert.equal(jellyfinParsed.jellyfinUsername, 'kyle');
|
||||
});
|
||||
|
||||
test('parseArgs maps root version flags without conflicting with stats vocab flag', () => {
|
||||
const shortParsed = parseArgs(['-v'], 'subminer', {});
|
||||
const longParsed = parseArgs(['--version'], 'subminer', {});
|
||||
const statsParsed = parseArgs(['stats', 'cleanup', '-v'], 'subminer', {});
|
||||
|
||||
assert.equal(shortParsed.version, true);
|
||||
assert.equal(longParsed.version, true);
|
||||
assert.equal(statsParsed.version, false);
|
||||
assert.equal(statsParsed.statsCleanupVocab, true);
|
||||
});
|
||||
|
||||
test('parseArgs maps jellyfin play action and log-level override', () => {
|
||||
const parsed = parseArgs(['jellyfin', 'play', '--log-level', 'debug'], 'subminer', {});
|
||||
|
||||
@@ -90,6 +107,33 @@ test('parseArgs maps config show action', () => {
|
||||
assert.equal(parsed.configPath, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps bare config command to settings window', () => {
|
||||
const parsed = parseArgs(['config'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.configSettings, true);
|
||||
assert.equal(parsed.configPath, false);
|
||||
assert.equal(parsed.configShow, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps config path action to config path output', () => {
|
||||
const parsed = parseArgs(['config', 'path'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.configPath, true);
|
||||
assert.equal(parsed.configSettings, false);
|
||||
});
|
||||
|
||||
test('parseArgs rejects removed config open and launch actions', () => {
|
||||
const openExit = withProcessExitIntercept(() => {
|
||||
parseArgs(['config', 'open'], 'subminer', {});
|
||||
});
|
||||
const exit = withProcessExitIntercept(() => {
|
||||
parseArgs(['config', 'launch'], 'subminer', {});
|
||||
});
|
||||
|
||||
assert.equal(openExit.code, 1);
|
||||
assert.equal(exit.code, 1);
|
||||
});
|
||||
|
||||
test('parseArgs maps mpv idle action', () => {
|
||||
const parsed = parseArgs(['mpv', 'idle'], 'subminer', {});
|
||||
|
||||
|
||||
+11
-13
@@ -58,14 +58,11 @@ function createSmokeCase(name: string): SmokeCase {
|
||||
|
||||
fs.mkdirSync(artifactsDir, { recursive: true });
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
|
||||
fs.writeFileSync(videoPath, 'fake video fixture');
|
||||
fs.writeFileSync(
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
`socket_path=${socketPath}\n`,
|
||||
);
|
||||
|
||||
const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir });
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } }));
|
||||
const setupState = createDefaultSetupState();
|
||||
setupState.status = 'completed';
|
||||
setupState.completedAt = '2026-03-07T00:00:00.000Z';
|
||||
@@ -356,14 +353,15 @@ test(
|
||||
async () => {
|
||||
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
||||
fs.writeFileSync(
|
||||
path.join(smokeCase.xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
[
|
||||
`socket_path=${smokeCase.socketPath}`,
|
||||
'auto_start=yes',
|
||||
'auto_start_visible_overlay=yes',
|
||||
'auto_start_pause_until_ready=yes',
|
||||
'',
|
||||
].join('\n'),
|
||||
path.join(getDefaultConfigDir(smokeCase), 'config.jsonc'),
|
||||
JSON.stringify({
|
||||
auto_start_overlay: true,
|
||||
mpv: {
|
||||
socketPath: smokeCase.socketPath,
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const env = makeTestEnv(smokeCase);
|
||||
|
||||
+16
-5
@@ -1,15 +1,12 @@
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import type { MpvLaunchMode } from '../src/types/config.js';
|
||||
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
|
||||
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
|
||||
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
||||
|
||||
export const ROFI_THEME_FILE = 'subminer.rasi';
|
||||
export function getDefaultSocketPath(platform: NodeJS.Platform = process.platform): string {
|
||||
if (platform === 'win32') {
|
||||
return '\\\\.\\pipe\\subminer-socket';
|
||||
}
|
||||
return '/tmp/subminer-socket';
|
||||
return platform === 'win32' ? '\\\\.\\pipe\\subminer-socket' : '/tmp/subminer-socket';
|
||||
}
|
||||
|
||||
export const DEFAULT_SOCKET_PATH = getDefaultSocketPath();
|
||||
@@ -134,7 +131,9 @@ export interface Args {
|
||||
dictionaryTarget?: string;
|
||||
doctor: boolean;
|
||||
doctorRefreshKnownWords: boolean;
|
||||
version: boolean;
|
||||
update?: boolean;
|
||||
configSettings: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
mpvIdle: boolean;
|
||||
@@ -176,13 +175,25 @@ export interface LauncherJellyfinConfig {
|
||||
|
||||
export interface LauncherMpvConfig {
|
||||
launchMode?: MpvLaunchMode;
|
||||
socketPath?: string;
|
||||
backend?: MpvBackend;
|
||||
autoStartSubMiner?: boolean;
|
||||
pauseUntilOverlayReady?: boolean;
|
||||
subminerBinaryPath?: string;
|
||||
aniskipEnabled?: boolean;
|
||||
aniskipButtonKey?: string;
|
||||
}
|
||||
|
||||
export interface PluginRuntimeConfig {
|
||||
socketPath: string;
|
||||
binaryPath: string;
|
||||
backend: Backend;
|
||||
autoStart: boolean;
|
||||
autoStartVisibleOverlay: boolean;
|
||||
autoStartPauseUntilReady: boolean;
|
||||
texthookerEnabled: boolean;
|
||||
aniskipEnabled: boolean;
|
||||
aniskipButtonKey: string;
|
||||
}
|
||||
|
||||
export interface CommandExecOptions {
|
||||
|
||||
Generated
+4133
File diff suppressed because it is too large
Load Diff
+7
-4
@@ -18,8 +18,9 @@
|
||||
"build:launcher": "bun build ./launcher/main.ts --target=bun --packages=bundle --banner='#!/usr/bin/env bun' --outfile=dist/launcher/subminer",
|
||||
"build:stats": "cd stats && bun run build",
|
||||
"dev:stats": "cd stats && bun run dev",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:launcher && bun run build:assets",
|
||||
"build": "bun run build:yomitan && bun run build:stats && tsc -p tsconfig.json && bun run build:renderer && bun run build:settings && bun run build:launcher && bun run build:assets",
|
||||
"build:renderer": "esbuild src/renderer/renderer.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/renderer/renderer.js --sourcemap",
|
||||
"build:settings": "esbuild src/settings/settings.ts --bundle --platform=browser --format=esm --target=es2022 --outfile=dist/settings/settings.js --sourcemap",
|
||||
"changelog:build": "bun run scripts/build-changelog.ts build-release",
|
||||
"changelog:check": "bun run scripts/build-changelog.ts check",
|
||||
"changelog:docs": "bun run scripts/build-changelog.ts docs",
|
||||
@@ -39,6 +40,7 @@
|
||||
"lint": "bun run lint:stats",
|
||||
"docs:dev": "bun run --cwd docs-site docs:dev",
|
||||
"docs:build": "bun run --cwd docs-site docs:build",
|
||||
"docs:build:versioned": "bun run scripts/build-versioned-docs.ts",
|
||||
"docs:preview": "bun run --cwd docs-site docs:preview",
|
||||
"docs:test": "bun run --cwd docs-site test",
|
||||
"test:docs:kb": "bun test scripts/docs-knowledge-base.test.ts",
|
||||
@@ -48,8 +50,8 @@
|
||||
"test:plugin:src": "lua scripts/test-plugin-lua-compat.lua && lua scripts/test-plugin-start-gate.lua && lua scripts/test-plugin-session-bindings.lua && lua scripts/test-plugin-binary-windows.lua",
|
||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/config/cli-parser-builder.test.ts launcher/config/args-normalizer.test.ts launcher/mpv.test.ts launcher/picker.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/commands/update-command.test.ts launcher/smoke.e2e.test.ts && bun run test:plugin:src",
|
||||
"test:core:src": "bun test src/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/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/__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/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/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/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/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 stats/src/styles/globals.test.ts",
|
||||
"test:core:dist": "bun test 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/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/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/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
||||
"test:core:src": "bun test src/preload-settings.test.ts src/settings/settings-anki-controls.test.ts src/settings/settings-model.test.ts src/settings/settings-field-layout.test.ts src/cli/args.test.ts src/cli/help.test.ts src/shared/setup-state.test.ts src/core/services/cli-command.test.ts src/core/services/ipc.test.ts src/core/services/anki-jimaku-ipc.test.ts src/core/services/field-grouping-overlay.test.ts src/core/services/numeric-shortcut-session.test.ts src/core/services/secondary-subtitle.test.ts src/core/services/mpv-render-metrics.test.ts src/core/services/overlay-content-measurement.test.ts src/core/services/mpv-control.test.ts src/core/services/mpv.test.ts src/core/services/runtime-options-ipc.test.ts src/core/services/runtime-config.test.ts src/core/services/yomitan-extension-paths.test.ts src/core/services/yomitan-extension-loader.test.ts src/core/services/yomitan-settings.test.ts src/core/services/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/__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/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/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: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/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/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/handlers/mouse.test.js dist/renderer/handlers/keyboard.test.js dist/renderer/modals/jimaku.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/hyprland-tracker.test.js dist/window-trackers/x11-tracker.test.js dist/window-trackers/windows-helper.test.js dist/window-trackers/windows-tracker.test.js",
|
||||
"test:core:smoke:dist": "bun test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||
"test:subtitle:src": "bun test src/core/services/subsync.test.ts src/subsync/utils.test.ts",
|
||||
@@ -70,7 +72,7 @@
|
||||
"test:launcher": "bun run test:launcher:src",
|
||||
"test:core": "bun run test:core:src",
|
||||
"test:subtitle": "bun run test:subtitle:src",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"test:fast": "bun run test:config:src && bun run test:core:src && bun run test:docs:kb && bun test src/main-entry-runtime.test.ts src/anki-integration.test.ts src/anki-integration/card-creation-manual-update.test.ts src/anki-integration/anki-connect-proxy.test.ts src/anki-integration/field-grouping-workflow.test.ts src/anki-integration/field-grouping.test.ts src/anki-integration/field-grouping-merge.test.ts src/release-workflow.test.ts src/prerelease-workflow.test.ts src/ci-workflow.test.ts scripts/docs-versioning.test.ts scripts/docs-versioned-assets.test.ts scripts/build-changelog.test.ts scripts/electron-builder-after-pack.test.ts scripts/get-mpv-window-macos.test.ts scripts/mkv-to-readme-video.test.ts scripts/run-coverage-lane.test.ts scripts/update-aur-package.test.ts && bun test src/core/services/immersion-tracker/__tests__/query.test.ts src/core/services/immersion-tracker/__tests__/query-split-modules.test.ts && bun run tsc && bun test dist/main/runtime/registry.test.js",
|
||||
"generate:config-example": "bun run src/generate-config-example.ts",
|
||||
"verify:config-example": "bun run src/verify-config-example.ts",
|
||||
"start": "bun run build && electron . --start",
|
||||
@@ -116,6 +118,7 @@
|
||||
"jsonc-parser": "^3.3.1",
|
||||
"koffi": "^2.15.6",
|
||||
"libsql": "^0.5.22",
|
||||
"vscode-json-languageservice": "^5.7.2",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+3
-75
@@ -1,75 +1,3 @@
|
||||
# SubMiner configuration
|
||||
# Place this file in ~/.config/mpv/script-opts/
|
||||
|
||||
# Path to SubMiner binary (leave empty for auto-detection)
|
||||
# Auto-detection searches common locations, including:
|
||||
# - macOS: /Applications/SubMiner.app/Contents/MacOS/SubMiner, ~/Applications/SubMiner.app/Contents/MacOS/SubMiner
|
||||
# - Windows: %LOCALAPPDATA%\Programs\SubMiner\SubMiner.exe, %ProgramFiles%\SubMiner\SubMiner.exe
|
||||
# - Linux: ~/.local/bin/SubMiner.AppImage, /opt/SubMiner/SubMiner.AppImage, /usr/local/bin/SubMiner, /usr/local/bin/subminer, /usr/bin/SubMiner, /usr/bin/subminer
|
||||
binary_path=
|
||||
|
||||
# Path to mpv IPC socket (must match input-ipc-server in mpv.conf)
|
||||
# Windows installs rewrite this to \\.\pipe\subminer-socket during installation.
|
||||
socket_path=/tmp/subminer-socket
|
||||
|
||||
# Enable texthooker WebSocket server
|
||||
texthooker_enabled=yes
|
||||
|
||||
# Texthooker WebSocket port
|
||||
texthooker_port=5174
|
||||
|
||||
# Window manager backend: auto, hyprland, sway, x11, macos, windows
|
||||
# "auto" detects based on environment variables
|
||||
backend=auto
|
||||
|
||||
# Automatically start overlay when a file is loaded
|
||||
# Runs only when mpv input-ipc-server matches socket_path.
|
||||
auto_start=yes
|
||||
|
||||
# Automatically show visible overlay when overlay starts
|
||||
# Runs only when mpv input-ipc-server matches socket_path.
|
||||
auto_start_visible_overlay=yes
|
||||
|
||||
# Pause mpv on visible auto-start until SubMiner signals overlay/tokenization readiness.
|
||||
# Requires auto_start=yes and auto_start_visible_overlay=yes.
|
||||
auto_start_pause_until_ready=yes
|
||||
|
||||
# Show OSD messages for overlay status
|
||||
osd_messages=yes
|
||||
|
||||
# Log level for plugin and SubMiner binary: debug, info, warn, error
|
||||
log_level=info
|
||||
|
||||
# Enable AniSkip intro detection + markers.
|
||||
aniskip_enabled=yes
|
||||
|
||||
# Force title (optional). Launcher fills this from guessit when available.
|
||||
aniskip_title=
|
||||
|
||||
# Force season (optional). Launcher fills this from guessit when available.
|
||||
aniskip_season=
|
||||
|
||||
# Force MAL id (optional). Leave blank for title lookup.
|
||||
aniskip_mal_id=
|
||||
|
||||
# Force episode number (optional). Leave blank for filename/title detection.
|
||||
aniskip_episode=
|
||||
|
||||
# Optional pre-fetched AniSkip payload for this media (JSON or base64 JSON). When set, the plugin uses this directly and skips network lookup.
|
||||
aniskip_payload=
|
||||
|
||||
# Show intro skip OSD button while inside OP range.
|
||||
aniskip_show_button=yes
|
||||
|
||||
# OSD text shown for intro skip action.
|
||||
# `%s` is replaced by keybinding.
|
||||
aniskip_button_text=You can skip by pressing %s
|
||||
|
||||
# Keybinding to execute intro skip when button is visible.
|
||||
aniskip_button_key=TAB
|
||||
|
||||
# OSD hint duration in seconds (shown during first 3s of intro).
|
||||
aniskip_button_duration=3
|
||||
|
||||
# MPV keybindings provided by plugin/subminer/main.lua:
|
||||
# y-s start, y-S stop, y-t toggle visible overlay
|
||||
# SubMiner managed playback config lives in SubMiner config.jsonc.
|
||||
# This file is intentionally empty so installed/default mpv script-opts do not
|
||||
# override the app config modal or generated config file.
|
||||
|
||||
@@ -27,16 +27,16 @@ function M.load(options_lib, default_socket_path)
|
||||
local opts = {
|
||||
binary_path = "",
|
||||
socket_path = default_socket_path,
|
||||
texthooker_enabled = true,
|
||||
texthooker_enabled = false,
|
||||
texthooker_port = 5174,
|
||||
backend = "auto",
|
||||
auto_start = true,
|
||||
auto_start_visible_overlay = true,
|
||||
auto_start = false,
|
||||
auto_start_visible_overlay = false,
|
||||
auto_start_pause_until_ready = true,
|
||||
auto_start_pause_until_ready_timeout_seconds = 15,
|
||||
osd_messages = true,
|
||||
log_level = "info",
|
||||
aniskip_enabled = true,
|
||||
aniskip_enabled = false,
|
||||
aniskip_title = "",
|
||||
aniskip_season = "",
|
||||
aniskip_mal_id = "",
|
||||
|
||||
+173
-36
@@ -2,12 +2,15 @@ local M = {}
|
||||
|
||||
local OVERLAY_START_RETRY_DELAY_SECONDS = 0.2
|
||||
local OVERLAY_START_MAX_ATTEMPTS = 6
|
||||
local OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS = 0.2
|
||||
local OVERLAY_RESTART_PING_MAX_ATTEMPTS = 20
|
||||
local AUTO_PLAY_READY_LOADING_OSD = "Loading subtitle tokenization..."
|
||||
local AUTO_PLAY_READY_READY_OSD = "Subtitle tokenization ready"
|
||||
local DEFAULT_AUTO_PLAY_READY_TIMEOUT_SECONDS = 15
|
||||
|
||||
function M.create(ctx)
|
||||
local mp = ctx.mp
|
||||
local utils = ctx.utils
|
||||
local opts = ctx.opts
|
||||
local state = ctx.state
|
||||
local binary = ctx.binary
|
||||
@@ -17,6 +20,8 @@ function M.create(ctx)
|
||||
local show_osd = ctx.log.show_osd
|
||||
local normalize_log_level = ctx.log.normalize_log_level
|
||||
local run_control_command_async
|
||||
local APP_ARGC_ENV = "SUBMINER_APP_ARGC"
|
||||
local APP_ARG_PREFIX = "SUBMINER_APP_ARG_"
|
||||
|
||||
local function resolve_visible_overlay_startup()
|
||||
local raw_visible_overlay = opts.auto_start_visible_overlay
|
||||
@@ -112,10 +117,12 @@ function M.create(ctx)
|
||||
local function disarm_auto_play_ready_gate(options)
|
||||
local should_resume = options == nil or options.resume_playback ~= false
|
||||
local was_armed = state.auto_play_ready_gate_armed
|
||||
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
|
||||
clear_auto_play_ready_timeout()
|
||||
clear_auto_play_ready_osd_timer()
|
||||
state.auto_play_ready_gate_armed = false
|
||||
if was_armed and should_resume then
|
||||
state.auto_play_ready_should_resume_playback = false
|
||||
if was_armed and should_resume and should_resume_playback then
|
||||
mp.set_property_native("pause", false)
|
||||
end
|
||||
end
|
||||
@@ -124,17 +131,26 @@ function M.create(ctx)
|
||||
if not state.auto_play_ready_gate_armed then
|
||||
return
|
||||
end
|
||||
local should_resume_playback = state.auto_play_ready_should_resume_playback == true
|
||||
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||
mp.set_property_native("pause", false)
|
||||
show_osd(AUTO_PLAY_READY_READY_OSD)
|
||||
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
|
||||
if should_resume_playback then
|
||||
mp.set_property_native("pause", false)
|
||||
subminer_log("info", "process", "Resuming playback after startup gate: " .. tostring(reason or "ready"))
|
||||
else
|
||||
subminer_log("info", "process", "Startup gate ready; leaving playback paused: " .. tostring(reason or "ready"))
|
||||
end
|
||||
end
|
||||
|
||||
local function arm_auto_play_ready_gate()
|
||||
if state.auto_play_ready_gate_armed then
|
||||
local was_armed = state.auto_play_ready_gate_armed
|
||||
if was_armed then
|
||||
clear_auto_play_ready_timeout()
|
||||
clear_auto_play_ready_osd_timer()
|
||||
end
|
||||
if not was_armed then
|
||||
state.auto_play_ready_should_resume_playback = mp.get_property_native("pause") ~= true
|
||||
end
|
||||
state.auto_play_ready_gate_armed = true
|
||||
mp.set_property_native("pause", true)
|
||||
show_osd(AUTO_PLAY_READY_LOADING_OSD)
|
||||
@@ -164,10 +180,15 @@ function M.create(ctx)
|
||||
|
||||
local function notify_auto_play_ready()
|
||||
release_auto_play_ready_gate("tokenization-ready")
|
||||
if state.suppress_ready_overlay_restore then
|
||||
local force_ready_overlay_restore = state.force_ready_overlay_restore == true
|
||||
state.force_ready_overlay_restore = false
|
||||
if state.suppress_ready_overlay_restore and not force_ready_overlay_restore then
|
||||
return
|
||||
end
|
||||
if state.overlay_running and resolve_visible_overlay_startup() then
|
||||
if force_ready_overlay_restore then
|
||||
state.suppress_ready_overlay_restore = false
|
||||
end
|
||||
if state.overlay_running and (force_ready_overlay_restore or resolve_visible_overlay_startup()) then
|
||||
run_control_command_async("show-visible-overlay", {
|
||||
socket_path = opts.socket_path,
|
||||
})
|
||||
@@ -199,7 +220,10 @@ function M.create(ctx)
|
||||
table.insert(args, "--socket")
|
||||
table.insert(args, socket_path)
|
||||
|
||||
local should_show_visible = resolve_visible_overlay_startup()
|
||||
local should_show_visible = overrides.show_visible_overlay
|
||||
if should_show_visible == nil then
|
||||
should_show_visible = resolve_visible_overlay_startup()
|
||||
end
|
||||
if should_show_visible then
|
||||
table.insert(args, "--show-visible-overlay")
|
||||
else
|
||||
@@ -215,12 +239,75 @@ function M.create(ctx)
|
||||
return args
|
||||
end
|
||||
|
||||
local function is_appimage_binary(path)
|
||||
return environment.is_linux() and type(path) == "string" and path:lower():match("%.appimage$") ~= nil
|
||||
end
|
||||
|
||||
local function append_transport_env(env, args)
|
||||
local count = math.max(#args - 1, 0)
|
||||
env[#env + 1] = APP_ARGC_ENV .. "=" .. tostring(count)
|
||||
for index = 2, #args do
|
||||
env[#env + 1] = APP_ARG_PREFIX .. tostring(index - 2) .. "=" .. tostring(args[index])
|
||||
end
|
||||
end
|
||||
|
||||
local function env_has_name(env, name)
|
||||
local prefix = name .. "="
|
||||
for _, value in ipairs(env) do
|
||||
if type(value) == "string" and value:sub(1, #prefix) == prefix then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function append_default_app_log_env(env)
|
||||
local log_dir = environment.join_path(environment.resolve_subminer_config_dir(), "logs")
|
||||
local date = os.date("%Y-%m-%d")
|
||||
if not env_has_name(env, "SUBMINER_APP_LOG") then
|
||||
env[#env + 1] = "SUBMINER_APP_LOG=" .. environment.join_path(log_dir, "app-" .. date .. ".log")
|
||||
end
|
||||
if not env_has_name(env, "SUBMINER_MPV_LOG") then
|
||||
env[#env + 1] = "SUBMINER_MPV_LOG=" .. environment.join_path(log_dir, "mpv-" .. date .. ".log")
|
||||
end
|
||||
end
|
||||
|
||||
local function build_appimage_subprocess_env(args)
|
||||
local env = {}
|
||||
if utils and type(utils.get_env_list) == "function" then
|
||||
for _, value in ipairs(utils.get_env_list()) do
|
||||
if
|
||||
type(value) == "string"
|
||||
and not value:match("^" .. APP_ARGC_ENV .. "=")
|
||||
and not value:match("^" .. APP_ARG_PREFIX .. "%d+=")
|
||||
then
|
||||
env[#env + 1] = value
|
||||
end
|
||||
end
|
||||
end
|
||||
append_default_app_log_env(env)
|
||||
append_transport_env(env, args)
|
||||
return env
|
||||
end
|
||||
|
||||
local function build_subprocess_command(args)
|
||||
if is_appimage_binary(args[1]) then
|
||||
return {
|
||||
args = { args[1] },
|
||||
env = build_appimage_subprocess_env(args),
|
||||
}
|
||||
end
|
||||
return { args = args }
|
||||
end
|
||||
|
||||
run_control_command_async = function(action, overrides, callback)
|
||||
local args = build_command_args(action, overrides)
|
||||
local command = build_subprocess_command(args)
|
||||
subminer_log("debug", "process", "Control command: " .. table.concat(args, " "))
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
args = args,
|
||||
args = command.args,
|
||||
env = command.env,
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
@@ -232,11 +319,36 @@ function M.create(ctx)
|
||||
end)
|
||||
end
|
||||
|
||||
local function wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt)
|
||||
attempt = attempt or 1
|
||||
run_control_command_async("app-ping", nil, function(_ok, result)
|
||||
local status = result and result.status
|
||||
local is_running = status == 0
|
||||
local is_not_running = status == 1
|
||||
if (expected_running and is_running) or ((not expected_running) and is_not_running) then
|
||||
on_ready()
|
||||
return
|
||||
end
|
||||
if attempt >= OVERLAY_RESTART_PING_MAX_ATTEMPTS then
|
||||
subminer_log("warn", "process", "Timed out waiting for SubMiner app to " .. label)
|
||||
if on_timeout then
|
||||
on_timeout()
|
||||
end
|
||||
return
|
||||
end
|
||||
mp.add_timeout(OVERLAY_RESTART_PING_RETRY_DELAY_SECONDS, function()
|
||||
wait_for_app_ping_state(expected_running, label, on_ready, on_timeout, attempt + 1)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
local function run_binary_command_async(args, callback)
|
||||
local command = build_subprocess_command(args)
|
||||
subminer_log("debug", "process", "Binary command: " .. table.concat(args, " "))
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
args = args,
|
||||
args = command.args,
|
||||
env = command.env,
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
@@ -347,9 +459,11 @@ function M.create(ctx)
|
||||
end
|
||||
state.overlay_running = true
|
||||
|
||||
local command = build_subprocess_command(args)
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
args = args,
|
||||
args = command.args,
|
||||
env = command.env,
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
@@ -501,38 +615,61 @@ function M.create(ctx)
|
||||
subminer_log("info", "process", "Restarting overlay...")
|
||||
show_osd("Restarting...")
|
||||
|
||||
run_control_command_async("stop", nil, function()
|
||||
run_control_command_async("stop", nil, function(ok, result)
|
||||
if not ok then
|
||||
local reason = result and result.stderr or "unknown error"
|
||||
subminer_log("warn", "process", "Restart stop command failed: " .. reason)
|
||||
show_osd("Restart failed")
|
||||
return
|
||||
end
|
||||
|
||||
state.overlay_running = false
|
||||
state.texthooker_running = false
|
||||
disarm_auto_play_ready_gate()
|
||||
state.suppress_ready_overlay_restore = false
|
||||
state.force_ready_overlay_restore = true
|
||||
disarm_auto_play_ready_gate({ resume_playback = false })
|
||||
|
||||
local start_args = build_command_args("start")
|
||||
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
|
||||
wait_for_app_ping_state(false, "release the single-instance lock", function()
|
||||
local start_args = build_command_args("start", {
|
||||
show_visible_overlay = true,
|
||||
})
|
||||
subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " "))
|
||||
|
||||
state.overlay_running = true
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
args = start_args,
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
}, function(success, result, error)
|
||||
if not success or (result and result.status ~= 0) then
|
||||
state.overlay_running = false
|
||||
subminer_log(
|
||||
"error",
|
||||
"process",
|
||||
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
|
||||
)
|
||||
show_osd("Restart failed")
|
||||
else
|
||||
show_osd("Restarted successfully")
|
||||
state.overlay_running = true
|
||||
local command = build_subprocess_command(start_args)
|
||||
mp.command_native_async({
|
||||
name = "subprocess",
|
||||
args = command.args,
|
||||
env = command.env,
|
||||
playback_only = false,
|
||||
capture_stdout = true,
|
||||
capture_stderr = true,
|
||||
}, function(success, result, error)
|
||||
if not success or (result and result.status ~= 0) then
|
||||
state.overlay_running = false
|
||||
subminer_log(
|
||||
"error",
|
||||
"process",
|
||||
"Overlay start failed: " .. (error or (result and result.stderr) or "unknown error")
|
||||
)
|
||||
show_osd("Restart failed")
|
||||
else
|
||||
wait_for_app_ping_state(true, "own the single-instance lock", function()
|
||||
run_control_command_async("show-visible-overlay")
|
||||
show_osd("Restarted successfully")
|
||||
end, function()
|
||||
run_control_command_async("show-visible-overlay")
|
||||
show_osd("Restarted successfully")
|
||||
end)
|
||||
end
|
||||
end)
|
||||
|
||||
if resolve_texthooker_enabled(nil) then
|
||||
ensure_texthooker_running(function() end)
|
||||
end
|
||||
end, function()
|
||||
show_osd("Restart failed")
|
||||
end)
|
||||
|
||||
if resolve_texthooker_enabled(nil) then
|
||||
ensure_texthooker_running(function() end)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
@@ -30,9 +30,11 @@ function M.new()
|
||||
prompt_shown = false,
|
||||
},
|
||||
auto_play_ready_gate_armed = false,
|
||||
auto_play_ready_should_resume_playback = false,
|
||||
auto_play_ready_timeout = nil,
|
||||
auto_play_ready_osd_timer = nil,
|
||||
suppress_ready_overlay_restore = false,
|
||||
force_ready_overlay_restore = false,
|
||||
current_media_identity = nil,
|
||||
pending_reload_media_identity = nil,
|
||||
session_binding_generation = 0,
|
||||
|
||||
+13
-13
@@ -3,33 +3,33 @@
|
||||
## Highlights
|
||||
### Added
|
||||
|
||||
**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`. 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.
|
||||
|
||||
**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:** 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.
|
||||
|
||||
### 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 also 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 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 also fixed so mpv controls stay clickable before hovering a subtitle bar. Background tracking overhead is reduced while mpv is stably focused.
|
||||
|
||||
**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.
|
||||
|
||||
**Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults.
|
||||
- **Controller:** Controller config and debug shortcuts now stay closed while controller support is disabled, with a notice to enable `controller.enabled`. Learn mode can be entered from the edit pencil or binding badge, remaps are saved per controller profile, and individual bindings can be reset to their defaults.
|
||||
|
||||
**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.
|
||||
- **Character Dictionary:** Cached media matches are reused when loading a title with an existing snapshot, avoiding redundant AniList search requests on repeat visits.
|
||||
|
||||
**Updater — Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists.
|
||||
- **Updater - Linux:** The tray app now uses GitHub release metadata for update checks instead of the native Electron updater, preventing crashes. `subminer -u` performs updates independently of any running tray instance and correctly reports "up to date" without downloading assets when no newer release exists.
|
||||
|
||||
**Updater — macOS:** Update dialogs now come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native updater path without triggering premature Squirrel install checks.
|
||||
- **Updater - macOS:** Update dialogs now come to the front when launched from `subminer --update`. Builds that cannot install native updates show a manual-install message instead of an inapplicable restart prompt. Signed macOS builds remain on the native updater path without triggering premature Squirrel install checks.
|
||||
|
||||
**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, returning control to the terminal.
|
||||
- **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, returning control to the terminal.
|
||||
|
||||
**Launcher — Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
|
||||
- **Launcher - Linux:** First-run launcher installs are now built with a valid Bun shebang, fixing installs that previously failed silently.
|
||||
|
||||
**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.
|
||||
- **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.
|
||||
|
||||
**Build — Linux Install:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
|
||||
- **Build - Linux Install:** Fixed one-shot `make clean build install` flows so the install step correctly picks up the AppImage produced earlier in the same make invocation.
|
||||
|
||||
## Installation
|
||||
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
cpSync,
|
||||
existsSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
readlinkSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
collectSharedAssetPaths,
|
||||
dedupeVersionedPublicAssets,
|
||||
pruneArchiveCacheGenerations,
|
||||
} from './docs-versioned-assets';
|
||||
import {
|
||||
buildVersionManifest,
|
||||
stableTagsWithDocs,
|
||||
versionArchiveCacheKey,
|
||||
versionArchiveCacheName,
|
||||
versionOutputPath,
|
||||
versionPath,
|
||||
} from './docs-versioning';
|
||||
|
||||
const repoRoot = resolve(__dirname, '..');
|
||||
const currentDocsSite = join(repoRoot, 'docs-site');
|
||||
const buildRoot = join(repoRoot, '.tmp/docs-versioned-build');
|
||||
const aggregateOutDir = join(repoRoot, '.tmp/docs-versioned-site');
|
||||
const archiveCacheRoot = join(repoRoot, '.tmp/docs-versioned-archive-cache');
|
||||
const maxCloudflareFiles = 20_000;
|
||||
const maxCloudflareFileBytes = 25 * 1024 * 1024;
|
||||
|
||||
function run(
|
||||
command: string,
|
||||
args: string[],
|
||||
options: { cwd?: string; env?: NodeJS.ProcessEnv } = {},
|
||||
) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: options.cwd ?? repoRoot,
|
||||
env: options.env ?? process.env,
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Command failed: ${command} ${args.join(' ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function capture(command: string, args: string[]): string {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || `Command failed: ${command} ${args.join(' ')}`);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function archiveDocsSite(ref: string, targetDir: string) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
const archive = spawnSync('git', ['archive', '--format=tar', ref, 'docs-site'], {
|
||||
cwd: repoRoot,
|
||||
encoding: 'buffer',
|
||||
maxBuffer: 1024 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (archive.status !== 0 || !archive.stdout) {
|
||||
throw new Error(`Unable to archive docs-site from ${ref}`);
|
||||
}
|
||||
|
||||
const extract = spawnSync('tar', ['-x', '-C', targetDir], {
|
||||
input: archive.stdout,
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
});
|
||||
|
||||
if (extract.status !== 0) {
|
||||
throw new Error(`Unable to extract docs-site archive from ${ref}`);
|
||||
}
|
||||
}
|
||||
|
||||
function copyCurrentDocsSite(targetDir: string) {
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
cpSync(currentDocsSite, join(targetDir, 'docs-site'), {
|
||||
recursive: true,
|
||||
dereference: false,
|
||||
filter: (source) =>
|
||||
!/[\\/]node_modules([\\/]|$)/.test(source) &&
|
||||
!/[\\/]\\.vitepress[\\/]dist([\\/]|$)/.test(source),
|
||||
});
|
||||
}
|
||||
|
||||
function overlayCurrentVitePress(snapshotDocsSite: string) {
|
||||
const targetVitePress = join(snapshotDocsSite, '.vitepress');
|
||||
rmSync(targetVitePress, { recursive: true, force: true });
|
||||
cpSync(join(currentDocsSite, '.vitepress'), targetVitePress, {
|
||||
recursive: true,
|
||||
filter: (source) => !isGeneratedVitePressPath(source),
|
||||
});
|
||||
|
||||
const currentThemeFonts = join(currentDocsSite, 'public/assets/fonts');
|
||||
if (existsSync(currentThemeFonts)) {
|
||||
cpSync(currentThemeFonts, join(snapshotDocsSite, 'public/assets/fonts'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function linkDocsDependencies(snapshotDocsSite: string) {
|
||||
const currentNodeModules = join(currentDocsSite, 'node_modules');
|
||||
const targetNodeModules = join(snapshotDocsSite, 'node_modules');
|
||||
|
||||
if (!existsSync(currentNodeModules) || existsSync(targetNodeModules)) {
|
||||
return;
|
||||
}
|
||||
|
||||
symlinkSync(currentNodeModules, targetNodeModules, 'dir');
|
||||
}
|
||||
|
||||
function prepareSnapshot(name: string, ref?: string): string {
|
||||
const snapshotRoot = join(buildRoot, name);
|
||||
rmSync(snapshotRoot, { recursive: true, force: true });
|
||||
|
||||
if (ref) {
|
||||
archiveDocsSite(ref, snapshotRoot);
|
||||
} else {
|
||||
copyCurrentDocsSite(snapshotRoot);
|
||||
}
|
||||
|
||||
const snapshotDocsSite = join(snapshotRoot, 'docs-site');
|
||||
overlayCurrentVitePress(snapshotDocsSite);
|
||||
linkDocsDependencies(snapshotDocsSite);
|
||||
return snapshotDocsSite;
|
||||
}
|
||||
|
||||
function tagHasDocsSite(tag: string): boolean {
|
||||
const result = spawnSync('git', ['cat-file', '-e', `${tag}:docs-site/package.json`], {
|
||||
cwd: repoRoot,
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
function getStableVersions(): string[] {
|
||||
const tags = capture('git', ['tag', '--list', 'v*'])
|
||||
.split('\n')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
return stableTagsWithDocs(tags, tagHasDocsSite);
|
||||
}
|
||||
|
||||
function buildDocs(options: {
|
||||
snapshotDocsSite: string;
|
||||
base: string;
|
||||
outDir: string;
|
||||
channel: string;
|
||||
version?: string;
|
||||
latestStable: string;
|
||||
manifestJson: string;
|
||||
}) {
|
||||
console.info(`[docs] building ${options.version ?? options.channel} -> ${options.base}`);
|
||||
run('bun', ['run', '--cwd', currentDocsSite, 'vitepress', 'build', options.snapshotDocsSite], {
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
SUBMINER_DOCS_BASE: options.base,
|
||||
SUBMINER_DOCS_OUT_DIR: options.outDir,
|
||||
SUBMINER_DOCS_SOURCE_DIR: options.snapshotDocsSite,
|
||||
SUBMINER_DOCS_CHANNEL: options.channel,
|
||||
SUBMINER_DOCS_VERSION: options.version ?? '',
|
||||
SUBMINER_DOCS_LATEST_STABLE: options.latestStable,
|
||||
SUBMINER_DOCS_VERSION_MANIFEST: options.manifestJson,
|
||||
VITE_EXTRA_EXTENSIONS: 'jsonc',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function updateHashWithPath(hash: ReturnType<typeof createHash>, path: string) {
|
||||
if (isSharedInternalsHashIgnoredPath(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const stat = lstatSync(path);
|
||||
const relativePath = path.replace(repoRoot, '');
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
hash.update(`symlink:${relativePath}`);
|
||||
hash.update(readlinkSync(path));
|
||||
return;
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
hash.update(`dir:${relativePath}`);
|
||||
for (const entry of readdirSync(path).sort()) {
|
||||
updateHashWithPath(hash, join(path, entry));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
hash.update(`file:${relativePath}`);
|
||||
hash.update(readFileSync(path));
|
||||
}
|
||||
|
||||
function isGeneratedVitePressPath(path: string): boolean {
|
||||
return /[\\/]\\.vitepress[\\/](cache|dist)([\\/]|$)/.test(path);
|
||||
}
|
||||
|
||||
function isSharedInternalsHashIgnoredPath(path: string): boolean {
|
||||
return isGeneratedVitePressPath(path) || /\.test\.[cm]?[jt]s$/.test(path);
|
||||
}
|
||||
|
||||
function computeSharedInternalsHash(): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update(
|
||||
`version-link-origin:${process.env.SUBMINER_DOCS_VERSION_LINK_ORIGIN === 'local' ? 'local' : 'production'}`,
|
||||
);
|
||||
const paths = [
|
||||
join(currentDocsSite, '.vitepress'),
|
||||
join(currentDocsSite, 'public/assets/fonts'),
|
||||
join(currentDocsSite, 'package.json'),
|
||||
join(currentDocsSite, 'bun.lock'),
|
||||
join(repoRoot, 'scripts/build-versioned-docs.ts'),
|
||||
join(repoRoot, 'scripts/docs-versioning.ts'),
|
||||
];
|
||||
|
||||
for (const path of paths) {
|
||||
if (existsSync(path)) {
|
||||
updateHashWithPath(hash, path);
|
||||
}
|
||||
}
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
function archiveCachePath(version: string, sharedInternalsHash: string): string {
|
||||
return join(archiveCacheRoot, versionArchiveCacheName(version, sharedInternalsHash));
|
||||
}
|
||||
|
||||
function restoreCachedArchive(version: string, sharedInternalsHash: string): boolean {
|
||||
const cachedArchive = archiveCachePath(version, sharedInternalsHash);
|
||||
if (!existsSync(cachedArchive)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
console.info(`[docs] cache hit ${version}`);
|
||||
cpSync(cachedArchive, join(aggregateOutDir, versionOutputPath(version)), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function saveArchiveCache(version: string, sharedInternalsHash: string) {
|
||||
const outputPath = join(aggregateOutDir, versionOutputPath(version));
|
||||
if (!existsSync(outputPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cachedArchive = archiveCachePath(version, sharedInternalsHash);
|
||||
rmSync(cachedArchive, { recursive: true, force: true });
|
||||
mkdirSync(archiveCacheRoot, { recursive: true });
|
||||
cpSync(outputPath, cachedArchive, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
function assertCloudflarePagesLimits(root: string) {
|
||||
let fileCount = 0;
|
||||
const oversizedFiles: string[] = [];
|
||||
|
||||
function walk(dir: string) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
walk(path);
|
||||
continue;
|
||||
}
|
||||
|
||||
fileCount += 1;
|
||||
if (stat.size > maxCloudflareFileBytes) {
|
||||
oversizedFiles.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
|
||||
if (fileCount > maxCloudflareFiles) {
|
||||
throw new Error(
|
||||
`Versioned docs output has ${fileCount} files; Cloudflare Pages free plan limit is ${maxCloudflareFiles}.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (oversizedFiles.length > 0) {
|
||||
throw new Error(`Versioned docs output has files over 25 MiB:\n${oversizedFiles.join('\n')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function main() {
|
||||
const stableVersions = getStableVersions();
|
||||
const latestStable = stableVersions[0];
|
||||
|
||||
if (!latestStable) {
|
||||
throw new Error('No stable release tags with docs-site/package.json found.');
|
||||
}
|
||||
|
||||
const manifest = buildVersionManifest({ latestStable, stableVersions });
|
||||
const manifestJson = JSON.stringify(manifest);
|
||||
const sharedInternalsHash = computeSharedInternalsHash();
|
||||
const archiveCacheKey = versionArchiveCacheKey({ sharedInternalsHash, manifestJson });
|
||||
const sharedAssetPaths = collectSharedAssetPaths(join(currentDocsSite, 'public/assets'));
|
||||
console.info(`[docs] archive cache key ${archiveCacheKey.slice(0, 12)}`);
|
||||
|
||||
rmSync(buildRoot, { recursive: true, force: true });
|
||||
rmSync(aggregateOutDir, { recursive: true, force: true });
|
||||
mkdirSync(buildRoot, { recursive: true });
|
||||
mkdirSync(aggregateOutDir, { recursive: true });
|
||||
|
||||
const latestStableSnapshot = prepareSnapshot(latestStable, latestStable);
|
||||
buildDocs({
|
||||
snapshotDocsSite: latestStableSnapshot,
|
||||
base: '/',
|
||||
outDir: aggregateOutDir,
|
||||
channel: 'stable-root',
|
||||
version: latestStable,
|
||||
latestStable,
|
||||
manifestJson,
|
||||
});
|
||||
|
||||
for (const version of stableVersions) {
|
||||
if (restoreCachedArchive(version, archiveCacheKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
console.info(`[docs] rebuilding archive ${version}`);
|
||||
const snapshot =
|
||||
version === latestStable ? latestStableSnapshot : prepareSnapshot(version, version);
|
||||
buildDocs({
|
||||
snapshotDocsSite: snapshot,
|
||||
base: versionPath(version),
|
||||
outDir: join(aggregateOutDir, versionOutputPath(version)),
|
||||
channel: 'stable-archive',
|
||||
version,
|
||||
latestStable,
|
||||
manifestJson,
|
||||
});
|
||||
dedupeVersionedPublicAssets({
|
||||
outDir: join(aggregateOutDir, versionOutputPath(version)),
|
||||
base: versionPath(version),
|
||||
sharedAssetPaths,
|
||||
});
|
||||
saveArchiveCache(version, archiveCacheKey);
|
||||
}
|
||||
|
||||
const mainSnapshot = prepareSnapshot('main');
|
||||
buildDocs({
|
||||
snapshotDocsSite: mainSnapshot,
|
||||
base: '/main/',
|
||||
outDir: join(aggregateOutDir, 'main'),
|
||||
channel: 'main',
|
||||
version: 'main',
|
||||
latestStable,
|
||||
manifestJson,
|
||||
});
|
||||
dedupeVersionedPublicAssets({
|
||||
outDir: join(aggregateOutDir, 'main'),
|
||||
base: '/main/',
|
||||
sharedAssetPaths,
|
||||
});
|
||||
|
||||
writeFileSync(join(aggregateOutDir, 'versions.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
assertCloudflarePagesLimits(aggregateOutDir);
|
||||
const prunedArchives = pruneArchiveCacheGenerations({
|
||||
cacheRoot: archiveCacheRoot,
|
||||
activeCacheKey: archiveCacheKey,
|
||||
});
|
||||
if (prunedArchives.length > 0) {
|
||||
console.info(`[docs] pruned ${prunedArchives.length} stale archive cache directories`);
|
||||
}
|
||||
rmSync(buildRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
dedupeVersionedPublicAssets,
|
||||
pruneArchiveCacheGenerations,
|
||||
rewriteSharedAssetReferences,
|
||||
} from './docs-versioned-assets';
|
||||
|
||||
function tempDir() {
|
||||
return mkdtempSync(join(tmpdir(), 'subminer-docs-versioned-assets-'));
|
||||
}
|
||||
|
||||
describe('docs versioned asset dedupe', () => {
|
||||
test('rewrites version-scoped public asset references to shared root assets', () => {
|
||||
const html =
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/v/0.14.0/assets/minecard.webm">';
|
||||
const expected =
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/assets/minecard.webm">';
|
||||
|
||||
expect(rewriteSharedAssetReferences(html, '/v/0.14.0/', new Set(['minecard.webm']))).toBe(
|
||||
expected,
|
||||
);
|
||||
expect(rewriteSharedAssetReferences(html, '/v/0.14.0', new Set(['minecard.webm']))).toBe(
|
||||
expected,
|
||||
);
|
||||
});
|
||||
|
||||
test('does not rewrite longer asset paths with a shared asset prefix', () => {
|
||||
expect(
|
||||
rewriteSharedAssetReferences(
|
||||
[
|
||||
'<script src="/v/0.14.0/assets/foo.js"></script>',
|
||||
'<script src="/v/0.14.0/assets/foo.js?v=1"></script>',
|
||||
'<script src="/v/0.14.0/assets/foo.js.map"></script>',
|
||||
].join(''),
|
||||
'/v/0.14.0/',
|
||||
new Set(['foo.js']),
|
||||
),
|
||||
).toBe(
|
||||
[
|
||||
'<script src="/assets/foo.js"></script>',
|
||||
'<script src="/assets/foo.js?v=1"></script>',
|
||||
'<script src="/v/0.14.0/assets/foo.js.map"></script>',
|
||||
].join(''),
|
||||
);
|
||||
});
|
||||
|
||||
test('removes duplicated version public assets while preserving generated VitePress assets', async () => {
|
||||
const dir = tempDir();
|
||||
try {
|
||||
mkdirSync(join(dir, 'assets/chunks'), { recursive: true });
|
||||
writeFileSync(join(dir, 'assets/style.hash.css'), 'body{}');
|
||||
writeFileSync(join(dir, 'assets/chunks/theme.hash.js'), 'export {};');
|
||||
writeFileSync(join(dir, 'assets/minecard.webm'), 'large video');
|
||||
writeFileSync(
|
||||
join(dir, 'index.html'),
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/v/0.14.0/assets/minecard.webm">',
|
||||
);
|
||||
|
||||
const result = dedupeVersionedPublicAssets({
|
||||
outDir: dir,
|
||||
base: '/v/0.14.0',
|
||||
sharedAssetPaths: new Set(['minecard.webm']),
|
||||
});
|
||||
|
||||
expect(result.rewrittenFiles).toEqual([join(dir, 'index.html')]);
|
||||
expect(existsSync(join(dir, 'assets/style.hash.css'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'assets/chunks/theme.hash.js'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'assets/minecard.webm'))).toBe(false);
|
||||
expect(readFileSync(join(dir, 'index.html'), 'utf8')).toBe(
|
||||
'<link href="/v/0.14.0/assets/style.hash.css"><source src="/assets/minecard.webm">',
|
||||
);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('keeps root public assets because they are the shared copy', async () => {
|
||||
const dir = tempDir();
|
||||
try {
|
||||
mkdirSync(join(dir, 'assets'), { recursive: true });
|
||||
writeFileSync(join(dir, 'assets/minecard.webm'), 'large video');
|
||||
|
||||
const result = dedupeVersionedPublicAssets({
|
||||
outDir: dir,
|
||||
base: '/',
|
||||
sharedAssetPaths: new Set(['minecard.webm']),
|
||||
});
|
||||
|
||||
expect(result.removedAssetsDir).toBe(false);
|
||||
expect(existsSync(join(dir, 'assets'))).toBe(true);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('docs archive cache pruning', () => {
|
||||
test('removes stale cache generations while keeping the active generation', async () => {
|
||||
const dir = tempDir();
|
||||
try {
|
||||
mkdirSync(join(dir, 'active123456-v0.14.0'), { recursive: true });
|
||||
mkdirSync(join(dir, 'stale654321-v0.14.0'), { recursive: true });
|
||||
mkdirSync(join(dir, 'stale654321-v0.13.0'), { recursive: true });
|
||||
|
||||
const removed = pruneArchiveCacheGenerations({
|
||||
cacheRoot: dir,
|
||||
activeCacheKey: 'active123456abcdef',
|
||||
});
|
||||
|
||||
expect(removed.sort()).toEqual([
|
||||
join(dir, 'stale654321-v0.13.0'),
|
||||
join(dir, 'stale654321-v0.14.0'),
|
||||
]);
|
||||
expect(existsSync(join(dir, 'active123456-v0.14.0'))).toBe(true);
|
||||
expect(existsSync(join(dir, 'stale654321-v0.14.0'))).toBe(false);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,148 @@
|
||||
import { existsSync, lstatSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
|
||||
const textOutputPattern = /\.(?:css|html|js|json|map|mjs|txt|xml)$/;
|
||||
|
||||
function normalizeBase(base: string): string {
|
||||
return base === '/' ? '/' : `${base.replace(/\/+$/, '')}/`;
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function collectSharedAssetPaths(publicAssetsDir: string): Set<string> {
|
||||
if (!existsSync(publicAssetsDir)) {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
return new Set(
|
||||
walkFiles(publicAssetsDir).map((file) => relative(publicAssetsDir, file).split('\\').join('/')),
|
||||
);
|
||||
}
|
||||
|
||||
export function rewriteSharedAssetReferences(
|
||||
content: string,
|
||||
base: string,
|
||||
sharedAssetPaths: Set<string>,
|
||||
): string {
|
||||
const normalizedBase = normalizeBase(base);
|
||||
if (normalizedBase === '/') {
|
||||
return content;
|
||||
}
|
||||
|
||||
let rewritten = content;
|
||||
const escapedBase = escapeRegExp(normalizedBase);
|
||||
for (const assetPath of sharedAssetPaths) {
|
||||
const escapedAssetPath = escapeRegExp(assetPath);
|
||||
rewritten = rewritten.replace(
|
||||
new RegExp(`${escapedBase}assets/${escapedAssetPath}(?=$|[?#"'()\\s<])`, 'g'),
|
||||
`/assets/${assetPath}`,
|
||||
);
|
||||
}
|
||||
return rewritten;
|
||||
}
|
||||
|
||||
function walkFiles(root: string): string[] {
|
||||
const files: string[] = [];
|
||||
|
||||
function walk(dir: string) {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const path = join(dir, entry);
|
||||
const stat = lstatSync(path);
|
||||
if (stat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
walk(path);
|
||||
continue;
|
||||
}
|
||||
files.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
walk(root);
|
||||
return files;
|
||||
}
|
||||
|
||||
export function dedupeVersionedPublicAssets(options: {
|
||||
outDir: string;
|
||||
base: string;
|
||||
sharedAssetPaths: Set<string>;
|
||||
}): {
|
||||
removedAssetsDir: boolean;
|
||||
rewrittenFiles: string[];
|
||||
} {
|
||||
const normalizedBase = normalizeBase(options.base);
|
||||
const rewrittenFiles: string[] = [];
|
||||
|
||||
for (const file of walkFiles(options.outDir)) {
|
||||
if (!textOutputPattern.test(file)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const before = readFileSync(file, 'utf8');
|
||||
const after = rewriteSharedAssetReferences(before, normalizedBase, options.sharedAssetPaths);
|
||||
if (after === before) {
|
||||
continue;
|
||||
}
|
||||
|
||||
writeFileSync(file, after);
|
||||
rewrittenFiles.push(file);
|
||||
}
|
||||
|
||||
const assetsDir = join(options.outDir, 'assets');
|
||||
if (normalizedBase !== '/') {
|
||||
for (const assetPath of options.sharedAssetPaths) {
|
||||
rmSync(join(assetsDir, assetPath), { force: true });
|
||||
}
|
||||
removeEmptyDirectories(assetsDir);
|
||||
}
|
||||
|
||||
const removedAssetsDir = !existsSync(assetsDir);
|
||||
return { removedAssetsDir, rewrittenFiles };
|
||||
}
|
||||
|
||||
function removeEmptyDirectories(root: string) {
|
||||
if (!existsSync(root) || !lstatSync(root).isDirectory()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of readdirSync(root)) {
|
||||
const path = join(root, entry);
|
||||
if (lstatSync(path).isDirectory()) {
|
||||
removeEmptyDirectories(path);
|
||||
}
|
||||
}
|
||||
|
||||
if (readdirSync(root).length === 0) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function pruneArchiveCacheGenerations(options: {
|
||||
cacheRoot: string;
|
||||
activeCacheKey: string;
|
||||
}): string[] {
|
||||
if (!existsSync(options.cacheRoot)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const activePrefix = options.activeCacheKey.slice(0, 12);
|
||||
const removed: string[] = [];
|
||||
|
||||
for (const entry of readdirSync(options.cacheRoot)) {
|
||||
const path = join(options.cacheRoot, entry);
|
||||
if (!lstatSync(path).isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
if (entry.startsWith(`${activePrefix}-`)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
rmSync(path, { recursive: true, force: true });
|
||||
removed.push(path);
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import {
|
||||
buildVersionManifest,
|
||||
compareStableVersionsDesc,
|
||||
versionArchiveCacheKey,
|
||||
isStableReleaseTag,
|
||||
stableTagsWithDocs,
|
||||
versionArchiveCacheName,
|
||||
versionOutputPath,
|
||||
versionPath,
|
||||
} from './docs-versioning';
|
||||
|
||||
describe('docs versioning helpers', () => {
|
||||
test('stable tag filtering excludes beta and rc tags', () => {
|
||||
expect(isStableReleaseTag('v0.14.0')).toBe(true);
|
||||
expect(isStableReleaseTag('v0.15.0-beta.3')).toBe(false);
|
||||
expect(isStableReleaseTag('v0.15.0-rc.1')).toBe(false);
|
||||
});
|
||||
|
||||
test('latest stable resolves to v0.14.0 when beta tags are present', () => {
|
||||
const tags = ['v0.13.0', 'v0.15.0-beta.3', 'v0.14.0'].sort(compareStableVersionsDesc);
|
||||
|
||||
expect(tags[0]).toBe('v0.14.0');
|
||||
});
|
||||
|
||||
test('tags before docs-site are skipped', () => {
|
||||
const tags = ['v0.12.0', 'v0.13.0', 'v0.14.0'];
|
||||
const hasDocsSite = (tag: string) => tag !== 'v0.12.0';
|
||||
|
||||
expect(stableTagsWithDocs(tags, hasDocsSite)).toEqual(['v0.14.0', 'v0.13.0']);
|
||||
});
|
||||
|
||||
test('version manifest paths are normalized', () => {
|
||||
expect(versionPath('v0.14.0')).toBe('/v/0.14.0/');
|
||||
expect(
|
||||
buildVersionManifest({
|
||||
latestStable: 'v0.14.0',
|
||||
stableVersions: ['v0.14.0'],
|
||||
}),
|
||||
).toEqual({
|
||||
latestStable: 'v0.14.0',
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: [{ version: 'v0.14.0', path: '/v/0.14.0/' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('archive cache names are normalized by version and shared internals hash', () => {
|
||||
expect(versionArchiveCacheName('v0.14.0', 'abcdef1234567890')).toBe('abcdef123456-v0.14.0');
|
||||
});
|
||||
|
||||
test('archive cache keys change when manifest contents change', () => {
|
||||
const firstKey = versionArchiveCacheKey({
|
||||
sharedInternalsHash: 'abcdef1234567890',
|
||||
manifestJson: '{"latestStable":"v0.14.0"}',
|
||||
});
|
||||
const secondKey = versionArchiveCacheKey({
|
||||
sharedInternalsHash: 'abcdef1234567890',
|
||||
manifestJson: '{"latestStable":"v0.15.0"}',
|
||||
});
|
||||
|
||||
expect(firstKey).not.toBe(secondKey);
|
||||
});
|
||||
|
||||
test('archive output paths stay relative for filesystem joins', () => {
|
||||
expect(versionOutputPath('v0.14.0')).toBe('v/0.14.0');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
export type DocsVersionEntry = {
|
||||
version: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type DocsChannelEntry = {
|
||||
label: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type DocsVersionManifest = {
|
||||
latestStable: string;
|
||||
channels: DocsChannelEntry[];
|
||||
versions: DocsVersionEntry[];
|
||||
};
|
||||
|
||||
const STABLE_TAG_PATTERN = /^v\d+\.\d+\.\d+$/;
|
||||
|
||||
export function isStableReleaseTag(tag: string): boolean {
|
||||
return STABLE_TAG_PATTERN.test(tag);
|
||||
}
|
||||
|
||||
function parseStableVersion(tag: string): [number, number, number] {
|
||||
const match = /^v(\d+)\.(\d+)\.(\d+)$/.exec(tag);
|
||||
if (!match) {
|
||||
throw new Error(`Invalid stable SubMiner version tag: ${tag}`);
|
||||
}
|
||||
|
||||
return [Number(match[1]), Number(match[2]), Number(match[3])];
|
||||
}
|
||||
|
||||
export function compareStableVersionsDesc(a: string, b: string): number {
|
||||
if (!isStableReleaseTag(a) && !isStableReleaseTag(b)) return a.localeCompare(b);
|
||||
if (!isStableReleaseTag(a)) return 1;
|
||||
if (!isStableReleaseTag(b)) return -1;
|
||||
|
||||
const parsedA = parseStableVersion(a);
|
||||
const parsedB = parseStableVersion(b);
|
||||
|
||||
for (let index = 0; index < parsedA.length; index += 1) {
|
||||
const difference = parsedB[index]! - parsedA[index]!;
|
||||
if (difference !== 0) return difference;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function versionPath(version: string): string {
|
||||
return `/v/${version.replace(/^v/, '')}/`;
|
||||
}
|
||||
|
||||
export function versionOutputPath(version: string): string {
|
||||
return `v/${version.replace(/^v/, '')}`;
|
||||
}
|
||||
|
||||
export function versionArchiveCacheName(version: string, sharedInternalsHash: string): string {
|
||||
return `${sharedInternalsHash.slice(0, 12)}-${version}`;
|
||||
}
|
||||
|
||||
export function versionArchiveCacheKey(options: {
|
||||
sharedInternalsHash: string;
|
||||
manifestJson: string;
|
||||
}): string {
|
||||
const hash = createHash('sha256');
|
||||
hash.update('shared-internals:');
|
||||
hash.update(options.sharedInternalsHash);
|
||||
hash.update('\nmanifest:');
|
||||
hash.update(options.manifestJson);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
export function stableTagsWithDocs(
|
||||
tags: string[],
|
||||
hasDocsSite: (tag: string) => boolean,
|
||||
): string[] {
|
||||
return tags.filter(isStableReleaseTag).filter(hasDocsSite).sort(compareStableVersionsDesc);
|
||||
}
|
||||
|
||||
export function buildVersionManifest(options: {
|
||||
latestStable: string;
|
||||
stableVersions: string[];
|
||||
}): DocsVersionManifest {
|
||||
return {
|
||||
latestStable: options.latestStable,
|
||||
channels: [
|
||||
{ label: 'Latest stable', path: '/' },
|
||||
{ label: 'main', path: '/main/' },
|
||||
],
|
||||
versions: options.stableVersions.map((version) => ({
|
||||
version,
|
||||
path: versionPath(version),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -7,6 +7,8 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const rendererSourceDir = path.join(repoRoot, 'src', 'renderer');
|
||||
const rendererOutputDir = path.join(repoRoot, 'dist', 'renderer');
|
||||
const settingsSourceDir = path.join(repoRoot, 'src', 'settings');
|
||||
const settingsOutputDir = path.join(repoRoot, 'dist', 'settings');
|
||||
const scriptsOutputDir = path.join(repoRoot, 'dist', 'scripts');
|
||||
const macosHelperSourcePath = path.join(scriptDir, 'get-mpv-window-macos.swift');
|
||||
const macosHelperBinaryPath = path.join(scriptsOutputDir, 'get-mpv-window-macos');
|
||||
@@ -21,14 +23,22 @@ function copyFile(sourcePath, outputPath) {
|
||||
fs.copyFileSync(sourcePath, outputPath);
|
||||
}
|
||||
|
||||
function copyRendererAssets() {
|
||||
copyFile(path.join(rendererSourceDir, 'index.html'), path.join(rendererOutputDir, 'index.html'));
|
||||
copyFile(path.join(rendererSourceDir, 'style.css'), path.join(rendererOutputDir, 'style.css'));
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(rendererOutputDir, 'fonts'), {
|
||||
function copyAssets(sourceDir, outputDir, label) {
|
||||
copyFile(path.join(sourceDir, 'index.html'), path.join(outputDir, 'index.html'));
|
||||
copyFile(path.join(sourceDir, 'style.css'), path.join(outputDir, 'style.css'));
|
||||
fs.cpSync(path.join(rendererSourceDir, 'fonts'), path.join(outputDir, 'fonts'), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
process.stdout.write(`Staged renderer assets in ${rendererOutputDir}\n`);
|
||||
process.stdout.write(`Staged ${label} assets in ${outputDir}\n`);
|
||||
}
|
||||
|
||||
function copyRendererAssets() {
|
||||
copyAssets(rendererSourceDir, rendererOutputDir, 'renderer');
|
||||
}
|
||||
|
||||
function copySettingsAssets() {
|
||||
copyAssets(settingsSourceDir, settingsOutputDir, 'settings');
|
||||
}
|
||||
|
||||
function fallbackToMacosSource() {
|
||||
@@ -70,6 +80,7 @@ function buildMacosHelper() {
|
||||
|
||||
function main() {
|
||||
copyRendererAssets();
|
||||
copySettingsAssets();
|
||||
buildMacosHelper();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { buildVersionManifest, stableTagsWithDocs } from './docs-versioning';
|
||||
|
||||
const repoRoot = resolve(__dirname, '..');
|
||||
|
||||
function capture(command: string, args: string[]): string {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: repoRoot,
|
||||
encoding: 'utf8',
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr || `Command failed: ${command} ${args.join(' ')}`);
|
||||
}
|
||||
|
||||
return result.stdout;
|
||||
}
|
||||
|
||||
function tagHasDocsSite(tag: string): boolean {
|
||||
const result = spawnSync('git', ['cat-file', '-e', `${tag}:docs-site/package.json`], {
|
||||
cwd: repoRoot,
|
||||
});
|
||||
return result.status === 0;
|
||||
}
|
||||
|
||||
const stableVersions = stableTagsWithDocs(
|
||||
capture('git', ['tag', '--list', 'v*'])
|
||||
.split('\n')
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
tagHasDocsSite,
|
||||
);
|
||||
|
||||
const latestStable = stableVersions[0];
|
||||
|
||||
if (!latestStable) {
|
||||
throw new Error('No stable release tags with docs-site/package.json found.');
|
||||
}
|
||||
|
||||
process.stdout.write(JSON.stringify(buildVersionManifest({ latestStable, stableVersions })));
|
||||
@@ -12,6 +12,7 @@ local function run_plugin_scenario(config)
|
||||
logs = {},
|
||||
property_sets = {},
|
||||
periodic_timers = {},
|
||||
timeouts = {},
|
||||
}
|
||||
|
||||
local function make_mp_stub()
|
||||
@@ -40,6 +41,9 @@ local function run_plugin_scenario(config)
|
||||
end
|
||||
|
||||
function mp.get_property_native(name)
|
||||
if name == "pause" then
|
||||
return config.paused == true
|
||||
end
|
||||
if name == "osd-dimensions" then
|
||||
return config.osd_dimensions or {
|
||||
w = 1280,
|
||||
@@ -108,11 +112,26 @@ local function run_plugin_scenario(config)
|
||||
return
|
||||
end
|
||||
end
|
||||
for _, value in ipairs(args) do
|
||||
if value == "--app-ping" then
|
||||
config.app_ping_index = (config.app_ping_index or 0) + 1
|
||||
local statuses = config.app_ping_statuses or { 1 }
|
||||
local status = statuses[config.app_ping_index] or statuses[#statuses] or 1
|
||||
callback(status == 0, { status = status, stdout = "", stderr = "" }, nil)
|
||||
return
|
||||
end
|
||||
if value == "--stop" and config.stop_command_fails then
|
||||
local stderr = config.stop_command_stderr or "stop failed"
|
||||
callback(false, { status = 1, stdout = "", stderr = stderr }, stderr)
|
||||
return
|
||||
end
|
||||
end
|
||||
callback(true, { status = 0, stdout = "", stderr = "" }, nil)
|
||||
end
|
||||
end
|
||||
|
||||
function mp.add_timeout(seconds, callback)
|
||||
recorded.timeouts[#recorded.timeouts + 1] = seconds
|
||||
local timeout = {
|
||||
killed = false,
|
||||
}
|
||||
@@ -185,6 +204,9 @@ local function run_plugin_scenario(config)
|
||||
name = name,
|
||||
value = value,
|
||||
}
|
||||
if name == "pause" then
|
||||
config.paused = value == true
|
||||
end
|
||||
end
|
||||
function mp.set_property(name, value)
|
||||
recorded.property_sets[#recorded.property_sets + 1] = {
|
||||
@@ -222,6 +244,10 @@ local function run_plugin_scenario(config)
|
||||
return table.concat(parts, "/")
|
||||
end
|
||||
|
||||
function utils.get_env_list()
|
||||
return config.env_list or {}
|
||||
end
|
||||
|
||||
function utils.parse_json(json)
|
||||
if json == '{"enabled":true,"amount":125}' then
|
||||
return {
|
||||
@@ -398,6 +424,29 @@ local function find_control_call(async_calls, flag)
|
||||
return nil
|
||||
end
|
||||
|
||||
local function find_nth_control_call(async_calls, flag, target_count)
|
||||
local count = 0
|
||||
for _, call in ipairs(async_calls) do
|
||||
local args = call.args or {}
|
||||
local has_flag = false
|
||||
local has_start = false
|
||||
for _, value in ipairs(args) do
|
||||
if value == flag then
|
||||
has_flag = true
|
||||
elseif value == "--start" then
|
||||
has_start = true
|
||||
end
|
||||
end
|
||||
if has_flag and not has_start then
|
||||
count = count + 1
|
||||
if count == target_count then
|
||||
return call
|
||||
end
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function count_control_calls(async_calls, flag)
|
||||
local count = 0
|
||||
for _, call in ipairs(async_calls) do
|
||||
@@ -503,6 +552,35 @@ local function count_osd_message(messages, target)
|
||||
return count
|
||||
end
|
||||
|
||||
local function has_timeout(timeouts, target)
|
||||
for _, seconds in ipairs(timeouts) do
|
||||
if math.abs(seconds - target) < 0.0001 then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function env_has(call, target)
|
||||
local env = (call and call.env) or {}
|
||||
for _, value in ipairs(env) do
|
||||
if value == target then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function env_has_prefix(call, target)
|
||||
local env = (call and call.env) or {}
|
||||
for _, value in ipairs(env) do
|
||||
if type(value) == "string" and value:sub(1, #target) == target then
|
||||
return true
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function count_property_set(property_sets, name, value)
|
||||
local count = 0
|
||||
for _, call in ipairs(property_sets) do
|
||||
@@ -537,6 +615,7 @@ local function has_key_binding(recorded, keys, name)
|
||||
end
|
||||
|
||||
local binary_path = "/tmp/subminer-binary"
|
||||
local appimage_path = "/tmp/SubMiner.AppImage"
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
@@ -562,6 +641,139 @@ end
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = appimage_path,
|
||||
auto_start = "no",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
files = {
|
||||
[appimage_path] = true,
|
||||
},
|
||||
env_list = {
|
||||
"PATH=/usr/bin",
|
||||
"SUBMINER_APP_ARGC=stale",
|
||||
"SUBMINER_APP_ARG_0=--stale",
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for AppImage env transport scenario: " .. tostring(err))
|
||||
recorded.script_messages["subminer-start"]("texthooker=no")
|
||||
local call = recorded.async_calls[#recorded.async_calls]
|
||||
assert_true(call ~= nil, "AppImage start should issue an async subprocess")
|
||||
assert_true(#call.args == 1 and call.args[1] == appimage_path, "AppImage subprocess should not receive raw CLI flags")
|
||||
assert_true(env_has(call, "PATH=/usr/bin"), "AppImage subprocess should preserve existing environment")
|
||||
assert_true(env_has(call, "SUBMINER_APP_ARGC=8"), "AppImage subprocess should transport app arg count")
|
||||
assert_true(env_has(call, "SUBMINER_APP_ARG_0=--start"), "AppImage subprocess should transport --start")
|
||||
assert_true(env_has(call, "SUBMINER_APP_ARG_1=--background"), "AppImage subprocess should transport --background")
|
||||
assert_true(env_has(call, "SUBMINER_APP_ARG_7=--hide-visible-overlay"), "AppImage subprocess should transport visibility flag")
|
||||
assert_true(env_has_prefix(call, "SUBMINER_APP_LOG="), "AppImage subprocess should include app log env")
|
||||
assert_true(env_has_prefix(call, "SUBMINER_MPV_LOG="), "AppImage subprocess should include mpv log env")
|
||||
assert_true(
|
||||
not env_has(call, "SUBMINER_APP_ARG_0=--stale"),
|
||||
"AppImage subprocess should remove stale transported args"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
app_ping_statuses = { 0, 1, 0 },
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
auto_start_visible_overlay = "no",
|
||||
},
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for manual visible restart scenario: " .. tostring(err))
|
||||
local restart_binding = nil
|
||||
for _, candidate in ipairs(recorded.key_bindings) do
|
||||
if candidate.name == "subminer-restart" then
|
||||
restart_binding = candidate
|
||||
break
|
||||
end
|
||||
end
|
||||
assert_true(restart_binding ~= nil, "restart binding should be registered")
|
||||
restart_binding.fn()
|
||||
local start_call = find_start_call(recorded.async_calls)
|
||||
assert_true(start_call ~= nil, "manual restart should issue --start command")
|
||||
local start_index = find_call_index(recorded.async_calls, start_call) or 0
|
||||
local old_app_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 1)
|
||||
local old_app_stopped_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 2)
|
||||
local new_app_started_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 3)
|
||||
assert_true(old_app_ping ~= nil, "manual restart should ping before waiting for old app shutdown")
|
||||
assert_true(old_app_stopped_ping ~= nil, "manual restart should keep pinging until old app shutdown")
|
||||
assert_true(new_app_started_ping ~= nil, "manual restart should ping after start until the new app is running")
|
||||
assert_true(
|
||||
(find_call_index(recorded.async_calls, old_app_ping) or 0) < start_index,
|
||||
"manual restart should wait for old app ping before starting"
|
||||
)
|
||||
assert_true(
|
||||
(find_call_index(recorded.async_calls, old_app_stopped_ping) or 0) < start_index,
|
||||
"manual restart should wait for old app stopped ping before starting"
|
||||
)
|
||||
assert_true(
|
||||
start_index < (find_call_index(recorded.async_calls, new_app_started_ping) or 0),
|
||||
"manual restart should wait for new app running ping after starting"
|
||||
)
|
||||
assert_true(
|
||||
call_has_arg(start_call, "--show-visible-overlay"),
|
||||
"manual restart should bring the visible overlay back after process reload"
|
||||
)
|
||||
assert_true(
|
||||
not call_has_arg(start_call, "--hide-visible-overlay"),
|
||||
"manual restart should not restart into hidden visible-overlay state"
|
||||
)
|
||||
assert_true(
|
||||
not has_timeout(recorded.timeouts, 0.5),
|
||||
"manual restart should use app-ping readiness instead of a fixed 0.5s start delay"
|
||||
)
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 1,
|
||||
"manual restart should re-assert visible overlay after the restarted app is launched"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
app_ping_statuses = { 0, 2, 1, 0 },
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
auto_start_visible_overlay = "no",
|
||||
},
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(
|
||||
recorded ~= nil,
|
||||
"plugin failed to load for transient app-ping failure restart scenario: " .. tostring(err)
|
||||
)
|
||||
recorded.script_messages["subminer-restart"]()
|
||||
local start_call = find_start_call(recorded.async_calls)
|
||||
assert_true(start_call ~= nil, "manual restart should start after app-ping reports stopped")
|
||||
local start_index = find_call_index(recorded.async_calls, start_call) or 0
|
||||
local failed_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 2)
|
||||
local stopped_ping = find_nth_control_call(recorded.async_calls, "--app-ping", 3)
|
||||
assert_true(failed_ping ~= nil, "manual restart should retry after transient app-ping failure")
|
||||
assert_true(stopped_ping ~= nil, "manual restart should observe stopped app-ping status")
|
||||
assert_true(
|
||||
(find_call_index(recorded.async_calls, failed_ping) or 0) < start_index,
|
||||
"manual restart should not treat app-ping status 2 as stopped"
|
||||
)
|
||||
assert_true(
|
||||
(find_call_index(recorded.async_calls, stopped_ping) or 0) < start_index,
|
||||
"manual restart should wait for explicit stopped app-ping status"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
app_ping_statuses = { 0, 1, 0 },
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
@@ -570,6 +782,92 @@ do
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for gated restart pause scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
count_property_set(recorded.property_sets, "pause", true) == 1,
|
||||
"gated restart should start from an armed pause gate"
|
||||
)
|
||||
recorded.script_messages["subminer-restart"]()
|
||||
assert_true(
|
||||
count_property_set(recorded.property_sets, "pause", false) == 0,
|
||||
"manual restart should clear a startup gate without resuming playback"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
app_ping_statuses = { 1, 0 },
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
auto_start_visible_overlay = "no",
|
||||
},
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for restart ready restore scenario: " .. tostring(err))
|
||||
assert_true(
|
||||
recorded.script_messages["subminer-toggle"] ~= nil,
|
||||
"subminer-toggle script message not registered"
|
||||
)
|
||||
assert_true(
|
||||
recorded.script_messages["subminer-restart"] ~= nil,
|
||||
"subminer-restart script message not registered"
|
||||
)
|
||||
assert_true(
|
||||
recorded.script_messages["subminer-autoplay-ready"] ~= nil,
|
||||
"subminer-autoplay-ready script message not registered"
|
||||
)
|
||||
recorded.script_messages["subminer-toggle"]()
|
||||
recorded.script_messages["subminer-restart"]()
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
count_control_calls(recorded.async_calls, "--show-visible-overlay") == 2,
|
||||
"manual restart should re-assert visible overlay after launch and readiness even when auto-start visibility is disabled"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
stop_command_fails = true,
|
||||
stop_command_stderr = "stop refused",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
},
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for failed restart-stop scenario: " .. tostring(err))
|
||||
recorded.script_messages["subminer-restart"]()
|
||||
assert_true(find_control_call(recorded.async_calls, "--stop") ~= nil, "restart should attempt stop")
|
||||
assert_true(count_start_calls(recorded.async_calls) == 0, "restart should not start overlay when stop fails")
|
||||
assert_true(
|
||||
has_osd_message(recorded.osd, "SubMiner: Restart failed"),
|
||||
"restart stop failure should show failure OSD"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
aniskip_enabled = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
@@ -608,6 +906,7 @@ do
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
@@ -644,6 +943,7 @@ do
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
aniskip_enabled = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
@@ -682,6 +982,7 @@ do
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "no",
|
||||
texthooker_enabled = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
@@ -737,6 +1038,7 @@ do
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
@@ -765,6 +1067,7 @@ do
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "no",
|
||||
texthooker_enabled = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
@@ -793,6 +1096,7 @@ do
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
media_title = "Sample Show S01E01",
|
||||
mal_lookup_stdout = "__MAL_FOUND__",
|
||||
@@ -818,6 +1122,7 @@ do
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "no",
|
||||
aniskip_enabled = "yes",
|
||||
},
|
||||
media_title = "Sample Show S01E01",
|
||||
time_pos = 13,
|
||||
@@ -852,6 +1157,7 @@ do
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "no",
|
||||
texthooker_enabled = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
@@ -1023,6 +1329,37 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
auto_start = "yes",
|
||||
auto_start_visible_overlay = "yes",
|
||||
auto_start_pause_until_ready = "yes",
|
||||
socket_path = "/tmp/subminer-socket",
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
paused = true,
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for pre-paused pause-until-ready scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
assert_true(
|
||||
count_property_set(recorded.property_sets, "pause", true) == 1,
|
||||
"pre-paused pause-until-ready should still arm the gate"
|
||||
)
|
||||
assert_true(recorded.script_messages["subminer-autoplay-ready"] ~= nil, "subminer-autoplay-ready script message not registered")
|
||||
recorded.script_messages["subminer-autoplay-ready"]()
|
||||
assert_true(
|
||||
count_property_set(recorded.property_sets, "pause", false) == 0,
|
||||
"pre-paused pause-until-ready should leave playback paused when ready"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
@@ -1236,6 +1573,27 @@ do
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
process_list = "",
|
||||
option_overrides = {
|
||||
binary_path = binary_path,
|
||||
},
|
||||
input_ipc_server = "/tmp/subminer-socket",
|
||||
media_title = "Random Movie",
|
||||
files = {
|
||||
[binary_path] = true,
|
||||
},
|
||||
})
|
||||
assert_true(recorded ~= nil, "plugin failed to load for default config scenario: " .. tostring(err))
|
||||
fire_event(recorded, "file-loaded")
|
||||
local start_call = find_start_call(recorded.async_calls)
|
||||
assert_true(
|
||||
start_call == nil,
|
||||
"plugin should not auto-start from built-in defaults without managed config script opts"
|
||||
)
|
||||
end
|
||||
|
||||
do
|
||||
local recorded, err = run_plugin_scenario({
|
||||
platform = "windows",
|
||||
|
||||
@@ -48,3 +48,141 @@ test('AnkiConnectClient includes action name in retry logs', async () => {
|
||||
console.info = originalInfo;
|
||||
}
|
||||
});
|
||||
|
||||
test('AnkiConnectClient lists decks and note type fields', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
if (body.action === 'deckNames') {
|
||||
return { data: { result: ['Core', 'Mining'], error: null } };
|
||||
}
|
||||
if (body.action === 'modelNames') {
|
||||
return { data: { result: ['Japanese sentences'], error: null } };
|
||||
}
|
||||
if (body.action === 'modelFieldNames') {
|
||||
return { data: { result: ['Expression', 'Sentence'], error: null } };
|
||||
}
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
const typedClient = client as unknown as AnkiConnectClient;
|
||||
|
||||
assert.deepEqual(await typedClient.deckNames(), ['Core', 'Mining']);
|
||||
assert.deepEqual(await typedClient.modelNames(), ['Japanese sentences']);
|
||||
assert.deepEqual(await typedClient.modelFieldNames('Japanese sentences'), [
|
||||
'Expression',
|
||||
'Sentence',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.action),
|
||||
['deckNames', 'modelNames', 'modelFieldNames'],
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient derives field names from sampled notes in a deck', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
if (body.action === 'findNotes') {
|
||||
return { data: { result: [3, 1, 2], error: null } };
|
||||
}
|
||||
if (body.action === 'notesInfo') {
|
||||
return {
|
||||
data: {
|
||||
result: [
|
||||
{ fields: { Sentence: { value: 'x' }, Expression: { value: 'y' } } },
|
||||
{ fields: { Reading: { value: 'z' } } },
|
||||
],
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(
|
||||
await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining "Current"'),
|
||||
['Expression', 'Reading', 'Sentence'],
|
||||
);
|
||||
assert.deepEqual(calls[0], {
|
||||
action: 'findNotes',
|
||||
params: { query: 'deck:"Mining \\"Current\\""' },
|
||||
});
|
||||
assert.deepEqual(calls[1], {
|
||||
action: 'notesInfo',
|
||||
params: { notes: [3, 1, 2] },
|
||||
});
|
||||
});
|
||||
|
||||
test('AnkiConnectClient treats negative deck note sample sizes as empty samples', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
if (body.action === 'findNotes') {
|
||||
return { data: { result: [3, 1, 2], error: null } };
|
||||
}
|
||||
if (body.action === 'notesInfo') {
|
||||
return {
|
||||
data: {
|
||||
result: [{ fields: { Sentence: { value: 'x' } } }],
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(await (client as unknown as AnkiConnectClient).fieldNamesForDeck('Mining', -1), []);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.action),
|
||||
['findNotes'],
|
||||
);
|
||||
});
|
||||
|
||||
test('AnkiConnectClient derives model names from sampled notes in a deck', async () => {
|
||||
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
|
||||
client: { post: (url: string, body: { action: string; params: unknown }) => Promise<unknown> };
|
||||
};
|
||||
const calls: Array<{ action: string; params: unknown }> = [];
|
||||
client.client = {
|
||||
post: async (_url, body) => {
|
||||
calls.push({ action: body.action, params: body.params });
|
||||
if (body.action === 'findNotes') {
|
||||
return { data: { result: [5, 4], error: null } };
|
||||
}
|
||||
if (body.action === 'notesInfo') {
|
||||
return {
|
||||
data: {
|
||||
result: [{ modelName: 'Lapis Morph' }, { modelName: 'Kiku' }],
|
||||
error: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
return { data: { result: [], error: null } };
|
||||
},
|
||||
};
|
||||
|
||||
assert.deepEqual(await (client as unknown as AnkiConnectClient).modelNamesForDeck('Mining'), [
|
||||
'Kiku',
|
||||
'Lapis Morph',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
calls.map((call) => call.action),
|
||||
['findNotes', 'notesInfo'],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -156,6 +156,73 @@ export class AnkiConnectClient {
|
||||
return (result as number[]) || [];
|
||||
}
|
||||
|
||||
async deckNames(): Promise<string[]> {
|
||||
const result = await this.invoke('deckNames');
|
||||
return Array.isArray(result)
|
||||
? result.filter((value): value is string => typeof value === 'string').sort()
|
||||
: [];
|
||||
}
|
||||
|
||||
async modelNames(): Promise<string[]> {
|
||||
const result = await this.invoke('modelNames');
|
||||
return Array.isArray(result)
|
||||
? result.filter((value): value is string => typeof value === 'string').sort()
|
||||
: [];
|
||||
}
|
||||
|
||||
async modelFieldNames(modelName: string): Promise<string[]> {
|
||||
const result = await this.invoke('modelFieldNames', { modelName });
|
||||
return Array.isArray(result)
|
||||
? result.filter((value): value is string => typeof value === 'string').sort()
|
||||
: [];
|
||||
}
|
||||
|
||||
private async noteInfosForDeck(
|
||||
deckName: string,
|
||||
sampleSize = 100,
|
||||
): Promise<Record<string, unknown>[]> {
|
||||
const escapedDeckName = deckName.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
const noteIds = await this.findNotes(`deck:"${escapedDeckName}"`, { maxRetries: 0 });
|
||||
if (noteIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const finiteSampleSize = Number.isFinite(sampleSize) ? sampleSize : 0;
|
||||
const normalizedSampleSize = Math.min(noteIds.length, Math.max(0, Math.floor(finiteSampleSize)));
|
||||
if (normalizedSampleSize === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.notesInfo(noteIds.slice(0, normalizedSampleSize));
|
||||
}
|
||||
|
||||
async fieldNamesForDeck(deckName: string, sampleSize = 100): Promise<string[]> {
|
||||
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
|
||||
const fields = new Set<string>();
|
||||
for (const noteInfo of noteInfos) {
|
||||
const noteFields = noteInfo.fields;
|
||||
if (!noteFields || typeof noteFields !== 'object' || Array.isArray(noteFields)) {
|
||||
continue;
|
||||
}
|
||||
for (const fieldName of Object.keys(noteFields)) {
|
||||
fields.add(fieldName);
|
||||
}
|
||||
}
|
||||
return [...fields].sort();
|
||||
}
|
||||
|
||||
async modelNamesForDeck(deckName: string, sampleSize = 100): Promise<string[]> {
|
||||
const noteInfos = await this.noteInfosForDeck(deckName, sampleSize);
|
||||
const modelNames = new Set<string>();
|
||||
for (const noteInfo of noteInfos) {
|
||||
const modelName = noteInfo.modelName;
|
||||
if (typeof modelName === 'string' && modelName.length > 0) {
|
||||
modelNames.add(modelName);
|
||||
}
|
||||
}
|
||||
return [...modelNames].sort();
|
||||
}
|
||||
|
||||
async notesInfo(noteIds: number[]): Promise<Record<string, unknown>[]> {
|
||||
const result = await this.invoke('notesInfo', { notes: noteIds });
|
||||
return (result as Record<string, unknown>[]) || [];
|
||||
|
||||
@@ -277,7 +277,8 @@ export class KnownWordCacheManager {
|
||||
}
|
||||
|
||||
private isKnownWordCacheEnabled(): boolean {
|
||||
return this.deps.getConfig().knownWords?.highlightEnabled === true;
|
||||
const config = this.deps.getConfig();
|
||||
return config.knownWords?.highlightEnabled === true || config.nPlusOne?.enabled === true;
|
||||
}
|
||||
|
||||
private shouldAddMinedWordsImmediately(): boolean {
|
||||
|
||||
@@ -157,7 +157,8 @@ export class AnkiIntegrationRuntime {
|
||||
}
|
||||
|
||||
applyRuntimeConfigPatch(patch: Partial<AnkiConnectConfig>): void {
|
||||
const wasKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
|
||||
const wasKnownWordCacheEnabled =
|
||||
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
|
||||
const previousKnownWordCacheConfig = wasKnownWordCacheEnabled
|
||||
? this.getKnownWordCacheLifecycleConfig(this.config)
|
||||
: null;
|
||||
@@ -207,7 +208,8 @@ export class AnkiIntegrationRuntime {
|
||||
};
|
||||
this.config = normalizeAnkiIntegrationConfig(mergedConfig);
|
||||
this.deps.onConfigChanged?.(this.config);
|
||||
const nextKnownWordCacheEnabled = this.config.knownWords?.highlightEnabled === true;
|
||||
const nextKnownWordCacheEnabled =
|
||||
this.config.knownWords?.highlightEnabled === true || this.config.nPlusOne?.enabled === true;
|
||||
|
||||
if (wasKnownWordCacheEnabled && !nextKnownWordCacheEnabled) {
|
||||
if (this.started) {
|
||||
|
||||
@@ -5,6 +5,8 @@ import { resolve } from 'node:path';
|
||||
|
||||
const ciWorkflowPath = resolve(__dirname, '../.github/workflows/ci.yml');
|
||||
const ciWorkflow = readFileSync(ciWorkflowPath, 'utf8');
|
||||
const docsPagesWorkflowPath = resolve(__dirname, '../.github/workflows/docs-pages.yml');
|
||||
const docsPagesWorkflow = readFileSync(docsPagesWorkflowPath, 'utf8');
|
||||
const packageJsonPath = resolve(__dirname, '../package.json');
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
@@ -36,3 +38,28 @@ test('ci workflow runs the maintained source coverage lane and uploads lcov outp
|
||||
assert.match(ciWorkflow, /name: Upload coverage artifact/);
|
||||
assert.match(ciWorkflow, /path: coverage\/test-src\/lcov\.info/);
|
||||
});
|
||||
|
||||
test('main docs deploy exists, serializes deploys, and uses Cloudflare credentials', () => {
|
||||
assert.match(docsPagesWorkflow, /name: Docs Pages/);
|
||||
assert.match(docsPagesWorkflow, /branches:\s*\n\s*-\s*main/);
|
||||
assert.match(docsPagesWorkflow, /group:\s*docs-pages-production/);
|
||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_API_TOKEN/);
|
||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_ACCOUNT_ID/);
|
||||
assert.match(docsPagesWorkflow, /CLOUDFLARE_PAGES_PROJECT_NAME/);
|
||||
assert.match(docsPagesWorkflow, /pages deploy \.tmp\/docs-versioned-site/);
|
||||
assert.match(docsPagesWorkflow, /--branch main/);
|
||||
});
|
||||
|
||||
test('docs deploy caches stable archive builds between runs', () => {
|
||||
assert.match(docsPagesWorkflow, /actions\/cache@v4/);
|
||||
assert.match(docsPagesWorkflow, /\.tmp\/docs-versioned-archive-cache/);
|
||||
assert.match(docsPagesWorkflow, /docs-versioned-archives-/);
|
||||
assert.match(docsPagesWorkflow, /docs-site\/\.vitepress\/\*\*/);
|
||||
});
|
||||
|
||||
test('docs deploy skips invalid release tags without failing the workflow', () => {
|
||||
assert.match(docsPagesWorkflow, /id:\s*tag_guard/);
|
||||
assert.match(docsPagesWorkflow, /stable_tag=false/);
|
||||
assert.doesNotMatch(docsPagesWorkflow, /exit 78/);
|
||||
assert.match(docsPagesWorkflow, /if:\s*steps\.tag_guard\.outputs\.stable_tag != 'false'/);
|
||||
});
|
||||
|
||||
@@ -212,6 +212,14 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(shouldStartApp(settings), true);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(settings), true);
|
||||
|
||||
const configSettings = parseArgs(['--config']);
|
||||
assert.equal(configSettings.configSettings, true);
|
||||
assert.equal(hasExplicitCommand(configSettings), true);
|
||||
assert.equal(shouldStartApp(configSettings), true);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(configSettings), false);
|
||||
assert.equal(commandNeedsOverlayRuntime(configSettings), false);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(configSettings), false);
|
||||
|
||||
const settingsWithOverlay = parseArgs(['--settings', '--toggle-visible-overlay']);
|
||||
assert.equal(settingsWithOverlay.settings, true);
|
||||
assert.equal(settingsWithOverlay.toggleVisibleOverlay, true);
|
||||
@@ -228,6 +236,11 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
|
||||
assert.equal(shouldStartApp(help), false);
|
||||
assert.equal(shouldRunSettingsOnlyStartup(help), false);
|
||||
|
||||
const appPing = parseArgs(['--app-ping']);
|
||||
assert.equal(appPing.appPing, true);
|
||||
assert.equal(hasExplicitCommand(appPing), true);
|
||||
assert.equal(shouldStartApp(appPing), false);
|
||||
|
||||
const youtubePlay = parseArgs(['--youtube-play', 'https://youtube.com/watch?v=abc']);
|
||||
assert.equal(commandNeedsOverlayStartupPrereqs(youtubePlay), true);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export interface CliArgs {
|
||||
toggleVisibleOverlay: boolean;
|
||||
togglePrimarySubtitleBar: boolean;
|
||||
settings: boolean;
|
||||
configSettings: boolean;
|
||||
setup: boolean;
|
||||
show: boolean;
|
||||
hide: boolean;
|
||||
@@ -73,6 +74,7 @@ export interface CliArgs {
|
||||
texthooker: boolean;
|
||||
texthookerOpenBrowser: boolean;
|
||||
help: boolean;
|
||||
appPing?: boolean;
|
||||
update?: boolean;
|
||||
updateLauncherPath?: string;
|
||||
updateResponsePath?: string;
|
||||
@@ -115,6 +117,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
toggleVisibleOverlay: false,
|
||||
togglePrimarySubtitleBar: false,
|
||||
settings: false,
|
||||
configSettings: false,
|
||||
setup: false,
|
||||
show: false,
|
||||
hide: false,
|
||||
@@ -170,6 +173,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
texthooker: false,
|
||||
texthookerOpenBrowser: false,
|
||||
help: false,
|
||||
appPing: false,
|
||||
update: false,
|
||||
updateLauncherPath: undefined,
|
||||
updateResponsePath: undefined,
|
||||
@@ -234,6 +238,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--toggle-visible-overlay') args.toggleVisibleOverlay = true;
|
||||
else if (arg === '--toggle-primary-subtitle-bar') args.togglePrimarySubtitleBar = true;
|
||||
else if (arg === '--settings' || arg === '--yomitan') args.settings = true;
|
||||
else if (arg === '--config') args.configSettings = true;
|
||||
else if (arg === '--setup') args.setup = true;
|
||||
else if (arg === '--show') args.show = true;
|
||||
else if (arg === '--hide') args.hide = true;
|
||||
@@ -336,6 +341,7 @@ export function parseArgs(argv: string[]): CliArgs {
|
||||
else if (arg === '--jellyfin-preview-auth') args.jellyfinPreviewAuth = true;
|
||||
else if (arg === '--texthooker') args.texthooker = true;
|
||||
else if (arg === '--open-browser') args.texthookerOpenBrowser = true;
|
||||
else if (arg === '--app-ping') args.appPing = true;
|
||||
else if (arg === '--update') args.update = true;
|
||||
else if (arg.startsWith('--update-launcher-path=')) {
|
||||
const value = arg.split('=', 2)[1];
|
||||
@@ -486,6 +492,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.settings ||
|
||||
args.configSettings ||
|
||||
args.setup ||
|
||||
args.show ||
|
||||
args.hide ||
|
||||
@@ -536,6 +543,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
|
||||
args.jellyfinRemoteAnnounce ||
|
||||
args.jellyfinPreviewAuth ||
|
||||
args.texthooker ||
|
||||
args.appPing ||
|
||||
args.update ||
|
||||
args.generateConfig ||
|
||||
args.help
|
||||
@@ -558,6 +566,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.settings &&
|
||||
!args.configSettings &&
|
||||
!args.setup &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
@@ -607,6 +616,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
|
||||
!args.jellyfinPlay &&
|
||||
!args.jellyfinRemoteAnnounce &&
|
||||
!args.jellyfinPreviewAuth &&
|
||||
!args.appPing &&
|
||||
!args.update &&
|
||||
!args.help &&
|
||||
!args.autoStartOverlay &&
|
||||
@@ -625,6 +635,7 @@ export function shouldStartApp(args: CliArgs): boolean {
|
||||
args.toggleVisibleOverlay ||
|
||||
args.togglePrimarySubtitleBar ||
|
||||
args.settings ||
|
||||
args.configSettings ||
|
||||
args.setup ||
|
||||
args.copySubtitle ||
|
||||
args.copySubtitleMultiple ||
|
||||
@@ -679,6 +690,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.toggle &&
|
||||
!args.toggleVisibleOverlay &&
|
||||
!args.togglePrimarySubtitleBar &&
|
||||
!args.configSettings &&
|
||||
!args.show &&
|
||||
!args.hide &&
|
||||
!args.setup &&
|
||||
@@ -730,6 +742,7 @@ export function shouldRunSettingsOnlyStartup(args: CliArgs): boolean {
|
||||
!args.jellyfinRemoteAnnounce &&
|
||||
!args.jellyfinPreviewAuth &&
|
||||
!args.texthooker &&
|
||||
!args.appPing &&
|
||||
!args.update &&
|
||||
!args.help &&
|
||||
!args.autoStartOverlay &&
|
||||
|
||||
@@ -22,6 +22,7 @@ test('printHelp includes configured texthooker port', () => {
|
||||
assert.match(output, /--open-browser\s+Open texthooker in your default browser/);
|
||||
assert.doesNotMatch(output, /--refresh-known-words/);
|
||||
assert.match(output, /--setup\s+Open first-run setup window/);
|
||||
assert.match(output, /--config\s+Open configuration window/);
|
||||
assert.match(output, /--anilist-status/);
|
||||
assert.match(output, /--anilist-retry-queue/);
|
||||
assert.match(output, /--dictionary/);
|
||||
|
||||
@@ -25,6 +25,7 @@ ${B}Overlay${R}
|
||||
--show-visible-overlay Show subtitle overlay
|
||||
--hide-visible-overlay Hide subtitle overlay
|
||||
--settings Open Yomitan settings window
|
||||
--config Open configuration window
|
||||
--setup Open first-run setup window
|
||||
--auto-start-overlay Auto-hide mpv subs, show overlay on connect
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
import {
|
||||
getNodeValue,
|
||||
parseTree as parseJsoncTree,
|
||||
type Node as JsoncNode,
|
||||
type ParseError,
|
||||
} from 'jsonc-parser';
|
||||
import type { RawConfig } from '../types/config';
|
||||
import type { ConfigSettingsPatchOperation } from '../types/settings';
|
||||
import { DEFAULT_CONFIG } from './definitions';
|
||||
import { applyConfigSettingsPatchToContent } from './settings/jsonc-edit';
|
||||
|
||||
export type LegacyAnkiConnectNPlusOneMigrationResult =
|
||||
| {
|
||||
migrated: true;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}
|
||||
| {
|
||||
migrated: false;
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
};
|
||||
|
||||
const LEGACY_N_PLUS_ONE_PATH_MAP = {
|
||||
highlightEnabled: 'ankiConnect.knownWords.highlightEnabled',
|
||||
refreshMinutes: 'ankiConnect.knownWords.refreshMinutes',
|
||||
matchMode: 'ankiConnect.knownWords.matchMode',
|
||||
decks: 'ankiConnect.knownWords.decks',
|
||||
knownWord: 'subtitleStyle.knownWordColor',
|
||||
nPlusOne: 'subtitleStyle.nPlusOneColor',
|
||||
} as const;
|
||||
|
||||
function propertyKey(propertyNode: JsoncNode): string | undefined {
|
||||
return propertyNode.children?.[0]?.value;
|
||||
}
|
||||
|
||||
function propertyValue(propertyNode: JsoncNode | undefined): JsoncNode | undefined {
|
||||
return propertyNode?.children?.[1];
|
||||
}
|
||||
|
||||
function objectProperties(node: JsoncNode | undefined): JsoncNode[] {
|
||||
return node?.type === 'object' ? (node.children ?? []) : [];
|
||||
}
|
||||
|
||||
function findLastProperty(node: JsoncNode | undefined, key: string): JsoncNode | undefined {
|
||||
const matches = objectProperties(node).filter((property) => propertyKey(property) === key);
|
||||
return matches.at(-1);
|
||||
}
|
||||
|
||||
function findProperties(node: JsoncNode | undefined, key: string): JsoncNode[] {
|
||||
return objectProperties(node).filter((property) => propertyKey(property) === key);
|
||||
}
|
||||
|
||||
function findValueAtPath(root: JsoncNode | undefined, path: string): JsoncNode | undefined {
|
||||
let node = root;
|
||||
for (const segment of path.split('.')) {
|
||||
node = propertyValue(findLastProperty(node, segment));
|
||||
if (!node) return undefined;
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function hasPath(root: JsoncNode | undefined, path: string): boolean {
|
||||
return findValueAtPath(root, path) !== undefined;
|
||||
}
|
||||
|
||||
function normalizeLegacyDecks(value: unknown): unknown {
|
||||
if (!Array.isArray(value)) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const defaultFields = [DEFAULT_CONFIG.ankiConnect.fields.word, 'Word', 'Reading', 'Word Reading'];
|
||||
const decks = value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const normalized: Record<string, string[]> = {};
|
||||
for (const deck of new Set(decks)) {
|
||||
normalized[deck] = defaultFields;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildLegacyNPlusOneMigrationOperations(root: JsoncNode | undefined): {
|
||||
operations: ConfigSettingsPatchOperation[];
|
||||
hasLegacy: boolean;
|
||||
} {
|
||||
const operations: ConfigSettingsPatchOperation[] = [];
|
||||
const ankiConnect = propertyValue(findLastProperty(root, 'ankiConnect'));
|
||||
const nPlusOneProperties = findProperties(ankiConnect, 'nPlusOne');
|
||||
const nPlusOneObjects = nPlusOneProperties.map(propertyValue).filter(Boolean) as JsoncNode[];
|
||||
if (nPlusOneObjects.length === 0) {
|
||||
return { operations, hasLegacy: false };
|
||||
}
|
||||
|
||||
const canonicalNPlusOneValues = new Map<string, unknown>();
|
||||
const legacyValues = new Map<keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, unknown>();
|
||||
let hasLegacy = false;
|
||||
|
||||
for (const nPlusOne of nPlusOneObjects) {
|
||||
for (const property of objectProperties(nPlusOne)) {
|
||||
const key = propertyKey(property);
|
||||
if (!key) continue;
|
||||
const valueNode = propertyValue(property);
|
||||
const value = valueNode ? getNodeValue(valueNode) : undefined;
|
||||
if (key === 'enabled' || key === 'minSentenceWords') {
|
||||
canonicalNPlusOneValues.set(key, value);
|
||||
continue;
|
||||
}
|
||||
if (key in LEGACY_N_PLUS_ONE_PATH_MAP) {
|
||||
hasLegacy = true;
|
||||
legacyValues.set(key as keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nPlusOneObjects.length > 1) {
|
||||
for (const [key, value] of canonicalNPlusOneValues) {
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path: `ankiConnect.nPlusOne.${key}`,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, path] of Object.entries(LEGACY_N_PLUS_ONE_PATH_MAP) as Array<
|
||||
[keyof typeof LEGACY_N_PLUS_ONE_PATH_MAP, string]
|
||||
>) {
|
||||
if (!legacyValues.has(key)) continue;
|
||||
if (!hasPath(root, path)) {
|
||||
const value =
|
||||
key === 'decks' ? normalizeLegacyDecks(legacyValues.get(key)) : legacyValues.get(key);
|
||||
operations.push({
|
||||
op: 'set',
|
||||
path,
|
||||
value,
|
||||
});
|
||||
}
|
||||
operations.push({
|
||||
op: 'reset',
|
||||
path: `ankiConnect.nPlusOne.${key}`,
|
||||
});
|
||||
}
|
||||
|
||||
return { operations, hasLegacy };
|
||||
}
|
||||
|
||||
export function applyLegacyAnkiConnectNPlusOneMigrationToContent(options: {
|
||||
content: string;
|
||||
rawConfig: RawConfig;
|
||||
}): LegacyAnkiConnectNPlusOneMigrationResult {
|
||||
const errors: ParseError[] = [];
|
||||
const root = parseJsoncTree(options.content || '{}', errors, {
|
||||
allowTrailingComma: true,
|
||||
disallowComments: false,
|
||||
});
|
||||
if (!root || errors.length > 0) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
const { operations, hasLegacy } = buildLegacyNPlusOneMigrationOperations(root);
|
||||
if (operations.length === 0 && !hasLegacy) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
const result = applyConfigSettingsPatchToContent({
|
||||
content: options.content,
|
||||
operations,
|
||||
previousWarnings: [],
|
||||
});
|
||||
if (!result.ok) {
|
||||
return {
|
||||
migrated: false,
|
||||
content: options.content,
|
||||
rawConfig: options.rawConfig,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
migrated: true,
|
||||
content: result.content,
|
||||
rawConfig: result.rawConfig,
|
||||
};
|
||||
}
|
||||
+301
-22
@@ -12,16 +12,44 @@ import {
|
||||
} from './definitions';
|
||||
import { parseConfigContent } from './parse';
|
||||
import { generateConfigTemplate } from './template';
|
||||
import {
|
||||
buildSubtitleCssDeclarationObject,
|
||||
getSubtitleCssManagedConfigPaths,
|
||||
getSubtitleCssPath,
|
||||
type SubtitleCssScope,
|
||||
} from '../settings/subtitle-style-css';
|
||||
|
||||
const DEFAULT_SUBTITLE_FONT_FAMILY =
|
||||
'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP';
|
||||
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = 'Inter, Noto Sans, Helvetica Neue, sans-serif';
|
||||
const DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY = DEFAULT_SUBTITLE_FONT_FAMILY;
|
||||
const DEFAULT_SUBTITLE_TEXT_SHADOW = '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)';
|
||||
const SUBTITLE_CSS_SCOPES: SubtitleCssScope[] = ['primary', 'secondary', 'sidebar'];
|
||||
|
||||
function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-config-test-'));
|
||||
}
|
||||
|
||||
function getValueAtPath(root: unknown, path: string): unknown {
|
||||
let current = root;
|
||||
for (const segment of path.split('.')) {
|
||||
if (current === null || typeof current !== 'object' || Array.isArray(current)) {
|
||||
return undefined;
|
||||
}
|
||||
current = (current as Record<string, unknown>)[segment];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function buildDefaultSubtitleCssDeclarations(scope: SubtitleCssScope): Record<string, string> {
|
||||
const values: Record<string, unknown> = {
|
||||
[getSubtitleCssPath(scope)]: getValueAtPath(DEFAULT_CONFIG, getSubtitleCssPath(scope)),
|
||||
};
|
||||
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
|
||||
values[path] = getValueAtPath(DEFAULT_CONFIG, path);
|
||||
}
|
||||
return buildSubtitleCssDeclarationObject(scope, values);
|
||||
}
|
||||
|
||||
test('loads defaults when config is missing', () => {
|
||||
const dir = makeTempDir();
|
||||
const service = new ConfigService(dir);
|
||||
@@ -83,13 +111,19 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.subtitleStyle.fontKerning, 'normal');
|
||||
assert.equal(config.subtitleStyle.textRendering, 'geometricPrecision');
|
||||
assert.equal(config.subtitleStyle.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
|
||||
assert.equal(config.subtitleStyle.paintOrder, '');
|
||||
assert.equal(config.subtitleStyle.WebkitTextStroke, '');
|
||||
assert.equal(config.subtitleStyle.backdropFilter, 'blur(6px)');
|
||||
assert.equal(config.subtitleStyle.jlptColors.N4, '#8bd5ca');
|
||||
assert.equal(config.subtitleStyle.secondary.fontFamily, DEFAULT_SECONDARY_SUBTITLE_FONT_FAMILY);
|
||||
assert.equal(config.subtitleStyle.secondary.fontColor, '#cad3f5');
|
||||
assert.equal(config.subtitleStyle.secondary.fontWeight, '600');
|
||||
assert.equal(config.subtitleStyle.secondary.textShadow, DEFAULT_SUBTITLE_TEXT_SHADOW);
|
||||
assert.equal(config.subtitleStyle.secondary.paintOrder, '');
|
||||
assert.equal(config.subtitleStyle.secondary.WebkitTextStroke, '');
|
||||
assert.equal(config.subtitleStyle.secondary.backgroundColor, 'transparent');
|
||||
assert.deepEqual(config.subtitleSidebar.css, {});
|
||||
assert.equal(config.subtitleSidebar.fontFamily, DEFAULT_SUBTITLE_FONT_FAMILY);
|
||||
assert.equal(config.immersionTracking.enabled, true);
|
||||
assert.equal(config.immersionTracking.dbPath, '');
|
||||
assert.equal(config.immersionTracking.batchSize, 25);
|
||||
@@ -113,6 +147,13 @@ test('loads defaults when config is missing', () => {
|
||||
assert.equal(config.updates.checkIntervalHours, 24);
|
||||
assert.equal(config.updates.notificationType, 'system');
|
||||
assert.equal(config.updates.channel, 'stable');
|
||||
assert.equal(config.mpv.socketPath, '/tmp/subminer-socket');
|
||||
assert.equal(config.mpv.backend, 'auto');
|
||||
assert.equal(config.mpv.autoStartSubMiner, true);
|
||||
assert.equal(config.mpv.pauseUntilOverlayReady, true);
|
||||
assert.equal(config.mpv.subminerBinaryPath, '');
|
||||
assert.equal(config.mpv.aniskipEnabled, true);
|
||||
assert.equal(config.mpv.aniskipButtonKey, 'TAB');
|
||||
});
|
||||
|
||||
test('parses updates config and warns on invalid values', () => {
|
||||
@@ -181,6 +222,94 @@ test('throws actionable startup parse error for malformed config at construction
|
||||
);
|
||||
});
|
||||
|
||||
test('migrates legacy subtitle appearance options into css declaration objects on load', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"fontSize": 42,
|
||||
"fontColor": "#ffffff",
|
||||
"hoverTokenColor": "#abcdef",
|
||||
"hoverTokenBackgroundColor": "transparent",
|
||||
"css": {
|
||||
"font-size": "44px",
|
||||
"text-wrap": "balance"
|
||||
},
|
||||
"secondary": {
|
||||
"fontSize": 28,
|
||||
"fontColor": "#bbbbbb"
|
||||
}
|
||||
},
|
||||
"subtitleSidebar": {
|
||||
"fontFamily": "M PLUS 1, sans-serif",
|
||||
"fontSize": 18,
|
||||
"textColor": "#dddddd",
|
||||
"timestampColor": "#aaaaaa",
|
||||
"css": {
|
||||
"font-size": "19px"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||
subtitleStyle: {
|
||||
fontSize?: unknown;
|
||||
fontColor?: unknown;
|
||||
hoverTokenColor?: unknown;
|
||||
hoverTokenBackgroundColor?: unknown;
|
||||
css?: Record<string, string>;
|
||||
secondary?: {
|
||||
fontSize?: unknown;
|
||||
fontColor?: unknown;
|
||||
css?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
subtitleSidebar: {
|
||||
fontFamily?: unknown;
|
||||
fontSize?: unknown;
|
||||
textColor?: unknown;
|
||||
timestampColor?: unknown;
|
||||
css?: Record<string, string>;
|
||||
};
|
||||
};
|
||||
|
||||
assert.deepEqual(parsed.subtitleStyle.css, {
|
||||
color: '#ffffff',
|
||||
'font-size': '44px',
|
||||
'--subtitle-hover-token-color': '#abcdef',
|
||||
'--subtitle-hover-token-background-color': 'transparent',
|
||||
'text-wrap': 'balance',
|
||||
});
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontSize'), false);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'fontColor'), false);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenColor'), false);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle, 'hoverTokenBackgroundColor'), false);
|
||||
assert.deepEqual(parsed.subtitleStyle.secondary?.css, {
|
||||
color: '#bbbbbb',
|
||||
'font-size': '28px',
|
||||
});
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontSize'), false);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleStyle.secondary ?? {}, 'fontColor'), false);
|
||||
assert.deepEqual(parsed.subtitleSidebar.css, {
|
||||
'font-family': 'M PLUS 1, sans-serif',
|
||||
color: '#dddddd',
|
||||
'font-size': '19px',
|
||||
'--subtitle-sidebar-timestamp-color': '#aaaaaa',
|
||||
});
|
||||
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontFamily'), false);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'fontSize'), false);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'textColor'), false);
|
||||
assert.equal(Object.hasOwn(parsed.subtitleSidebar, 'timestampColor'), false);
|
||||
assert.equal(service.getConfig().subtitleStyle.css['font-size'], '44px');
|
||||
assert.equal(service.getConfig().subtitleStyle.secondary.css['font-size'], '28px');
|
||||
assert.equal(service.getConfig().subtitleSidebar.css['font-size'], '19px');
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -255,6 +384,70 @@ test('parses texthooker.launchAtStartup and warns on invalid values', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('parses managed mpv plugin runtime settings from config', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"mpv": {
|
||||
"socketPath": "/tmp/custom-subminer.sock",
|
||||
"backend": "x11",
|
||||
"autoStartSubMiner": false,
|
||||
"pauseUntilOverlayReady": false,
|
||||
"subminerBinaryPath": "/opt/SubMiner/SubMiner.AppImage",
|
||||
"aniskipEnabled": false,
|
||||
"aniskipButtonKey": "F8"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
const config = validService.getConfig();
|
||||
assert.equal(config.mpv.socketPath, '/tmp/custom-subminer.sock');
|
||||
assert.equal(config.mpv.backend, 'x11');
|
||||
assert.equal(config.mpv.autoStartSubMiner, false);
|
||||
assert.equal(config.mpv.pauseUntilOverlayReady, false);
|
||||
assert.equal(config.mpv.subminerBinaryPath, '/opt/SubMiner/SubMiner.AppImage');
|
||||
assert.equal(config.mpv.aniskipEnabled, false);
|
||||
assert.equal(config.mpv.aniskipButtonKey, 'F8');
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"mpv": {
|
||||
"socketPath": "",
|
||||
"backend": "weston",
|
||||
"autoStartSubMiner": "yes",
|
||||
"pauseUntilOverlayReady": "no",
|
||||
"subminerBinaryPath": 42,
|
||||
"aniskipEnabled": "disabled",
|
||||
"aniskipButtonKey": ""
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
const invalidConfig = invalidService.getConfig();
|
||||
const warnings = invalidService.getWarnings();
|
||||
assert.equal(invalidConfig.mpv.socketPath, DEFAULT_CONFIG.mpv.socketPath);
|
||||
assert.equal(invalidConfig.mpv.backend, DEFAULT_CONFIG.mpv.backend);
|
||||
assert.equal(invalidConfig.mpv.autoStartSubMiner, DEFAULT_CONFIG.mpv.autoStartSubMiner);
|
||||
assert.equal(invalidConfig.mpv.pauseUntilOverlayReady, DEFAULT_CONFIG.mpv.pauseUntilOverlayReady);
|
||||
assert.equal(invalidConfig.mpv.subminerBinaryPath, DEFAULT_CONFIG.mpv.subminerBinaryPath);
|
||||
assert.equal(invalidConfig.mpv.aniskipEnabled, DEFAULT_CONFIG.mpv.aniskipEnabled);
|
||||
assert.equal(invalidConfig.mpv.aniskipButtonKey, DEFAULT_CONFIG.mpv.aniskipButtonKey);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.socketPath'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.backend'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.autoStartSubMiner'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.pauseUntilOverlayReady'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.subminerBinaryPath'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipEnabled'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'mpv.aniskipButtonKey'));
|
||||
});
|
||||
|
||||
test('parses annotationWebsocket settings and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -1685,6 +1878,7 @@ test('runtime options registry is centralized', () => {
|
||||
const ids = RUNTIME_OPTION_REGISTRY.map((entry) => entry.id);
|
||||
assert.deepEqual(ids, [
|
||||
'anki.autoUpdateNewCards',
|
||||
'subtitle.annotation.knownWords.highlightEnabled',
|
||||
'subtitle.annotation.nPlusOne',
|
||||
'subtitle.annotation.jlpt',
|
||||
'subtitle.annotation.frequency',
|
||||
@@ -1846,7 +2040,7 @@ test('accepts valid ankiConnect knownWords match mode values', () => {
|
||||
assert.equal(config.ankiConnect.knownWords.matchMode, 'surface');
|
||||
});
|
||||
|
||||
test('validates ankiConnect knownWords and n+1 color values', () => {
|
||||
test('ignores invalid legacy ankiConnect n+1 color value after migration attempt', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
@@ -1867,16 +2061,17 @@ test('validates ankiConnect knownWords and n+1 color values', () => {
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, DEFAULT_CONFIG.ankiConnect.nPlusOne.nPlusOne);
|
||||
assert.equal(config.ankiConnect.knownWords.color, DEFAULT_CONFIG.ankiConnect.knownWords.color);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.nPlusOne.nPlusOne'));
|
||||
assert.equal(config.subtitleStyle.nPlusOneColor, DEFAULT_CONFIG.subtitleStyle.nPlusOneColor);
|
||||
assert.equal(config.subtitleStyle.knownWordColor, DEFAULT_CONFIG.subtitleStyle.knownWordColor);
|
||||
assert.ok(warnings.every((warning) => warning.path !== 'ankiConnect.nPlusOne.nPlusOne'));
|
||||
assert.ok(warnings.some((warning) => warning.path === 'ankiConnect.knownWords.color'));
|
||||
});
|
||||
|
||||
test('accepts valid ankiConnect knownWords and n+1 color values', () => {
|
||||
test('migrates legacy ankiConnect n+1 color value to subtitleStyle', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
configPath,
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
@@ -1893,14 +2088,23 @@ test('accepts valid ankiConnect knownWords and n+1 color values', () => {
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.nPlusOne, '#c6a0f6');
|
||||
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
|
||||
assert.equal(config.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
|
||||
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||
ankiConnect: { nPlusOne?: Record<string, unknown> };
|
||||
subtitleStyle: { nPlusOneColor?: string; knownWordColor?: string };
|
||||
};
|
||||
assert.equal(parsed.subtitleStyle.nPlusOneColor, '#c6a0f6');
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.equal(Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, 'nPlusOne'), false);
|
||||
});
|
||||
|
||||
test('supports legacy ankiConnect nPlusOne known-word settings as fallback', () => {
|
||||
test('migrates legacy ankiConnect nPlusOne known-word settings to knownWords', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
configPath,
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
@@ -1918,6 +2122,13 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||
ankiConnect: {
|
||||
knownWords: Record<string, unknown>;
|
||||
nPlusOne?: Record<string, unknown>;
|
||||
};
|
||||
subtitleStyle: { knownWordColor?: string };
|
||||
};
|
||||
|
||||
assert.equal(config.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(config.ankiConnect.knownWords.refreshMinutes, 90);
|
||||
@@ -1926,17 +2137,54 @@ test('supports legacy ankiConnect nPlusOne known-word settings as fallback', ()
|
||||
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
});
|
||||
assert.equal(config.ankiConnect.knownWords.color, '#a6da95');
|
||||
assert.equal(config.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.equal(parsed.ankiConnect.knownWords.highlightEnabled, true);
|
||||
assert.equal(parsed.ankiConnect.knownWords.refreshMinutes, 90);
|
||||
assert.equal(parsed.ankiConnect.knownWords.matchMode, 'surface');
|
||||
assert.deepEqual(parsed.ankiConnect.knownWords.decks, {
|
||||
Mining: ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
'Kaishi 1.5k': ['Expression', 'Word', 'Reading', 'Word Reading'],
|
||||
});
|
||||
assert.equal(parsed.subtitleStyle.knownWordColor, '#a6da95');
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'ankiConnect.nPlusOne.highlightEnabled' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.refreshMinutes' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.matchMode' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.decks' ||
|
||||
warning.path === 'ankiConnect.nPlusOne.knownWord',
|
||||
['highlightEnabled', 'refreshMinutes', 'matchMode', 'decks', 'knownWord'].every(
|
||||
(key) => !Object.hasOwn(parsed.ankiConnect.nPlusOne ?? {}, key),
|
||||
),
|
||||
);
|
||||
assert.ok(warnings.every((warning) => !warning.path.startsWith('ankiConnect.nPlusOne.')));
|
||||
});
|
||||
|
||||
test('migrates duplicate ankiConnect nPlusOne objects to the modal path', () => {
|
||||
const dir = makeTempDir();
|
||||
const configPath = path.join(dir, 'config.jsonc');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`{
|
||||
"ankiConnect": {
|
||||
"nPlusOne": {
|
||||
"enabled": true,
|
||||
"minSentenceWords": 3
|
||||
},
|
||||
"knownWords": {
|
||||
"highlightEnabled": true
|
||||
},
|
||||
"nPlusOne": {
|
||||
"minSentenceWords": "3"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const parsed = parseConfigContent(configPath, fs.readFileSync(configPath, 'utf-8')) as {
|
||||
ankiConnect: { nPlusOne: Record<string, unknown> };
|
||||
};
|
||||
|
||||
assert.equal(config.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.enabled, true);
|
||||
assert.equal(parsed.ankiConnect.nPlusOne.minSentenceWords, '3');
|
||||
});
|
||||
|
||||
test('supports legacy ankiConnect.behavior N+1 settings as fallback', () => {
|
||||
@@ -2280,9 +2528,9 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"characterDictionary":\s*\{/);
|
||||
assert.match(output, /"preserveLineBreaks": false/);
|
||||
assert.match(output, /"knownWords"\s*:\s*\{/);
|
||||
assert.match(output, /"color": "#a6da95"/);
|
||||
assert.match(output, /"knownWordColor": "#a6da95"/);
|
||||
assert.match(output, /"nPlusOneColor": "#c6a0f6"/);
|
||||
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
||||
assert.match(output, /"nPlusOne": "#c6a0f6"/);
|
||||
assert.match(output, /"minSentenceWords": 3/);
|
||||
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
|
||||
assert.match(
|
||||
@@ -2324,7 +2572,10 @@ test('template generator includes known keys', () => {
|
||||
/"dpadFallback": "horizontal",? \/\/ Optional D-pad fallback used when this analog controller action should also read D-pad input\. Values: none \| horizontal \| vertical/,
|
||||
);
|
||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||
assert.match(output, /"openBrowser": false,? \/\/ Open browser setting\. Values: true \| false/);
|
||||
assert.match(
|
||||
output,
|
||||
/"openBrowser": false,? \/\/ Open the texthooker page in the default browser when the server starts\. Values: true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"enabled": false,? \/\/ Enable overlay controller support through the Chrome Gamepad API\. Values: true \| false/,
|
||||
@@ -2382,6 +2633,34 @@ test('template generator includes known keys', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('template generator uses settings CSS declaration paths for appearance fields', () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
const parsed = parseConfigContent('config.example.jsonc', output);
|
||||
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleStyle.css'),
|
||||
buildDefaultSubtitleCssDeclarations('primary'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleStyle.secondary.css'),
|
||||
buildDefaultSubtitleCssDeclarations('secondary'),
|
||||
);
|
||||
assert.deepEqual(
|
||||
getValueAtPath(parsed, 'subtitleSidebar.css'),
|
||||
buildDefaultSubtitleCssDeclarations('sidebar'),
|
||||
);
|
||||
|
||||
for (const scope of SUBTITLE_CSS_SCOPES) {
|
||||
for (const path of getSubtitleCssManagedConfigPaths(scope)) {
|
||||
assert.equal(
|
||||
getValueAtPath(parsed, path),
|
||||
undefined,
|
||||
`${path} should be represented by ${getSubtitleCssPath(scope)} in the generated template`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('template generator shows built-in default keybindings in the keybindings array', () => {
|
||||
const output = generateConfigTemplate(DEFAULT_CONFIG);
|
||||
const parsed = parseConfigContent('config.example.jsonc', output) as {
|
||||
|
||||
@@ -124,5 +124,5 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
notificationType: 'system',
|
||||
channel: 'stable',
|
||||
},
|
||||
auto_start_overlay: false,
|
||||
auto_start_overlay: true,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import { getDefaultMpvSocketPath } from '../../shared/mpv-socket-path';
|
||||
|
||||
export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
ResolvedConfig,
|
||||
@@ -59,7 +60,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
addMinedWordsImmediately: true,
|
||||
matchMode: 'headword',
|
||||
decks: {},
|
||||
color: '#a6da95',
|
||||
},
|
||||
behavior: {
|
||||
overwriteAudio: true,
|
||||
@@ -70,15 +70,15 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
autoUpdateNewCards: true,
|
||||
},
|
||||
nPlusOne: {
|
||||
enabled: false,
|
||||
minSentenceWords: 3,
|
||||
nPlusOne: '#c6a0f6',
|
||||
},
|
||||
metadata: {
|
||||
pattern: '[SubMiner] %f (%t)',
|
||||
},
|
||||
isLapis: {
|
||||
enabled: false,
|
||||
sentenceCardModel: 'Japanese sentences',
|
||||
sentenceCardModel: 'Lapis',
|
||||
},
|
||||
isKiku: {
|
||||
enabled: false,
|
||||
@@ -94,6 +94,13 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
mpv: {
|
||||
executablePath: '',
|
||||
launchMode: 'normal',
|
||||
socketPath: getDefaultMpvSocketPath(),
|
||||
backend: 'auto',
|
||||
autoStartSubMiner: true,
|
||||
pauseUntilOverlayReady: true,
|
||||
subminerBinaryPath: '',
|
||||
aniskipEnabled: true,
|
||||
aniskipButtonKey: 'TAB',
|
||||
},
|
||||
anilist: {
|
||||
enabled: false,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ResolvedConfig } from '../../types/config';
|
||||
export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'subtitleSidebar'> = {
|
||||
subtitleStyle: {
|
||||
primaryDefaultMode: 'visible',
|
||||
css: {},
|
||||
enableJlpt: false,
|
||||
preserveLineBreaks: false,
|
||||
autoPauseVideoOnHover: true,
|
||||
@@ -21,6 +22,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision',
|
||||
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
||||
paintOrder: '',
|
||||
WebkitTextStroke: '',
|
||||
fontStyle: 'normal',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'blur(6px)',
|
||||
@@ -43,7 +46,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||
},
|
||||
secondary: {
|
||||
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
||||
css: {},
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 24,
|
||||
fontColor: '#cad3f5',
|
||||
lineHeight: 1.35,
|
||||
@@ -52,6 +56,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
fontKerning: 'normal',
|
||||
textRendering: 'geometricPrecision',
|
||||
textShadow: '0 2px 6px rgba(0,0,0,0.9), 0 0 12px rgba(0,0,0,0.55)',
|
||||
paintOrder: '',
|
||||
WebkitTextStroke: '',
|
||||
backgroundColor: 'transparent',
|
||||
backdropFilter: 'blur(6px)',
|
||||
fontWeight: '600',
|
||||
@@ -65,11 +71,12 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle' | 'su
|
||||
toggleKey: 'Backslash',
|
||||
pauseVideoOnHover: false,
|
||||
autoScroll: true,
|
||||
css: {},
|
||||
maxWidth: 420,
|
||||
opacity: 0.95,
|
||||
backgroundColor: 'rgba(73, 77, 100, 0.9)',
|
||||
textColor: '#cad3f5',
|
||||
fontFamily: '"M PLUS 1", "Noto Sans CJK JP", sans-serif',
|
||||
fontFamily: 'Hiragino Sans, M PLUS 1, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 16,
|
||||
timestampColor: '#a5adcb',
|
||||
activeLineColor: '#f5bde6',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { ResolvedConfig } from '../../types/config';
|
||||
import {
|
||||
CONFIG_OPTION_REGISTRY,
|
||||
CONFIG_TEMPLATE_SECTIONS,
|
||||
@@ -13,6 +14,79 @@ import { buildImmersionConfigOptionRegistry } from './options-immersion';
|
||||
import { buildIntegrationConfigOptionRegistry } from './options-integrations';
|
||||
import { buildSubtitleConfigOptionRegistry } from './options-subtitle';
|
||||
|
||||
function collectConfigLeafPaths(config: ResolvedConfig): string[] {
|
||||
const leaves: string[] = [];
|
||||
const visit = (value: unknown, prefix: string): void => {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
leaves.push(prefix);
|
||||
return;
|
||||
}
|
||||
const entries = Object.entries(value as Record<string, unknown>);
|
||||
if (entries.length === 0) {
|
||||
leaves.push(prefix);
|
||||
return;
|
||||
}
|
||||
for (const [key, child] of entries) {
|
||||
visit(child, prefix ? `${prefix}.${key}` : key);
|
||||
}
|
||||
};
|
||||
visit(config, '');
|
||||
return leaves;
|
||||
}
|
||||
|
||||
// DEFAULT_CONFIG leaves that intentionally do not have a curated
|
||||
// CONFIG_OPTION_REGISTRY entry. The generated config.example.jsonc still
|
||||
// includes these paths, but their inline comments fall back to an auto-
|
||||
// humanized key name instead of a written description.
|
||||
//
|
||||
// Current intentional gaps:
|
||||
// - subtitleStyle.*: thin wrappers around standard CSS properties; the
|
||||
// CSS reference is the canonical documentation surface.
|
||||
// - keybindings: an array of {key, command} objects, documented at the
|
||||
// section level via CONFIG_TEMPLATE_SECTIONS rather than per-leaf.
|
||||
//
|
||||
// New leaves added to DEFAULT_CONFIG should prefer a registry entry over
|
||||
// an allowlist entry. Only allowlist a path when the registry is genuinely
|
||||
// the wrong surface for it.
|
||||
const UNDOCUMENTED_LEAVES: ReadonlySet<string> = new Set([
|
||||
'keybindings',
|
||||
'subtitleStyle.backdropFilter',
|
||||
'subtitleStyle.backgroundColor',
|
||||
'subtitleStyle.fontColor',
|
||||
'subtitleStyle.fontFamily',
|
||||
'subtitleStyle.fontKerning',
|
||||
'subtitleStyle.fontSize',
|
||||
'subtitleStyle.fontStyle',
|
||||
'subtitleStyle.fontWeight',
|
||||
'subtitleStyle.jlptColors.N1',
|
||||
'subtitleStyle.jlptColors.N2',
|
||||
'subtitleStyle.jlptColors.N3',
|
||||
'subtitleStyle.jlptColors.N4',
|
||||
'subtitleStyle.jlptColors.N5',
|
||||
'subtitleStyle.letterSpacing',
|
||||
'subtitleStyle.lineHeight',
|
||||
'subtitleStyle.paintOrder',
|
||||
'subtitleStyle.secondary.backdropFilter',
|
||||
'subtitleStyle.secondary.backgroundColor',
|
||||
'subtitleStyle.secondary.fontColor',
|
||||
'subtitleStyle.secondary.fontFamily',
|
||||
'subtitleStyle.secondary.fontKerning',
|
||||
'subtitleStyle.secondary.fontSize',
|
||||
'subtitleStyle.secondary.fontStyle',
|
||||
'subtitleStyle.secondary.fontWeight',
|
||||
'subtitleStyle.secondary.letterSpacing',
|
||||
'subtitleStyle.secondary.lineHeight',
|
||||
'subtitleStyle.secondary.paintOrder',
|
||||
'subtitleStyle.secondary.textRendering',
|
||||
'subtitleStyle.secondary.textShadow',
|
||||
'subtitleStyle.secondary.WebkitTextStroke',
|
||||
'subtitleStyle.secondary.wordSpacing',
|
||||
'subtitleStyle.textRendering',
|
||||
'subtitleStyle.textShadow',
|
||||
'subtitleStyle.WebkitTextStroke',
|
||||
'subtitleStyle.wordSpacing',
|
||||
]);
|
||||
|
||||
test('config option registry includes critical paths and has unique entries', () => {
|
||||
const paths = CONFIG_OPTION_REGISTRY.map((entry) => entry.path);
|
||||
|
||||
@@ -31,6 +105,13 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'mpv.executablePath',
|
||||
'mpv.launchMode',
|
||||
'mpv.socketPath',
|
||||
'mpv.backend',
|
||||
'mpv.autoStartSubMiner',
|
||||
'mpv.pauseUntilOverlayReady',
|
||||
'mpv.subminerBinaryPath',
|
||||
'mpv.aniskipEnabled',
|
||||
'mpv.aniskipButtonKey',
|
||||
'yomitan.externalProfilePath',
|
||||
'immersionTracking.enabled',
|
||||
]) {
|
||||
@@ -40,6 +121,49 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
assert.equal(new Set(paths).size, paths.length);
|
||||
});
|
||||
|
||||
test('known-word annotation color has one public config path', () => {
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
assert.ok(leaves.includes('subtitleStyle.knownWordColor'));
|
||||
assert.ok(!leaves.includes('ankiConnect.knownWords.color'));
|
||||
});
|
||||
|
||||
test('n+1 annotation color has one public config path', () => {
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
assert.ok(leaves.includes('subtitleStyle.nPlusOneColor'));
|
||||
assert.ok(!leaves.includes('ankiConnect.nPlusOne.nPlusOne'));
|
||||
});
|
||||
|
||||
test('every DEFAULT_CONFIG leaf is in CONFIG_OPTION_REGISTRY or UNDOCUMENTED_LEAVES', () => {
|
||||
const registryPaths = new Set(CONFIG_OPTION_REGISTRY.map((entry) => entry.path));
|
||||
const leaves = collectConfigLeafPaths(DEFAULT_CONFIG);
|
||||
|
||||
const missing = leaves
|
||||
.filter((path) => !registryPaths.has(path) && !UNDOCUMENTED_LEAVES.has(path))
|
||||
.sort();
|
||||
assert.deepEqual(
|
||||
missing,
|
||||
[],
|
||||
`Add CONFIG_OPTION_REGISTRY entries (preferred) or add to UNDOCUMENTED_LEAVES allowlist: ${missing.join(', ')}`,
|
||||
);
|
||||
|
||||
const stale = [...UNDOCUMENTED_LEAVES].filter((path) => registryPaths.has(path)).sort();
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
`Remove from UNDOCUMENTED_LEAVES (now covered by CONFIG_OPTION_REGISTRY): ${stale.join(', ')}`,
|
||||
);
|
||||
|
||||
const leafSet = new Set(leaves);
|
||||
const orphaned = [...UNDOCUMENTED_LEAVES].filter((path) => !leafSet.has(path)).sort();
|
||||
assert.deepEqual(
|
||||
orphaned,
|
||||
[],
|
||||
`Remove from UNDOCUMENTED_LEAVES (no longer a DEFAULT_CONFIG leaf): ${orphaned.join(', ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('config template sections include expected domains and unique keys', () => {
|
||||
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user