mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor(launcher): split config parser and CLI builder
Decompose launcher/config.ts into focused domain parser and CLI normalization modules to reduce refactor risk while preserving command behavior. Align Jellyfin launcher config with session-based auth by dropping config token/userId dependency.
This commit is contained in:
@@ -0,0 +1,88 @@
|
|||||||
|
---
|
||||||
|
id: TASK-104
|
||||||
|
title: Split launcher config.ts into domain parsers and CLI builder
|
||||||
|
status: Done
|
||||||
|
assignee:
|
||||||
|
- codex
|
||||||
|
created_date: '2026-02-22 07:13'
|
||||||
|
updated_date: '2026-02-22 19:56'
|
||||||
|
labels:
|
||||||
|
- refactor
|
||||||
|
- launcher
|
||||||
|
- maintainability
|
||||||
|
dependencies:
|
||||||
|
- TASK-81
|
||||||
|
- TASK-102
|
||||||
|
priority: medium
|
||||||
|
---
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
<!-- SECTION:DESCRIPTION:BEGIN -->
|
||||||
|
`launcher/config.ts` is still a large multi-responsibility file (~700 LOC) combining:
|
||||||
|
- config file reading/parsing for multiple domains,
|
||||||
|
- plugin runtime config parsing,
|
||||||
|
- CLI command tree construction,
|
||||||
|
- root/subcommand arg normalization.
|
||||||
|
|
||||||
|
This file remains a cleanup hotspot and makes contract changes (like Jellyfin session migration) expensive to land safely.
|
||||||
|
<!-- SECTION:DESCRIPTION:END -->
|
||||||
|
|
||||||
|
## Action Steps
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1. Extract launcher config-file readers into domain loaders (YouTube/Jimaku, Jellyfin, plugin runtime).
|
||||||
|
2. Extract Commander command-tree setup into a dedicated CLI builder module.
|
||||||
|
3. Extract post-parse normalization into focused argument-normalization helpers.
|
||||||
|
4. Remove stale Jellyfin config auth field assumptions from launcher config readers.
|
||||||
|
5. Add focused tests per extracted module while preserving existing `launcher/config.test.ts` behavior expectations.
|
||||||
|
6. Keep `parseArgs` API stable for launcher call sites.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
<!-- AC:BEGIN -->
|
||||||
|
- [x] #1 `launcher/config.ts` is reduced to thin orchestration over extracted modules.
|
||||||
|
- [x] #2 Each extracted module has focused tests that assert current behavior.
|
||||||
|
- [x] #3 Launcher still passes `bun run test:launcher` without CLI behavior regressions.
|
||||||
|
- [x] #4 Launcher config readers align with current Jellyfin session contract (no config token/userId dependency).
|
||||||
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
Plan recorded at `docs/plans/2026-02-22-task-104-launcher-config-domain-parsers-cli-builder.md`.
|
||||||
|
|
||||||
|
Execution phases:
|
||||||
|
1. Extract shared config readers and domain parser modules (`launcher/config/shared-config-reader.ts`, `launcher/config/youtube-subgen-config.ts`, `launcher/config/jellyfin-config.ts`) and reduce `launcher/config.ts` to orchestration.
|
||||||
|
2. Extract plugin runtime parser into `launcher/config/plugin-runtime-config.ts` with focused behavior tests.
|
||||||
|
3. Extract CLI parser builder/normalization into `launcher/config/cli-parser-builder.ts`, `launcher/config/parse-helpers.ts`, and `launcher/config/args-normalizer.ts`; keep `parseArgs` API unchanged.
|
||||||
|
4. Align Jellyfin config contract by removing launcher config token/userId dependence and update caller/type wiring.
|
||||||
|
5. Verify with `bun run test:launcher` and `bun run test:fast`, then finalize TASK-104 evidence (AC/DoD checks + summary).
|
||||||
|
|
||||||
|
Validation-first loop per phase: add/expand focused tests, run targeted launcher tests, implement minimal refactor to pass, then run full required suites.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Implemented launcher config decomposition by extracting domain-focused modules under `launcher/config/` (`shared-config-reader`, `youtube-subgen-config`, `jellyfin-config`, `plugin-runtime-config`, `cli-parser-builder`, `args-normalizer`) and reducing `launcher/config.ts` to an orchestration facade with unchanged exported API.
|
||||||
|
|
||||||
|
Aligned launcher Jellyfin config contract by removing `accessToken`/`userId` from `LauncherJellyfinConfig` and parser output; launcher Jellyfin play now requires `SUBMINER_JELLYFIN_ACCESS_TOKEN` + `SUBMINER_JELLYFIN_USER_ID` env session values instead of config fields.
|
||||||
|
|
||||||
|
Added focused parser regression tests in `launcher/config-domain-parsers.test.ts` (youtube domain normalization, jellyfin legacy token/userId omission, plugin socket parsing) and expanded `launcher/parse-args.test.ts` branch coverage for jellyfin/config/mpv command mappings.
|
||||||
|
|
||||||
|
Verification: `bun test launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts`, `bun run test:launcher`, and `bun run test:fast` all pass.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Split `launcher/config.ts` into focused domain parser and CLI parsing modules while preserving the public launcher parsing API (`parseArgs`, config readers, plugin runtime reader). Added focused launcher parser tests, expanded parse-args coverage, and removed launcher config dependency on Jellyfin token/userId fields to match the current session contract. Verified behavior with `bun run test:launcher` and `bun run test:fast` passing.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|
||||||
|
## Definition of Done
|
||||||
|
<!-- DOD:BEGIN -->
|
||||||
|
- [x] #1 Public launcher parsing API unchanged for downstream callers.
|
||||||
|
- [x] #2 Help text and subcommand option behavior remains unchanged.
|
||||||
|
- [x] #3 `bun run test:launcher` and `bun run test:fast` pass.
|
||||||
|
<!-- DOD:END -->
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
---
|
|
||||||
id: TASK-104
|
|
||||||
title: Split launcher config.ts into domain parsers and CLI builder
|
|
||||||
status: To Do
|
|
||||||
assignee: []
|
|
||||||
created_date: '2026-02-22 07:13'
|
|
||||||
updated_date: '2026-02-22 07:13'
|
|
||||||
labels:
|
|
||||||
- refactor
|
|
||||||
- launcher
|
|
||||||
- maintainability
|
|
||||||
dependencies:
|
|
||||||
- TASK-81
|
|
||||||
- TASK-102
|
|
||||||
priority: medium
|
|
||||||
---
|
|
||||||
|
|
||||||
## Description
|
|
||||||
|
|
||||||
<!-- SECTION:DESCRIPTION:BEGIN -->
|
|
||||||
`launcher/config.ts` is still a large multi-responsibility file (~700 LOC) combining:
|
|
||||||
- config file reading/parsing for multiple domains,
|
|
||||||
- plugin runtime config parsing,
|
|
||||||
- CLI command tree construction,
|
|
||||||
- root/subcommand arg normalization.
|
|
||||||
|
|
||||||
This file remains a cleanup hotspot and makes contract changes (like Jellyfin session migration) expensive to land safely.
|
|
||||||
<!-- SECTION:DESCRIPTION:END -->
|
|
||||||
|
|
||||||
## Action Steps
|
|
||||||
|
|
||||||
<!-- SECTION:PLAN:BEGIN -->
|
|
||||||
1. Extract launcher config-file readers into domain loaders (YouTube/Jimaku, Jellyfin, plugin runtime).
|
|
||||||
2. Extract Commander command-tree setup into a dedicated CLI builder module.
|
|
||||||
3. Extract post-parse normalization into focused argument-normalization helpers.
|
|
||||||
4. Remove stale Jellyfin config auth field assumptions from launcher config readers.
|
|
||||||
5. Add focused tests per extracted module while preserving existing `launcher/config.test.ts` behavior expectations.
|
|
||||||
6. Keep `parseArgs` API stable for launcher call sites.
|
|
||||||
<!-- SECTION:PLAN:END -->
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
<!-- AC:BEGIN -->
|
|
||||||
- [ ] #1 `launcher/config.ts` is reduced to thin orchestration over extracted modules.
|
|
||||||
- [ ] #2 Each extracted module has focused tests that assert current behavior.
|
|
||||||
- [ ] #3 Launcher still passes `bun run test:launcher` without CLI behavior regressions.
|
|
||||||
- [ ] #4 Launcher config readers align with current Jellyfin session contract (no config token/userId dependency).
|
|
||||||
<!-- AC:END -->
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
<!-- DOD:BEGIN -->
|
|
||||||
- [ ] #1 Public launcher parsing API unchanged for downstream callers.
|
|
||||||
- [ ] #2 Help text and subcommand option behavior remains unchanged.
|
|
||||||
- [ ] #3 `bun run test:launcher` and `bun run test:fast` pass.
|
|
||||||
<!-- DOD:END -->
|
|
||||||
|
|
||||||
60
launcher/config-domain-parsers.test.ts
Normal file
60
launcher/config-domain-parsers.test.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||||
|
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||||
|
import { parsePluginRuntimeConfigContent } from './config/plugin-runtime-config.js';
|
||||||
|
|
||||||
|
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
||||||
|
const parsed = parseLauncherYoutubeSubgenConfig({
|
||||||
|
youtubeSubgen: {
|
||||||
|
mode: 'preprocess',
|
||||||
|
whisperBin: '/usr/bin/whisper',
|
||||||
|
whisperModel: '/models/base.bin',
|
||||||
|
primarySubLanguages: ['ja', 42, 'en'],
|
||||||
|
},
|
||||||
|
secondarySub: {
|
||||||
|
secondarySubLanguages: ['eng', true, 'deu'],
|
||||||
|
},
|
||||||
|
jimaku: {
|
||||||
|
apiKey: 'abc',
|
||||||
|
apiKeyCommand: 'pass show key',
|
||||||
|
apiBaseUrl: 'https://jimaku.cc',
|
||||||
|
languagePreference: 'ja',
|
||||||
|
maxEntryResults: 8.7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.mode, 'preprocess');
|
||||||
|
assert.deepEqual(parsed.primarySubLanguages, ['ja', 'en']);
|
||||||
|
assert.deepEqual(parsed.secondarySubLanguages, ['eng', 'deu']);
|
||||||
|
assert.equal(parsed.jimakuLanguagePreference, 'ja');
|
||||||
|
assert.equal(parsed.jimakuMaxEntryResults, 8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseLauncherJellyfinConfig omits legacy token and user id fields', () => {
|
||||||
|
const parsed = parseLauncherJellyfinConfig({
|
||||||
|
jellyfin: {
|
||||||
|
enabled: true,
|
||||||
|
serverUrl: 'https://jf.example',
|
||||||
|
username: 'alice',
|
||||||
|
accessToken: 'legacy-token',
|
||||||
|
userId: 'legacy-user',
|
||||||
|
pullPictures: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(parsed.enabled, true);
|
||||||
|
assert.equal(parsed.serverUrl, 'https://jf.example');
|
||||||
|
assert.equal(parsed.username, 'alice');
|
||||||
|
assert.equal(parsed.pullPictures, true);
|
||||||
|
assert.equal('accessToken' in parsed, false);
|
||||||
|
assert.equal('userId' in parsed, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parsePluginRuntimeConfigContent reads socket_path and ignores inline comments', () => {
|
||||||
|
const parsed = parsePluginRuntimeConfigContent(`
|
||||||
|
# comment
|
||||||
|
socket_path = /tmp/custom.sock # trailing comment
|
||||||
|
`);
|
||||||
|
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
||||||
|
});
|
||||||
@@ -1,321 +1,36 @@
|
|||||||
import fs from 'node:fs';
|
import { fail } from './log.js';
|
||||||
import path from 'node:path';
|
|
||||||
import os from 'node:os';
|
|
||||||
import { Command } from 'commander';
|
|
||||||
import { parse as parseJsonc } from 'jsonc-parser';
|
|
||||||
import { resolveConfigFilePath } from '../src/config/path-resolution.js';
|
|
||||||
import type {
|
import type {
|
||||||
LogLevel,
|
|
||||||
YoutubeSubgenMode,
|
|
||||||
Backend,
|
|
||||||
Args,
|
Args,
|
||||||
LauncherYoutubeSubgenConfig,
|
|
||||||
LauncherJellyfinConfig,
|
LauncherJellyfinConfig,
|
||||||
|
LauncherYoutubeSubgenConfig,
|
||||||
|
LogLevel,
|
||||||
PluginRuntimeConfig,
|
PluginRuntimeConfig,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
import {
|
import {
|
||||||
DEFAULT_SOCKET_PATH,
|
applyInvocationsToArgs,
|
||||||
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
|
applyRootOptionsToArgs,
|
||||||
DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS,
|
createDefaultArgs,
|
||||||
DEFAULT_YOUTUBE_SUBGEN_OUT_DIR,
|
} from './config/args-normalizer.js';
|
||||||
DEFAULT_JIMAKU_API_BASE_URL,
|
import { parseCliPrograms, resolveTopLevelCommand } from './config/cli-parser-builder.js';
|
||||||
} from './types.js';
|
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||||
import { log, fail } from './log.js';
|
import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './config/plugin-runtime-config.js';
|
||||||
import {
|
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
|
||||||
resolvePathMaybe,
|
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||||
isUrlTarget,
|
|
||||||
uniqueNormalizedLangCodes,
|
|
||||||
inferWhisperLanguage,
|
|
||||||
} from './util.js';
|
|
||||||
|
|
||||||
function resolveLauncherMainConfigPath(): string {
|
|
||||||
return resolveConfigFilePath({
|
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
existsSync: fs.existsSync,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
|
export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
|
||||||
const configPath = resolveLauncherMainConfigPath();
|
const root = readLauncherMainConfigObject();
|
||||||
if (!fs.existsSync(configPath)) return {};
|
if (!root) return {};
|
||||||
|
return parseLauncherYoutubeSubgenConfig(root);
|
||||||
try {
|
|
||||||
const data = fs.readFileSync(configPath, 'utf8');
|
|
||||||
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
|
|
||||||
if (!parsed || typeof parsed !== 'object') return {};
|
|
||||||
const root = parsed as {
|
|
||||||
youtubeSubgen?: unknown;
|
|
||||||
secondarySub?: { secondarySubLanguages?: unknown };
|
|
||||||
jimaku?: unknown;
|
|
||||||
};
|
|
||||||
const youtubeSubgen = root.youtubeSubgen;
|
|
||||||
const mode =
|
|
||||||
youtubeSubgen && typeof youtubeSubgen === 'object'
|
|
||||||
? (youtubeSubgen as { mode?: unknown }).mode
|
|
||||||
: undefined;
|
|
||||||
const whisperBin =
|
|
||||||
youtubeSubgen && typeof youtubeSubgen === 'object'
|
|
||||||
? (youtubeSubgen as { whisperBin?: unknown }).whisperBin
|
|
||||||
: undefined;
|
|
||||||
const whisperModel =
|
|
||||||
youtubeSubgen && typeof youtubeSubgen === 'object'
|
|
||||||
? (youtubeSubgen as { whisperModel?: unknown }).whisperModel
|
|
||||||
: undefined;
|
|
||||||
const primarySubLanguagesRaw =
|
|
||||||
youtubeSubgen && typeof youtubeSubgen === 'object'
|
|
||||||
? (youtubeSubgen as { primarySubLanguages?: unknown }).primarySubLanguages
|
|
||||||
: undefined;
|
|
||||||
const secondarySubLanguagesRaw = root.secondarySub?.secondarySubLanguages;
|
|
||||||
const primarySubLanguages = Array.isArray(primarySubLanguagesRaw)
|
|
||||||
? primarySubLanguagesRaw.filter((value): value is string => typeof value === 'string')
|
|
||||||
: undefined;
|
|
||||||
const secondarySubLanguages = Array.isArray(secondarySubLanguagesRaw)
|
|
||||||
? secondarySubLanguagesRaw.filter((value): value is string => typeof value === 'string')
|
|
||||||
: undefined;
|
|
||||||
const jimaku = root.jimaku;
|
|
||||||
const jimakuApiKey =
|
|
||||||
jimaku && typeof jimaku === 'object' ? (jimaku as { apiKey?: unknown }).apiKey : undefined;
|
|
||||||
const jimakuApiKeyCommand =
|
|
||||||
jimaku && typeof jimaku === 'object'
|
|
||||||
? (jimaku as { apiKeyCommand?: unknown }).apiKeyCommand
|
|
||||||
: undefined;
|
|
||||||
const jimakuApiBaseUrl =
|
|
||||||
jimaku && typeof jimaku === 'object'
|
|
||||||
? (jimaku as { apiBaseUrl?: unknown }).apiBaseUrl
|
|
||||||
: undefined;
|
|
||||||
const jimakuLanguagePreference =
|
|
||||||
jimaku && typeof jimaku === 'object'
|
|
||||||
? (jimaku as { languagePreference?: unknown }).languagePreference
|
|
||||||
: undefined;
|
|
||||||
const jimakuMaxEntryResults =
|
|
||||||
jimaku && typeof jimaku === 'object'
|
|
||||||
? (jimaku as { maxEntryResults?: unknown }).maxEntryResults
|
|
||||||
: undefined;
|
|
||||||
const resolvedJimakuLanguagePreference =
|
|
||||||
jimakuLanguagePreference === 'ja' ||
|
|
||||||
jimakuLanguagePreference === 'en' ||
|
|
||||||
jimakuLanguagePreference === 'none'
|
|
||||||
? jimakuLanguagePreference
|
|
||||||
: undefined;
|
|
||||||
const resolvedJimakuMaxEntryResults =
|
|
||||||
typeof jimakuMaxEntryResults === 'number' &&
|
|
||||||
Number.isFinite(jimakuMaxEntryResults) &&
|
|
||||||
jimakuMaxEntryResults > 0
|
|
||||||
? Math.floor(jimakuMaxEntryResults)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return {
|
|
||||||
mode: mode === 'automatic' || mode === 'preprocess' || mode === 'off' ? mode : undefined,
|
|
||||||
whisperBin: typeof whisperBin === 'string' ? whisperBin : undefined,
|
|
||||||
whisperModel: typeof whisperModel === 'string' ? whisperModel : undefined,
|
|
||||||
primarySubLanguages,
|
|
||||||
secondarySubLanguages,
|
|
||||||
jimakuApiKey: typeof jimakuApiKey === 'string' ? jimakuApiKey : undefined,
|
|
||||||
jimakuApiKeyCommand:
|
|
||||||
typeof jimakuApiKeyCommand === 'string' ? jimakuApiKeyCommand : undefined,
|
|
||||||
jimakuApiBaseUrl: typeof jimakuApiBaseUrl === 'string' ? jimakuApiBaseUrl : undefined,
|
|
||||||
jimakuLanguagePreference: resolvedJimakuLanguagePreference,
|
|
||||||
jimakuMaxEntryResults: resolvedJimakuMaxEntryResults,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
|
export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
|
||||||
const configPath = resolveLauncherMainConfigPath();
|
const root = readLauncherMainConfigObject();
|
||||||
if (!fs.existsSync(configPath)) return {};
|
if (!root) return {};
|
||||||
|
return parseLauncherJellyfinConfig(root);
|
||||||
try {
|
|
||||||
const data = fs.readFileSync(configPath, 'utf8');
|
|
||||||
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
|
|
||||||
if (!parsed || typeof parsed !== 'object') return {};
|
|
||||||
const jellyfin = (parsed as { jellyfin?: unknown }).jellyfin;
|
|
||||||
if (!jellyfin || typeof jellyfin !== 'object') return {};
|
|
||||||
const typed = jellyfin as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
enabled: typeof typed.enabled === 'boolean' ? typed.enabled : undefined,
|
|
||||||
serverUrl: typeof typed.serverUrl === 'string' ? typed.serverUrl : undefined,
|
|
||||||
username: typeof typed.username === 'string' ? typed.username : undefined,
|
|
||||||
accessToken: typeof typed.accessToken === 'string' ? typed.accessToken : undefined,
|
|
||||||
userId: typeof typed.userId === 'string' ? typed.userId : undefined,
|
|
||||||
defaultLibraryId:
|
|
||||||
typeof typed.defaultLibraryId === 'string' ? typed.defaultLibraryId : undefined,
|
|
||||||
pullPictures: typeof typed.pullPictures === 'boolean' ? typed.pullPictures : undefined,
|
|
||||||
iconCacheDir: typeof typed.iconCacheDir === 'string' ? typed.iconCacheDir : undefined,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPluginConfigCandidates(): string[] {
|
|
||||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
||||||
return Array.from(
|
|
||||||
new Set([
|
|
||||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
|
||||||
path.join(os.homedir(), '.config', 'mpv', 'script-opts', 'subminer.conf'),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||||
const runtimeConfig: PluginRuntimeConfig = {
|
return readPluginRuntimeConfigValue(logLevel);
|
||||||
socketPath: DEFAULT_SOCKET_PATH,
|
|
||||||
};
|
|
||||||
const candidates = getPluginConfigCandidates();
|
|
||||||
|
|
||||||
for (const configPath of candidates) {
|
|
||||||
if (!fs.existsSync(configPath)) continue;
|
|
||||||
try {
|
|
||||||
const content = fs.readFileSync(configPath, 'utf8');
|
|
||||||
const lines = content.split(/\r?\n/);
|
|
||||||
for (const line of lines) {
|
|
||||||
const trimmed = line.trim();
|
|
||||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
|
||||||
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
|
|
||||||
if (socketMatch) {
|
|
||||||
const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || '';
|
|
||||||
if (value) runtimeConfig.socketPath = value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
log(
|
|
||||||
'debug',
|
|
||||||
logLevel,
|
|
||||||
`Using mpv plugin settings from ${configPath}: socket_path=${runtimeConfig.socketPath}`,
|
|
||||||
);
|
|
||||||
return runtimeConfig;
|
|
||||||
} catch {
|
|
||||||
log('warn', logLevel, `Failed to read ${configPath}; using launcher defaults`);
|
|
||||||
return runtimeConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log(
|
|
||||||
'debug',
|
|
||||||
logLevel,
|
|
||||||
`No mpv subminer.conf found; using launcher defaults (socket_path=${runtimeConfig.socketPath})`,
|
|
||||||
);
|
|
||||||
return runtimeConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureTarget(target: string, parsed: Args): void {
|
|
||||||
if (isUrlTarget(target)) {
|
|
||||||
parsed.target = target;
|
|
||||||
parsed.targetKind = 'url';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resolved = resolvePathMaybe(target);
|
|
||||||
let stat: fs.Stats | null = null;
|
|
||||||
try {
|
|
||||||
stat = fs.statSync(resolved);
|
|
||||||
} catch {
|
|
||||||
stat = null;
|
|
||||||
}
|
|
||||||
if (stat?.isFile()) {
|
|
||||||
parsed.target = resolved;
|
|
||||||
parsed.targetKind = 'file';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (stat?.isDirectory()) {
|
|
||||||
parsed.directory = resolved;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
fail(`Not a file, directory, or supported URL: ${target}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseLogLevel(value: string): LogLevel {
|
|
||||||
if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
fail(`Invalid log level: ${value} (must be debug, info, warn, or error)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseYoutubeMode(value: string): YoutubeSubgenMode {
|
|
||||||
const normalized = value.toLowerCase();
|
|
||||||
if (normalized === 'automatic' || normalized === 'preprocess' || normalized === 'off') {
|
|
||||||
return normalized as YoutubeSubgenMode;
|
|
||||||
}
|
|
||||||
fail(`Invalid yt-subgen mode: ${value} (must be automatic, preprocess, or off)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBackend(value: string): Backend {
|
|
||||||
if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') {
|
|
||||||
return value as Backend;
|
|
||||||
}
|
|
||||||
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyRootOptions(program: Command): void {
|
|
||||||
program
|
|
||||||
.option('-b, --backend <backend>', 'Display backend')
|
|
||||||
.option('-d, --directory <dir>', 'Directory to browse')
|
|
||||||
.option('-r, --recursive', 'Search directories recursively')
|
|
||||||
.option('-p, --profile <profile>', 'MPV profile')
|
|
||||||
.option('--start', 'Explicitly start overlay')
|
|
||||||
.option('--log-level <level>', 'Log level')
|
|
||||||
.option('-R, --rofi', 'Use rofi picker')
|
|
||||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
|
||||||
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSubcommandHelpText(program: Command): string {
|
|
||||||
const subcommands = program.commands
|
|
||||||
.filter((command) => command.name() !== 'help')
|
|
||||||
.map((command) => {
|
|
||||||
const aliases = command.aliases();
|
|
||||||
const term = aliases.length > 0 ? `${command.name()}|${aliases[0]}` : command.name();
|
|
||||||
return { term, description: command.description() };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (subcommands.length === 0) return '';
|
|
||||||
const longestTerm = Math.max(...subcommands.map((entry) => entry.term.length));
|
|
||||||
const lines = subcommands.map(
|
|
||||||
(entry) => ` ${entry.term.padEnd(longestTerm)} ${entry.description || ''}`.trimEnd(),
|
|
||||||
);
|
|
||||||
return `\nCommands:\n${lines.join('\n')}\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasTopLevelCommand(argv: string[]): boolean {
|
|
||||||
return getTopLevelCommand(argv) !== null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getTopLevelCommand(argv: string[]): { name: string; index: number } | null {
|
|
||||||
const commandNames = new Set([
|
|
||||||
'jellyfin',
|
|
||||||
'jf',
|
|
||||||
'yt',
|
|
||||||
'youtube',
|
|
||||||
'doctor',
|
|
||||||
'config',
|
|
||||||
'mpv',
|
|
||||||
'texthooker',
|
|
||||||
'app',
|
|
||||||
'bin',
|
|
||||||
'help',
|
|
||||||
]);
|
|
||||||
const optionsWithValue = new Set([
|
|
||||||
'-b',
|
|
||||||
'--backend',
|
|
||||||
'-d',
|
|
||||||
'--directory',
|
|
||||||
'-p',
|
|
||||||
'--profile',
|
|
||||||
'--log-level',
|
|
||||||
]);
|
|
||||||
for (let i = 0; i < argv.length; i += 1) {
|
|
||||||
const token = argv[i] || '';
|
|
||||||
if (token === '--') return null;
|
|
||||||
if (token.startsWith('-')) {
|
|
||||||
if (optionsWithValue.has(token)) {
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
return commandNames.has(token) ? { name: token, index: i } : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseArgs(
|
export function parseArgs(
|
||||||
@@ -323,378 +38,24 @@ export function parseArgs(
|
|||||||
scriptName: string,
|
scriptName: string,
|
||||||
launcherConfig: LauncherYoutubeSubgenConfig,
|
launcherConfig: LauncherYoutubeSubgenConfig,
|
||||||
): Args {
|
): Args {
|
||||||
const topLevelCommand = getTopLevelCommand(argv);
|
const topLevelCommand = resolveTopLevelCommand(argv);
|
||||||
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase();
|
const parsed = createDefaultArgs(launcherConfig);
|
||||||
const defaultMode: YoutubeSubgenMode =
|
|
||||||
envMode === 'preprocess' || envMode === 'off' || envMode === 'automatic'
|
|
||||||
? (envMode as YoutubeSubgenMode)
|
|
||||||
: launcherConfig.mode
|
|
||||||
? launcherConfig.mode
|
|
||||||
: 'automatic';
|
|
||||||
const configuredSecondaryLangs = uniqueNormalizedLangCodes(
|
|
||||||
launcherConfig.secondarySubLanguages ?? [],
|
|
||||||
);
|
|
||||||
const configuredPrimaryLangs = uniqueNormalizedLangCodes(
|
|
||||||
launcherConfig.primarySubLanguages ?? [],
|
|
||||||
);
|
|
||||||
const primarySubLangs =
|
|
||||||
configuredPrimaryLangs.length > 0
|
|
||||||
? configuredPrimaryLangs
|
|
||||||
: [...DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS];
|
|
||||||
const secondarySubLangs =
|
|
||||||
configuredSecondaryLangs.length > 0
|
|
||||||
? configuredSecondaryLangs
|
|
||||||
: [...DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS];
|
|
||||||
const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]);
|
|
||||||
const parsed: Args = {
|
|
||||||
backend: 'auto',
|
|
||||||
directory: '.',
|
|
||||||
recursive: false,
|
|
||||||
profile: 'subminer',
|
|
||||||
startOverlay: false,
|
|
||||||
youtubeSubgenMode: defaultMode,
|
|
||||||
whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '',
|
|
||||||
whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '',
|
|
||||||
youtubeSubgenOutDir: process.env.SUBMINER_YT_SUBGEN_OUT_DIR || DEFAULT_YOUTUBE_SUBGEN_OUT_DIR,
|
|
||||||
youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a',
|
|
||||||
youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1',
|
|
||||||
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
|
|
||||||
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
|
|
||||||
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
|
|
||||||
jimakuLanguagePreference: launcherConfig.jimakuLanguagePreference || 'ja',
|
|
||||||
jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10,
|
|
||||||
jellyfin: false,
|
|
||||||
jellyfinLogin: false,
|
|
||||||
jellyfinLogout: false,
|
|
||||||
jellyfinPlay: false,
|
|
||||||
jellyfinDiscovery: false,
|
|
||||||
doctor: false,
|
|
||||||
configPath: false,
|
|
||||||
configShow: false,
|
|
||||||
mpvIdle: false,
|
|
||||||
mpvSocket: false,
|
|
||||||
mpvStatus: false,
|
|
||||||
appPassthrough: false,
|
|
||||||
appArgs: [],
|
|
||||||
jellyfinServer: '',
|
|
||||||
jellyfinUsername: '',
|
|
||||||
jellyfinPassword: '',
|
|
||||||
youtubePrimarySubLangs: primarySubLangs,
|
|
||||||
youtubeSecondarySubLangs: secondarySubLangs,
|
|
||||||
youtubeAudioLangs,
|
|
||||||
youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, 'ja'),
|
|
||||||
useTexthooker: true,
|
|
||||||
autoStartOverlay: false,
|
|
||||||
texthookerOnly: false,
|
|
||||||
useRofi: false,
|
|
||||||
logLevel: 'info',
|
|
||||||
target: '',
|
|
||||||
targetKind: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
if (launcherConfig.jimakuApiKey) parsed.jimakuApiKey = launcherConfig.jimakuApiKey;
|
|
||||||
if (launcherConfig.jimakuApiKeyCommand)
|
|
||||||
parsed.jimakuApiKeyCommand = launcherConfig.jimakuApiKeyCommand;
|
|
||||||
if (launcherConfig.jimakuApiBaseUrl) parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl;
|
|
||||||
if (launcherConfig.jimakuLanguagePreference)
|
|
||||||
parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference;
|
|
||||||
if (launcherConfig.jimakuMaxEntryResults !== undefined)
|
|
||||||
parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults;
|
|
||||||
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
|
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
|
||||||
parsed.appPassthrough = true;
|
parsed.appPassthrough = true;
|
||||||
parsed.appArgs = argv.slice(topLevelCommand.index + 1);
|
parsed.appArgs = argv.slice(topLevelCommand.index + 1);
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
let jellyfinInvocation: {
|
let cliResult: ReturnType<typeof parseCliPrograms>;
|
||||||
action?: string;
|
|
||||||
discovery?: boolean;
|
|
||||||
play?: boolean;
|
|
||||||
login?: boolean;
|
|
||||||
logout?: boolean;
|
|
||||||
setup?: boolean;
|
|
||||||
server?: string;
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
logLevel?: string;
|
|
||||||
} | null = null;
|
|
||||||
let ytInvocation: {
|
|
||||||
target?: string;
|
|
||||||
mode?: string;
|
|
||||||
outDir?: string;
|
|
||||||
keepTemp?: boolean;
|
|
||||||
whisperBin?: string;
|
|
||||||
whisperModel?: string;
|
|
||||||
ytSubgenAudioFormat?: string;
|
|
||||||
logLevel?: string;
|
|
||||||
} | null = null;
|
|
||||||
let configInvocation: { action: string; logLevel?: string } | null = null;
|
|
||||||
let mpvInvocation: { action: string; logLevel?: string } | null = null;
|
|
||||||
let appInvocation: { appArgs: string[] } | null = null;
|
|
||||||
let doctorLogLevel: string | null = null;
|
|
||||||
let texthookerLogLevel: string | null = null;
|
|
||||||
|
|
||||||
const commandProgram = new Command();
|
|
||||||
commandProgram
|
|
||||||
.name(scriptName)
|
|
||||||
.description('Launch MPV with SubMiner sentence mining overlay')
|
|
||||||
.showHelpAfterError(true)
|
|
||||||
.enablePositionalOptions()
|
|
||||||
.allowExcessArguments(false)
|
|
||||||
.allowUnknownOption(false)
|
|
||||||
.exitOverride();
|
|
||||||
applyRootOptions(commandProgram);
|
|
||||||
|
|
||||||
const rootProgram = new Command();
|
|
||||||
rootProgram
|
|
||||||
.name(scriptName)
|
|
||||||
.description('Launch MPV with SubMiner sentence mining overlay')
|
|
||||||
.usage('[options] [command] [target]')
|
|
||||||
.showHelpAfterError(true)
|
|
||||||
.allowExcessArguments(false)
|
|
||||||
.allowUnknownOption(false)
|
|
||||||
.exitOverride()
|
|
||||||
.argument('[target]', 'file, directory, or URL');
|
|
||||||
applyRootOptions(rootProgram);
|
|
||||||
|
|
||||||
commandProgram
|
|
||||||
.command('jellyfin')
|
|
||||||
.alias('jf')
|
|
||||||
.description('Jellyfin workflows')
|
|
||||||
.argument('[action]', 'setup|discovery|play|login|logout')
|
|
||||||
.option('-d, --discovery', 'Cast discovery mode')
|
|
||||||
.option('-p, --play', 'Interactive play picker')
|
|
||||||
.option('-l, --login', 'Login flow')
|
|
||||||
.option('--logout', 'Clear token/session')
|
|
||||||
.option('--setup', 'Open setup window')
|
|
||||||
.option('-s, --server <url>', 'Jellyfin server URL')
|
|
||||||
.option('-u, --username <name>', 'Jellyfin username')
|
|
||||||
.option('-w, --password <pass>', 'Jellyfin password')
|
|
||||||
.option('--log-level <level>', 'Log level')
|
|
||||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
|
||||||
jellyfinInvocation = {
|
|
||||||
action,
|
|
||||||
discovery: options.discovery === true,
|
|
||||||
play: options.play === true,
|
|
||||||
login: options.login === true,
|
|
||||||
logout: options.logout === true,
|
|
||||||
setup: options.setup === true,
|
|
||||||
server: typeof options.server === 'string' ? options.server : undefined,
|
|
||||||
username: typeof options.username === 'string' ? options.username : undefined,
|
|
||||||
password: typeof options.password === 'string' ? options.password : undefined,
|
|
||||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
commandProgram
|
|
||||||
.command('yt')
|
|
||||||
.alias('youtube')
|
|
||||||
.description('YouTube workflows')
|
|
||||||
.argument('[target]', 'YouTube URL or ytsearch: query')
|
|
||||||
.option('-m, --mode <mode>', 'Subtitle generation mode')
|
|
||||||
.option('-o, --out-dir <dir>', 'Subtitle output dir')
|
|
||||||
.option('--keep-temp', 'Keep temp files')
|
|
||||||
.option('--whisper-bin <path>', 'whisper.cpp CLI path')
|
|
||||||
.option('--whisper-model <path>', 'whisper model path')
|
|
||||||
.option('--yt-subgen-audio-format <format>', 'Audio extraction format')
|
|
||||||
.option('--log-level <level>', 'Log level')
|
|
||||||
.action((target: string | undefined, options: Record<string, unknown>) => {
|
|
||||||
ytInvocation = {
|
|
||||||
target,
|
|
||||||
mode: typeof options.mode === 'string' ? options.mode : undefined,
|
|
||||||
outDir: typeof options.outDir === 'string' ? options.outDir : undefined,
|
|
||||||
keepTemp: options.keepTemp === true,
|
|
||||||
whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined,
|
|
||||||
whisperModel: typeof options.whisperModel === 'string' ? options.whisperModel : undefined,
|
|
||||||
ytSubgenAudioFormat:
|
|
||||||
typeof options.ytSubgenAudioFormat === 'string' ? options.ytSubgenAudioFormat : undefined,
|
|
||||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
commandProgram
|
|
||||||
.command('doctor')
|
|
||||||
.description('Run dependency and environment checks')
|
|
||||||
.option('--log-level <level>', 'Log level')
|
|
||||||
.action((options: Record<string, unknown>) => {
|
|
||||||
parsed.doctor = true;
|
|
||||||
doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
commandProgram
|
|
||||||
.command('config')
|
|
||||||
.description('Config helpers')
|
|
||||||
.argument('[action]', 'path|show', 'path')
|
|
||||||
.option('--log-level <level>', 'Log level')
|
|
||||||
.action((action: string, options: Record<string, unknown>) => {
|
|
||||||
configInvocation = {
|
|
||||||
action,
|
|
||||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
commandProgram
|
|
||||||
.command('mpv')
|
|
||||||
.description('MPV helpers')
|
|
||||||
.argument('[action]', 'status|socket|idle', 'status')
|
|
||||||
.option('--log-level <level>', 'Log level')
|
|
||||||
.action((action: string, options: Record<string, unknown>) => {
|
|
||||||
mpvInvocation = {
|
|
||||||
action,
|
|
||||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
commandProgram
|
|
||||||
.command('texthooker')
|
|
||||||
.description('Launch texthooker-only mode')
|
|
||||||
.option('--log-level <level>', 'Log level')
|
|
||||||
.action((options: Record<string, unknown>) => {
|
|
||||||
parsed.texthookerOnly = true;
|
|
||||||
texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
|
||||||
});
|
|
||||||
|
|
||||||
commandProgram
|
|
||||||
.command('app')
|
|
||||||
.alias('bin')
|
|
||||||
.description('Pass arguments directly to SubMiner binary')
|
|
||||||
.allowUnknownOption(true)
|
|
||||||
.allowExcessArguments(true)
|
|
||||||
.argument('[appArgs...]', 'Arguments forwarded to SubMiner app binary')
|
|
||||||
.action((appArgs: string[] | undefined) => {
|
|
||||||
appInvocation = {
|
|
||||||
appArgs: Array.isArray(appArgs) ? appArgs : [],
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
rootProgram.addHelpText('after', buildSubcommandHelpText(commandProgram));
|
|
||||||
|
|
||||||
const selectedProgram = hasTopLevelCommand(argv) ? commandProgram : rootProgram;
|
|
||||||
try {
|
try {
|
||||||
selectedProgram.parse(['node', scriptName, ...argv]);
|
cliResult = parseCliPrograms(argv, scriptName);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const commanderError = error as { code?: string; message?: string };
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (commanderError?.code === 'commander.helpDisplayed') {
|
fail(message);
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
fail(commanderError?.message || String(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = selectedProgram.opts<Record<string, unknown>>();
|
|
||||||
if (typeof options.backend === 'string') {
|
|
||||||
parsed.backend = parseBackend(options.backend);
|
|
||||||
}
|
|
||||||
if (typeof options.directory === 'string') {
|
|
||||||
parsed.directory = options.directory;
|
|
||||||
}
|
|
||||||
if (options.recursive === true) parsed.recursive = true;
|
|
||||||
if (typeof options.profile === 'string') {
|
|
||||||
parsed.profile = options.profile;
|
|
||||||
}
|
|
||||||
if (options.start === true) parsed.startOverlay = true;
|
|
||||||
if (typeof options.logLevel === 'string') {
|
|
||||||
parsed.logLevel = parseLogLevel(options.logLevel);
|
|
||||||
}
|
|
||||||
if (options.rofi === true) parsed.useRofi = true;
|
|
||||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
|
||||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
|
||||||
|
|
||||||
const rootTarget = rootProgram.processedArgs[0];
|
|
||||||
if (typeof rootTarget === 'string' && rootTarget) {
|
|
||||||
ensureTarget(rootTarget, parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (jellyfinInvocation) {
|
|
||||||
if (jellyfinInvocation.logLevel) {
|
|
||||||
parsed.logLevel = parseLogLevel(jellyfinInvocation.logLevel);
|
|
||||||
}
|
|
||||||
const action = (jellyfinInvocation.action || '').toLowerCase();
|
|
||||||
if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) {
|
|
||||||
fail(`Unknown jellyfin action: ${jellyfinInvocation.action}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed.jellyfinServer = jellyfinInvocation.server || '';
|
|
||||||
parsed.jellyfinUsername = jellyfinInvocation.username || '';
|
|
||||||
parsed.jellyfinPassword = jellyfinInvocation.password || '';
|
|
||||||
|
|
||||||
const modeFlags = {
|
|
||||||
setup: jellyfinInvocation.setup || action === 'setup',
|
|
||||||
discovery: jellyfinInvocation.discovery || action === 'discovery',
|
|
||||||
play: jellyfinInvocation.play || action === 'play',
|
|
||||||
login: jellyfinInvocation.login || action === 'login',
|
|
||||||
logout: jellyfinInvocation.logout || action === 'logout',
|
|
||||||
};
|
|
||||||
if (
|
|
||||||
!modeFlags.setup &&
|
|
||||||
!modeFlags.discovery &&
|
|
||||||
!modeFlags.play &&
|
|
||||||
!modeFlags.login &&
|
|
||||||
!modeFlags.logout
|
|
||||||
) {
|
|
||||||
modeFlags.setup = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
parsed.jellyfin = Boolean(modeFlags.setup);
|
|
||||||
parsed.jellyfinDiscovery = Boolean(modeFlags.discovery);
|
|
||||||
parsed.jellyfinPlay = Boolean(modeFlags.play);
|
|
||||||
parsed.jellyfinLogin = Boolean(modeFlags.login);
|
|
||||||
parsed.jellyfinLogout = Boolean(modeFlags.logout);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ytInvocation) {
|
|
||||||
if (ytInvocation.logLevel) {
|
|
||||||
parsed.logLevel = parseLogLevel(ytInvocation.logLevel);
|
|
||||||
}
|
|
||||||
const mode = ytInvocation.mode;
|
|
||||||
if (mode) parsed.youtubeSubgenMode = parseYoutubeMode(mode);
|
|
||||||
const outDir = ytInvocation.outDir;
|
|
||||||
if (outDir) parsed.youtubeSubgenOutDir = outDir;
|
|
||||||
if (ytInvocation.keepTemp) {
|
|
||||||
parsed.youtubeSubgenKeepTemp = true;
|
|
||||||
}
|
|
||||||
if (ytInvocation.whisperBin) parsed.whisperBin = ytInvocation.whisperBin;
|
|
||||||
if (ytInvocation.whisperModel) parsed.whisperModel = ytInvocation.whisperModel;
|
|
||||||
if (ytInvocation.ytSubgenAudioFormat) {
|
|
||||||
parsed.youtubeSubgenAudioFormat = ytInvocation.ytSubgenAudioFormat;
|
|
||||||
}
|
|
||||||
if (ytInvocation.target) {
|
|
||||||
ensureTarget(ytInvocation.target, parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (doctorLogLevel) {
|
|
||||||
parsed.logLevel = parseLogLevel(doctorLogLevel);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (texthookerLogLevel) {
|
|
||||||
parsed.logLevel = parseLogLevel(texthookerLogLevel);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configInvocation !== null) {
|
|
||||||
if (configInvocation.logLevel) {
|
|
||||||
parsed.logLevel = parseLogLevel(configInvocation.logLevel);
|
|
||||||
}
|
|
||||||
const action = (configInvocation.action || 'path').toLowerCase();
|
|
||||||
if (action === 'path') parsed.configPath = true;
|
|
||||||
else if (action === 'show') parsed.configShow = true;
|
|
||||||
else fail(`Unknown config action: ${configInvocation.action}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (mpvInvocation !== null) {
|
|
||||||
if (mpvInvocation.logLevel) {
|
|
||||||
parsed.logLevel = parseLogLevel(mpvInvocation.logLevel);
|
|
||||||
}
|
|
||||||
const action = (mpvInvocation.action || 'status').toLowerCase();
|
|
||||||
if (action === 'status') parsed.mpvStatus = true;
|
|
||||||
else if (action === 'socket') parsed.mpvSocket = true;
|
|
||||||
else if (action === 'idle' || action === 'start') parsed.mpvIdle = true;
|
|
||||||
else fail(`Unknown mpv action: ${mpvInvocation.action}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (appInvocation !== null) {
|
|
||||||
parsed.appPassthrough = true;
|
|
||||||
parsed.appArgs = appInvocation.appArgs;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
applyRootOptionsToArgs(parsed, cliResult.options, cliResult.rootTarget);
|
||||||
|
applyInvocationsToArgs(parsed, cliResult.invocations);
|
||||||
return parsed;
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|||||||
257
launcher/config/args-normalizer.ts
Normal file
257
launcher/config/args-normalizer.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import { fail } from '../log.js';
|
||||||
|
import type {
|
||||||
|
Args,
|
||||||
|
Backend,
|
||||||
|
LauncherYoutubeSubgenConfig,
|
||||||
|
LogLevel,
|
||||||
|
YoutubeSubgenMode,
|
||||||
|
} from '../types.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_JIMAKU_API_BASE_URL,
|
||||||
|
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
|
||||||
|
DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS,
|
||||||
|
DEFAULT_YOUTUBE_SUBGEN_OUT_DIR,
|
||||||
|
} from '../types.js';
|
||||||
|
import {
|
||||||
|
inferWhisperLanguage,
|
||||||
|
isUrlTarget,
|
||||||
|
resolvePathMaybe,
|
||||||
|
uniqueNormalizedLangCodes,
|
||||||
|
} from '../util.js';
|
||||||
|
import type { CliInvocations } from './cli-parser-builder.js';
|
||||||
|
|
||||||
|
function ensureTarget(target: string, parsed: Args): void {
|
||||||
|
if (isUrlTarget(target)) {
|
||||||
|
parsed.target = target;
|
||||||
|
parsed.targetKind = 'url';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const resolved = resolvePathMaybe(target);
|
||||||
|
let stat: fs.Stats | null = null;
|
||||||
|
try {
|
||||||
|
stat = fs.statSync(resolved);
|
||||||
|
} catch {
|
||||||
|
stat = null;
|
||||||
|
}
|
||||||
|
if (stat?.isFile()) {
|
||||||
|
parsed.target = resolved;
|
||||||
|
parsed.targetKind = 'file';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (stat?.isDirectory()) {
|
||||||
|
parsed.directory = resolved;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fail(`Not a file, directory, or supported URL: ${target}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLogLevel(value: string): LogLevel {
|
||||||
|
if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
fail(`Invalid log level: ${value} (must be debug, info, warn, or error)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseYoutubeMode(value: string): YoutubeSubgenMode {
|
||||||
|
const normalized = value.toLowerCase();
|
||||||
|
if (normalized === 'automatic' || normalized === 'preprocess' || normalized === 'off') {
|
||||||
|
return normalized as YoutubeSubgenMode;
|
||||||
|
}
|
||||||
|
fail(`Invalid yt-subgen mode: ${value} (must be automatic, preprocess, or off)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBackend(value: string): Backend {
|
||||||
|
if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') {
|
||||||
|
return value as Backend;
|
||||||
|
}
|
||||||
|
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): Args {
|
||||||
|
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase();
|
||||||
|
const defaultMode: YoutubeSubgenMode =
|
||||||
|
envMode === 'preprocess' || envMode === 'off' || envMode === 'automatic'
|
||||||
|
? (envMode as YoutubeSubgenMode)
|
||||||
|
: launcherConfig.mode
|
||||||
|
? launcherConfig.mode
|
||||||
|
: 'automatic';
|
||||||
|
const configuredSecondaryLangs = uniqueNormalizedLangCodes(
|
||||||
|
launcherConfig.secondarySubLanguages ?? [],
|
||||||
|
);
|
||||||
|
const configuredPrimaryLangs = uniqueNormalizedLangCodes(
|
||||||
|
launcherConfig.primarySubLanguages ?? [],
|
||||||
|
);
|
||||||
|
const primarySubLangs =
|
||||||
|
configuredPrimaryLangs.length > 0
|
||||||
|
? configuredPrimaryLangs
|
||||||
|
: [...DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS];
|
||||||
|
const secondarySubLangs =
|
||||||
|
configuredSecondaryLangs.length > 0
|
||||||
|
? configuredSecondaryLangs
|
||||||
|
: [...DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS];
|
||||||
|
const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]);
|
||||||
|
|
||||||
|
const parsed: Args = {
|
||||||
|
backend: 'auto',
|
||||||
|
directory: '.',
|
||||||
|
recursive: false,
|
||||||
|
profile: 'subminer',
|
||||||
|
startOverlay: false,
|
||||||
|
youtubeSubgenMode: defaultMode,
|
||||||
|
whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '',
|
||||||
|
whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '',
|
||||||
|
youtubeSubgenOutDir: process.env.SUBMINER_YT_SUBGEN_OUT_DIR || DEFAULT_YOUTUBE_SUBGEN_OUT_DIR,
|
||||||
|
youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a',
|
||||||
|
youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1',
|
||||||
|
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
|
||||||
|
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
|
||||||
|
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
|
||||||
|
jimakuLanguagePreference: launcherConfig.jimakuLanguagePreference || 'ja',
|
||||||
|
jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10,
|
||||||
|
jellyfin: false,
|
||||||
|
jellyfinLogin: false,
|
||||||
|
jellyfinLogout: false,
|
||||||
|
jellyfinPlay: false,
|
||||||
|
jellyfinDiscovery: false,
|
||||||
|
doctor: false,
|
||||||
|
configPath: false,
|
||||||
|
configShow: false,
|
||||||
|
mpvIdle: false,
|
||||||
|
mpvSocket: false,
|
||||||
|
mpvStatus: false,
|
||||||
|
appPassthrough: false,
|
||||||
|
appArgs: [],
|
||||||
|
jellyfinServer: '',
|
||||||
|
jellyfinUsername: '',
|
||||||
|
jellyfinPassword: '',
|
||||||
|
youtubePrimarySubLangs: primarySubLangs,
|
||||||
|
youtubeSecondarySubLangs: secondarySubLangs,
|
||||||
|
youtubeAudioLangs,
|
||||||
|
youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, 'ja'),
|
||||||
|
useTexthooker: true,
|
||||||
|
autoStartOverlay: false,
|
||||||
|
texthookerOnly: false,
|
||||||
|
useRofi: false,
|
||||||
|
logLevel: 'info',
|
||||||
|
target: '',
|
||||||
|
targetKind: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (launcherConfig.jimakuApiKey) parsed.jimakuApiKey = launcherConfig.jimakuApiKey;
|
||||||
|
if (launcherConfig.jimakuApiKeyCommand)
|
||||||
|
parsed.jimakuApiKeyCommand = launcherConfig.jimakuApiKeyCommand;
|
||||||
|
if (launcherConfig.jimakuApiBaseUrl) parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl;
|
||||||
|
if (launcherConfig.jimakuLanguagePreference)
|
||||||
|
parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference;
|
||||||
|
if (launcherConfig.jimakuMaxEntryResults !== undefined)
|
||||||
|
parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults;
|
||||||
|
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyRootOptionsToArgs(
|
||||||
|
parsed: Args,
|
||||||
|
options: Record<string, unknown>,
|
||||||
|
rootTarget: unknown,
|
||||||
|
): void {
|
||||||
|
if (typeof options.backend === 'string') parsed.backend = parseBackend(options.backend);
|
||||||
|
if (typeof options.directory === 'string') parsed.directory = options.directory;
|
||||||
|
if (options.recursive === true) parsed.recursive = true;
|
||||||
|
if (typeof options.profile === 'string') parsed.profile = options.profile;
|
||||||
|
if (options.start === true) parsed.startOverlay = true;
|
||||||
|
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||||
|
if (options.rofi === true) parsed.useRofi = true;
|
||||||
|
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||||
|
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||||
|
if (typeof rootTarget === 'string' && rootTarget) ensureTarget(rootTarget, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||||
|
if (invocations.doctorTriggered) parsed.doctor = true;
|
||||||
|
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
|
||||||
|
|
||||||
|
if (invocations.jellyfinInvocation) {
|
||||||
|
if (invocations.jellyfinInvocation.logLevel) {
|
||||||
|
parsed.logLevel = parseLogLevel(invocations.jellyfinInvocation.logLevel);
|
||||||
|
}
|
||||||
|
const action = (invocations.jellyfinInvocation.action || '').toLowerCase();
|
||||||
|
if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) {
|
||||||
|
fail(`Unknown jellyfin action: ${invocations.jellyfinInvocation.action}`);
|
||||||
|
}
|
||||||
|
parsed.jellyfinServer = invocations.jellyfinInvocation.server || '';
|
||||||
|
parsed.jellyfinUsername = invocations.jellyfinInvocation.username || '';
|
||||||
|
parsed.jellyfinPassword = invocations.jellyfinInvocation.password || '';
|
||||||
|
|
||||||
|
const modeFlags = {
|
||||||
|
setup: invocations.jellyfinInvocation.setup || action === 'setup',
|
||||||
|
discovery: invocations.jellyfinInvocation.discovery || action === 'discovery',
|
||||||
|
play: invocations.jellyfinInvocation.play || action === 'play',
|
||||||
|
login: invocations.jellyfinInvocation.login || action === 'login',
|
||||||
|
logout: invocations.jellyfinInvocation.logout || action === 'logout',
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!modeFlags.setup &&
|
||||||
|
!modeFlags.discovery &&
|
||||||
|
!modeFlags.play &&
|
||||||
|
!modeFlags.login &&
|
||||||
|
!modeFlags.logout
|
||||||
|
) {
|
||||||
|
modeFlags.setup = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
parsed.jellyfin = Boolean(modeFlags.setup);
|
||||||
|
parsed.jellyfinDiscovery = Boolean(modeFlags.discovery);
|
||||||
|
parsed.jellyfinPlay = Boolean(modeFlags.play);
|
||||||
|
parsed.jellyfinLogin = Boolean(modeFlags.login);
|
||||||
|
parsed.jellyfinLogout = Boolean(modeFlags.logout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invocations.ytInvocation) {
|
||||||
|
if (invocations.ytInvocation.logLevel)
|
||||||
|
parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel);
|
||||||
|
if (invocations.ytInvocation.mode)
|
||||||
|
parsed.youtubeSubgenMode = parseYoutubeMode(invocations.ytInvocation.mode);
|
||||||
|
if (invocations.ytInvocation.outDir)
|
||||||
|
parsed.youtubeSubgenOutDir = invocations.ytInvocation.outDir;
|
||||||
|
if (invocations.ytInvocation.keepTemp) parsed.youtubeSubgenKeepTemp = true;
|
||||||
|
if (invocations.ytInvocation.whisperBin)
|
||||||
|
parsed.whisperBin = invocations.ytInvocation.whisperBin;
|
||||||
|
if (invocations.ytInvocation.whisperModel)
|
||||||
|
parsed.whisperModel = invocations.ytInvocation.whisperModel;
|
||||||
|
if (invocations.ytInvocation.ytSubgenAudioFormat) {
|
||||||
|
parsed.youtubeSubgenAudioFormat = invocations.ytInvocation.ytSubgenAudioFormat;
|
||||||
|
}
|
||||||
|
if (invocations.ytInvocation.target) ensureTarget(invocations.ytInvocation.target, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invocations.doctorLogLevel) parsed.logLevel = parseLogLevel(invocations.doctorLogLevel);
|
||||||
|
if (invocations.texthookerLogLevel)
|
||||||
|
parsed.logLevel = parseLogLevel(invocations.texthookerLogLevel);
|
||||||
|
|
||||||
|
if (invocations.configInvocation) {
|
||||||
|
if (invocations.configInvocation.logLevel) {
|
||||||
|
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
|
||||||
|
}
|
||||||
|
const action = (invocations.configInvocation.action || 'path').toLowerCase();
|
||||||
|
if (action === 'path') parsed.configPath = true;
|
||||||
|
else if (action === 'show') parsed.configShow = true;
|
||||||
|
else fail(`Unknown config action: ${invocations.configInvocation.action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invocations.mpvInvocation) {
|
||||||
|
if (invocations.mpvInvocation.logLevel) {
|
||||||
|
parsed.logLevel = parseLogLevel(invocations.mpvInvocation.logLevel);
|
||||||
|
}
|
||||||
|
const action = (invocations.mpvInvocation.action || 'status').toLowerCase();
|
||||||
|
if (action === 'status') parsed.mpvStatus = true;
|
||||||
|
else if (action === 'socket') parsed.mpvSocket = true;
|
||||||
|
else if (action === 'idle' || action === 'start') parsed.mpvIdle = true;
|
||||||
|
else fail(`Unknown mpv action: ${invocations.mpvInvocation.action}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invocations.appInvocation) {
|
||||||
|
parsed.appPassthrough = true;
|
||||||
|
parsed.appArgs = invocations.appInvocation.appArgs;
|
||||||
|
}
|
||||||
|
}
|
||||||
294
launcher/config/cli-parser-builder.ts
Normal file
294
launcher/config/cli-parser-builder.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import { Command } from 'commander';
|
||||||
|
|
||||||
|
export interface JellyfinInvocation {
|
||||||
|
action?: string;
|
||||||
|
discovery?: boolean;
|
||||||
|
play?: boolean;
|
||||||
|
login?: boolean;
|
||||||
|
logout?: boolean;
|
||||||
|
setup?: boolean;
|
||||||
|
server?: string;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
logLevel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface YtInvocation {
|
||||||
|
target?: string;
|
||||||
|
mode?: string;
|
||||||
|
outDir?: string;
|
||||||
|
keepTemp?: boolean;
|
||||||
|
whisperBin?: string;
|
||||||
|
whisperModel?: string;
|
||||||
|
ytSubgenAudioFormat?: string;
|
||||||
|
logLevel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommandActionInvocation {
|
||||||
|
action: string;
|
||||||
|
logLevel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CliInvocations {
|
||||||
|
jellyfinInvocation: JellyfinInvocation | null;
|
||||||
|
ytInvocation: YtInvocation | null;
|
||||||
|
configInvocation: CommandActionInvocation | null;
|
||||||
|
mpvInvocation: CommandActionInvocation | null;
|
||||||
|
appInvocation: { appArgs: string[] } | null;
|
||||||
|
doctorTriggered: boolean;
|
||||||
|
doctorLogLevel: string | null;
|
||||||
|
texthookerTriggered: boolean;
|
||||||
|
texthookerLogLevel: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRootOptions(program: Command): void {
|
||||||
|
program
|
||||||
|
.option('-b, --backend <backend>', 'Display backend')
|
||||||
|
.option('-d, --directory <dir>', 'Directory to browse')
|
||||||
|
.option('-r, --recursive', 'Search directories recursively')
|
||||||
|
.option('-p, --profile <profile>', 'MPV profile')
|
||||||
|
.option('--start', 'Explicitly start overlay')
|
||||||
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.option('-R, --rofi', 'Use rofi picker')
|
||||||
|
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||||
|
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSubcommandHelpText(program: Command): string {
|
||||||
|
const subcommands = program.commands
|
||||||
|
.filter((command) => command.name() !== 'help')
|
||||||
|
.map((command) => {
|
||||||
|
const aliases = command.aliases();
|
||||||
|
const term = aliases.length > 0 ? `${command.name()}|${aliases[0]}` : command.name();
|
||||||
|
return { term, description: command.description() };
|
||||||
|
});
|
||||||
|
|
||||||
|
if (subcommands.length === 0) return '';
|
||||||
|
const longestTerm = Math.max(...subcommands.map((entry) => entry.term.length));
|
||||||
|
const lines = subcommands.map((entry) =>
|
||||||
|
` ${entry.term.padEnd(longestTerm)} ${entry.description || ''}`.trimEnd(),
|
||||||
|
);
|
||||||
|
return `\nCommands:\n${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTopLevelCommand(argv: string[]): { name: string; index: number } | null {
|
||||||
|
const commandNames = new Set([
|
||||||
|
'jellyfin',
|
||||||
|
'jf',
|
||||||
|
'yt',
|
||||||
|
'youtube',
|
||||||
|
'doctor',
|
||||||
|
'config',
|
||||||
|
'mpv',
|
||||||
|
'texthooker',
|
||||||
|
'app',
|
||||||
|
'bin',
|
||||||
|
'help',
|
||||||
|
]);
|
||||||
|
const optionsWithValue = new Set([
|
||||||
|
'-b',
|
||||||
|
'--backend',
|
||||||
|
'-d',
|
||||||
|
'--directory',
|
||||||
|
'-p',
|
||||||
|
'--profile',
|
||||||
|
'--log-level',
|
||||||
|
]);
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const token = argv[i] || '';
|
||||||
|
if (token === '--') return null;
|
||||||
|
if (token.startsWith('-')) {
|
||||||
|
if (optionsWithValue.has(token)) i += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return commandNames.has(token) ? { name: token, index: i } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasTopLevelCommand(argv: string[]): boolean {
|
||||||
|
return getTopLevelCommand(argv) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTopLevelCommand(argv: string[]): { name: string; index: number } | null {
|
||||||
|
return getTopLevelCommand(argv);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCliPrograms(
|
||||||
|
argv: string[],
|
||||||
|
scriptName: string,
|
||||||
|
): {
|
||||||
|
options: Record<string, unknown>;
|
||||||
|
rootTarget: unknown;
|
||||||
|
invocations: CliInvocations;
|
||||||
|
} {
|
||||||
|
let jellyfinInvocation: JellyfinInvocation | null = null;
|
||||||
|
let ytInvocation: YtInvocation | null = null;
|
||||||
|
let configInvocation: CommandActionInvocation | null = null;
|
||||||
|
let mpvInvocation: CommandActionInvocation | null = null;
|
||||||
|
let appInvocation: { appArgs: string[] } | null = null;
|
||||||
|
let doctorLogLevel: string | null = null;
|
||||||
|
let texthookerLogLevel: string | null = null;
|
||||||
|
let doctorTriggered = false;
|
||||||
|
let texthookerTriggered = false;
|
||||||
|
|
||||||
|
const commandProgram = new Command();
|
||||||
|
commandProgram
|
||||||
|
.name(scriptName)
|
||||||
|
.description('Launch MPV with SubMiner sentence mining overlay')
|
||||||
|
.showHelpAfterError(true)
|
||||||
|
.enablePositionalOptions()
|
||||||
|
.allowExcessArguments(false)
|
||||||
|
.allowUnknownOption(false)
|
||||||
|
.exitOverride();
|
||||||
|
applyRootOptions(commandProgram);
|
||||||
|
|
||||||
|
const rootProgram = new Command();
|
||||||
|
rootProgram
|
||||||
|
.name(scriptName)
|
||||||
|
.description('Launch MPV with SubMiner sentence mining overlay')
|
||||||
|
.usage('[options] [command] [target]')
|
||||||
|
.showHelpAfterError(true)
|
||||||
|
.allowExcessArguments(false)
|
||||||
|
.allowUnknownOption(false)
|
||||||
|
.exitOverride()
|
||||||
|
.argument('[target]', 'file, directory, or URL');
|
||||||
|
applyRootOptions(rootProgram);
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('jellyfin')
|
||||||
|
.alias('jf')
|
||||||
|
.description('Jellyfin workflows')
|
||||||
|
.argument('[action]', 'setup|discovery|play|login|logout')
|
||||||
|
.option('-d, --discovery', 'Cast discovery mode')
|
||||||
|
.option('-p, --play', 'Interactive play picker')
|
||||||
|
.option('-l, --login', 'Login flow')
|
||||||
|
.option('--logout', 'Clear token/session')
|
||||||
|
.option('--setup', 'Open setup window')
|
||||||
|
.option('-s, --server <url>', 'Jellyfin server URL')
|
||||||
|
.option('-u, --username <name>', 'Jellyfin username')
|
||||||
|
.option('-w, --password <pass>', 'Jellyfin password')
|
||||||
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||||
|
jellyfinInvocation = {
|
||||||
|
action,
|
||||||
|
discovery: options.discovery === true,
|
||||||
|
play: options.play === true,
|
||||||
|
login: options.login === true,
|
||||||
|
logout: options.logout === true,
|
||||||
|
setup: options.setup === true,
|
||||||
|
server: typeof options.server === 'string' ? options.server : undefined,
|
||||||
|
username: typeof options.username === 'string' ? options.username : undefined,
|
||||||
|
password: typeof options.password === 'string' ? options.password : undefined,
|
||||||
|
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('yt')
|
||||||
|
.alias('youtube')
|
||||||
|
.description('YouTube workflows')
|
||||||
|
.argument('[target]', 'YouTube URL or ytsearch: query')
|
||||||
|
.option('-m, --mode <mode>', 'Subtitle generation mode')
|
||||||
|
.option('-o, --out-dir <dir>', 'Subtitle output dir')
|
||||||
|
.option('--keep-temp', 'Keep temp files')
|
||||||
|
.option('--whisper-bin <path>', 'whisper.cpp CLI path')
|
||||||
|
.option('--whisper-model <path>', 'whisper model path')
|
||||||
|
.option('--yt-subgen-audio-format <format>', 'Audio extraction format')
|
||||||
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.action((target: string | undefined, options: Record<string, unknown>) => {
|
||||||
|
ytInvocation = {
|
||||||
|
target,
|
||||||
|
mode: typeof options.mode === 'string' ? options.mode : undefined,
|
||||||
|
outDir: typeof options.outDir === 'string' ? options.outDir : undefined,
|
||||||
|
keepTemp: options.keepTemp === true,
|
||||||
|
whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined,
|
||||||
|
whisperModel: typeof options.whisperModel === 'string' ? options.whisperModel : undefined,
|
||||||
|
ytSubgenAudioFormat:
|
||||||
|
typeof options.ytSubgenAudioFormat === 'string' ? options.ytSubgenAudioFormat : undefined,
|
||||||
|
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('doctor')
|
||||||
|
.description('Run dependency and environment checks')
|
||||||
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.action((options: Record<string, unknown>) => {
|
||||||
|
doctorTriggered = true;
|
||||||
|
doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('config')
|
||||||
|
.description('Config helpers')
|
||||||
|
.argument('[action]', 'path|show', 'path')
|
||||||
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.action((action: string, options: Record<string, unknown>) => {
|
||||||
|
configInvocation = {
|
||||||
|
action,
|
||||||
|
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('mpv')
|
||||||
|
.description('MPV helpers')
|
||||||
|
.argument('[action]', 'status|socket|idle', 'status')
|
||||||
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.action((action: string, options: Record<string, unknown>) => {
|
||||||
|
mpvInvocation = {
|
||||||
|
action,
|
||||||
|
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('texthooker')
|
||||||
|
.description('Launch texthooker-only mode')
|
||||||
|
.option('--log-level <level>', 'Log level')
|
||||||
|
.action((options: Record<string, unknown>) => {
|
||||||
|
texthookerTriggered = true;
|
||||||
|
texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
commandProgram
|
||||||
|
.command('app')
|
||||||
|
.alias('bin')
|
||||||
|
.description('Pass arguments directly to SubMiner binary')
|
||||||
|
.allowUnknownOption(true)
|
||||||
|
.allowExcessArguments(true)
|
||||||
|
.argument('[appArgs...]', 'Arguments forwarded to SubMiner app binary')
|
||||||
|
.action((appArgs: string[] | undefined) => {
|
||||||
|
appInvocation = { appArgs: Array.isArray(appArgs) ? appArgs : [] };
|
||||||
|
});
|
||||||
|
|
||||||
|
rootProgram.addHelpText('after', buildSubcommandHelpText(commandProgram));
|
||||||
|
|
||||||
|
const selectedProgram = hasTopLevelCommand(argv) ? commandProgram : rootProgram;
|
||||||
|
try {
|
||||||
|
selectedProgram.parse(['node', scriptName, ...argv]);
|
||||||
|
} catch (error) {
|
||||||
|
const commanderError = error as { code?: string; message?: string };
|
||||||
|
if (commanderError?.code === 'commander.helpDisplayed') {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
throw new Error(commanderError?.message || String(error));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
options: selectedProgram.opts<Record<string, unknown>>(),
|
||||||
|
rootTarget: rootProgram.processedArgs[0],
|
||||||
|
invocations: {
|
||||||
|
jellyfinInvocation,
|
||||||
|
ytInvocation,
|
||||||
|
configInvocation,
|
||||||
|
mpvInvocation,
|
||||||
|
appInvocation,
|
||||||
|
doctorTriggered,
|
||||||
|
doctorLogLevel,
|
||||||
|
texthookerTriggered,
|
||||||
|
texthookerLogLevel,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
16
launcher/config/jellyfin-config.ts
Normal file
16
launcher/config/jellyfin-config.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import type { LauncherJellyfinConfig } from '../types.js';
|
||||||
|
|
||||||
|
export function parseLauncherJellyfinConfig(root: Record<string, unknown>): LauncherJellyfinConfig {
|
||||||
|
const jellyfinRaw = root.jellyfin;
|
||||||
|
if (!jellyfinRaw || typeof jellyfinRaw !== 'object') return {};
|
||||||
|
const jellyfin = jellyfinRaw as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
enabled: typeof jellyfin.enabled === 'boolean' ? jellyfin.enabled : undefined,
|
||||||
|
serverUrl: typeof jellyfin.serverUrl === 'string' ? jellyfin.serverUrl : undefined,
|
||||||
|
username: typeof jellyfin.username === 'string' ? jellyfin.username : undefined,
|
||||||
|
defaultLibraryId:
|
||||||
|
typeof jellyfin.defaultLibraryId === 'string' ? jellyfin.defaultLibraryId : undefined,
|
||||||
|
pullPictures: typeof jellyfin.pullPictures === 'boolean' ? jellyfin.pullPictures : undefined,
|
||||||
|
iconCacheDir: typeof jellyfin.iconCacheDir === 'string' ? jellyfin.iconCacheDir : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
57
launcher/config/plugin-runtime-config.ts
Normal file
57
launcher/config/plugin-runtime-config.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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 { DEFAULT_SOCKET_PATH } from '../types.js';
|
||||||
|
|
||||||
|
export function getPluginConfigCandidates(): string[] {
|
||||||
|
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
||||||
|
return Array.from(
|
||||||
|
new Set([
|
||||||
|
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||||
|
path.join(os.homedir(), '.config', 'mpv', 'script-opts', 'subminer.conf'),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig {
|
||||||
|
const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
|
||||||
|
for (const line of content.split(/\r?\n/)) {
|
||||||
|
const trimmed = line.trim();
|
||||||
|
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
||||||
|
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
|
||||||
|
if (!socketMatch) continue;
|
||||||
|
const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || '';
|
||||||
|
if (value) runtimeConfig.socketPath = value;
|
||||||
|
}
|
||||||
|
return runtimeConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||||
|
const candidates = getPluginConfigCandidates();
|
||||||
|
const defaults: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
log('warn', logLevel, `Failed to read ${configPath}; using launcher defaults`);
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log(
|
||||||
|
'debug',
|
||||||
|
logLevel,
|
||||||
|
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath})`,
|
||||||
|
);
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
25
launcher/config/shared-config-reader.ts
Normal file
25
launcher/config/shared-config-reader.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { parse as parseJsonc } from 'jsonc-parser';
|
||||||
|
import { resolveConfigFilePath } from '../../src/config/path-resolution.js';
|
||||||
|
|
||||||
|
export function resolveLauncherMainConfigPath(): string {
|
||||||
|
return resolveConfigFilePath({
|
||||||
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
existsSync: fs.existsSync,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readLauncherMainConfigObject(): Record<string, unknown> | null {
|
||||||
|
const configPath = resolveLauncherMainConfigPath();
|
||||||
|
if (!fs.existsSync(configPath)) return null;
|
||||||
|
try {
|
||||||
|
const data = fs.readFileSync(configPath, 'utf8');
|
||||||
|
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
|
||||||
|
if (!parsed || typeof parsed !== 'object') return null;
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
launcher/config/youtube-subgen-config.ts
Normal file
54
launcher/config/youtube-subgen-config.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import type { LauncherYoutubeSubgenConfig } from '../types.js';
|
||||||
|
|
||||||
|
function asStringArray(value: unknown): string[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseLauncherYoutubeSubgenConfig(
|
||||||
|
root: Record<string, unknown>,
|
||||||
|
): LauncherYoutubeSubgenConfig {
|
||||||
|
const youtubeSubgenRaw = root.youtubeSubgen;
|
||||||
|
const youtubeSubgen =
|
||||||
|
youtubeSubgenRaw && typeof youtubeSubgenRaw === 'object'
|
||||||
|
? (youtubeSubgenRaw as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const secondarySubRaw = root.secondarySub;
|
||||||
|
const secondarySub =
|
||||||
|
secondarySubRaw && typeof secondarySubRaw === 'object'
|
||||||
|
? (secondarySubRaw as Record<string, unknown>)
|
||||||
|
: null;
|
||||||
|
const jimakuRaw = root.jimaku;
|
||||||
|
const jimaku =
|
||||||
|
jimakuRaw && typeof jimakuRaw === 'object' ? (jimakuRaw as Record<string, unknown>) : null;
|
||||||
|
|
||||||
|
const mode = youtubeSubgen?.mode;
|
||||||
|
const jimakuLanguagePreference = jimaku?.languagePreference;
|
||||||
|
const jimakuMaxEntryResults = jimaku?.maxEntryResults;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mode: mode === 'automatic' || mode === 'preprocess' || mode === 'off' ? mode : undefined,
|
||||||
|
whisperBin:
|
||||||
|
typeof youtubeSubgen?.whisperBin === 'string' ? youtubeSubgen.whisperBin : undefined,
|
||||||
|
whisperModel:
|
||||||
|
typeof youtubeSubgen?.whisperModel === 'string' ? youtubeSubgen.whisperModel : undefined,
|
||||||
|
primarySubLanguages: asStringArray(youtubeSubgen?.primarySubLanguages),
|
||||||
|
secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages),
|
||||||
|
jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined,
|
||||||
|
jimakuApiKeyCommand:
|
||||||
|
typeof jimaku?.apiKeyCommand === 'string' ? jimaku.apiKeyCommand : undefined,
|
||||||
|
jimakuApiBaseUrl: typeof jimaku?.apiBaseUrl === 'string' ? jimaku.apiBaseUrl : undefined,
|
||||||
|
jimakuLanguagePreference:
|
||||||
|
jimakuLanguagePreference === 'ja' ||
|
||||||
|
jimakuLanguagePreference === 'en' ||
|
||||||
|
jimakuLanguagePreference === 'none'
|
||||||
|
? jimakuLanguagePreference
|
||||||
|
: undefined,
|
||||||
|
jimakuMaxEntryResults:
|
||||||
|
typeof jimakuMaxEntryResults === 'number' &&
|
||||||
|
Number.isFinite(jimakuMaxEntryResults) &&
|
||||||
|
jimakuMaxEntryResults > 0
|
||||||
|
? Math.floor(jimakuMaxEntryResults)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -127,6 +127,7 @@ export async function resolveJellyfinSelection(
|
|||||||
left.localeCompare(right, undefined, { sensitivity: 'base', numeric: true });
|
left.localeCompare(right, undefined, { sensitivity: 'base', numeric: true });
|
||||||
const sortEntries = (
|
const sortEntries = (
|
||||||
entries: Array<{
|
entries: Array<{
|
||||||
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
name: string;
|
name: string;
|
||||||
parentIndex: number | null;
|
parentIndex: number | null;
|
||||||
@@ -355,10 +356,12 @@ export async function runJellyfinPlayMenu(
|
|||||||
mpvSocketPath: string,
|
mpvSocketPath: string,
|
||||||
): Promise<never> {
|
): Promise<never> {
|
||||||
const config = loadLauncherJellyfinConfig();
|
const config = loadLauncherJellyfinConfig();
|
||||||
|
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
|
||||||
|
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
|
||||||
const session: JellyfinSessionConfig = {
|
const session: JellyfinSessionConfig = {
|
||||||
serverUrl: sanitizeServerUrl(args.jellyfinServer || config.serverUrl || ''),
|
serverUrl: sanitizeServerUrl(args.jellyfinServer || config.serverUrl || ''),
|
||||||
accessToken: config.accessToken || '',
|
accessToken: envAccessToken,
|
||||||
userId: config.userId || '',
|
userId: envUserId,
|
||||||
defaultLibraryId: config.defaultLibraryId || '',
|
defaultLibraryId: config.defaultLibraryId || '',
|
||||||
pullPictures: config.pullPictures === true,
|
pullPictures: config.pullPictures === true,
|
||||||
iconCacheDir: config.iconCacheDir || '',
|
iconCacheDir: config.iconCacheDir || '',
|
||||||
@@ -366,7 +369,7 @@ export async function runJellyfinPlayMenu(
|
|||||||
|
|
||||||
if (!session.serverUrl || !session.accessToken || !session.userId) {
|
if (!session.serverUrl || !session.accessToken || !session.userId) {
|
||||||
fail(
|
fail(
|
||||||
'Missing Jellyfin session config. Run `subminer --jellyfin` or `subminer --jellyfin-login` first.',
|
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,3 +22,24 @@ test('parseArgs keeps all args after app verbatim', () => {
|
|||||||
assert.equal(parsed.appPassthrough, true);
|
assert.equal(parsed.appPassthrough, true);
|
||||||
assert.deepEqual(parsed.appArgs, ['--start', '--anilist-setup', '-h']);
|
assert.deepEqual(parsed.appArgs, ['--start', '--anilist-setup', '-h']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('parseArgs maps jellyfin play action and log-level override', () => {
|
||||||
|
const parsed = parseArgs(['jellyfin', 'play', '--log-level', 'debug'], 'subminer', {});
|
||||||
|
|
||||||
|
assert.equal(parsed.jellyfinPlay, true);
|
||||||
|
assert.equal(parsed.logLevel, 'debug');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseArgs maps config show action', () => {
|
||||||
|
const parsed = parseArgs(['config', 'show'], 'subminer', {});
|
||||||
|
|
||||||
|
assert.equal(parsed.configShow, true);
|
||||||
|
assert.equal(parsed.configPath, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parseArgs maps mpv idle action', () => {
|
||||||
|
const parsed = parseArgs(['mpv', 'idle'], 'subminer', {});
|
||||||
|
|
||||||
|
assert.equal(parsed.mpvIdle, true);
|
||||||
|
assert.equal(parsed.mpvStatus, false);
|
||||||
|
});
|
||||||
|
|||||||
@@ -121,8 +121,6 @@ export interface LauncherJellyfinConfig {
|
|||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
serverUrl?: string;
|
serverUrl?: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
accessToken?: string;
|
|
||||||
userId?: string;
|
|
||||||
defaultLibraryId?: string;
|
defaultLibraryId?: string;
|
||||||
pullPictures?: boolean;
|
pullPictures?: boolean;
|
||||||
iconCacheDir?: string;
|
iconCacheDir?: string;
|
||||||
|
|||||||
@@ -24,8 +24,8 @@
|
|||||||
"test:config:dist": "node --test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js",
|
"test:config:dist": "node --test dist/config/config.test.js dist/config/path-resolution.test.js dist/config/resolve/anki-connect.test.js dist/config/resolve/subtitle-style.test.js dist/config/resolve/jellyfin.test.js dist/config/definitions/domain-registry.test.js",
|
||||||
"test:config:smoke:dist": "node --test dist/config/path-resolution.test.js",
|
"test:config:smoke:dist": "node --test dist/config/path-resolution.test.js",
|
||||||
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
"test:launcher:smoke:src": "bun test launcher/smoke.e2e.test.ts",
|
||||||
"test:launcher:src": "bun test launcher/config.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts",
|
"test:launcher:src": "bun test launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts launcher/smoke.e2e.test.ts",
|
||||||
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.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/config-hot-reload.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/mining.test.ts src/core/services/anki-jimaku.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/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/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
"test:core:src": "bun test src/cli/args.test.ts src/cli/help.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/config-hot-reload.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/mining.test.ts src/core/services/anki-jimaku.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/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/utils/shortcut-config.test.ts src/renderer/error-recovery.test.ts src/subsync/utils.test.ts src/main/anilist-url-guard.test.ts src/window-trackers/x11-tracker.test.ts launcher/config.test.ts launcher/config-domain-parsers.test.ts launcher/parse-args.test.ts launcher/main.test.ts launcher/commands/command-modules.test.ts",
|
||||||
"test:core:dist": "node --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/config-hot-reload.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/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.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/renderer/error-recovery.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:dist": "node --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/config-hot-reload.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/jellyfin.test.js dist/core/services/jellyfin-remote.test.js dist/core/services/immersion-tracker-service.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/renderer/error-recovery.test.js dist/subsync/utils.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:core:smoke:dist": "node --test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
"test:core:smoke:dist": "node --test dist/cli/help.test.js dist/core/services/runtime-config.test.js dist/core/services/ipc.test.js dist/core/services/overlay-manager.test.js dist/core/services/anilist/anilist-token-store.test.js dist/core/services/startup-bootstrap.test.js dist/renderer/error-recovery.test.js dist/main/anilist-url-guard.test.js dist/window-trackers/x11-tracker.test.js",
|
||||||
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
"test:smoke:dist": "bun run test:config:smoke:dist && bun run test:core:smoke:dist",
|
||||||
|
|||||||
Reference in New Issue
Block a user