mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
refactor(launcher): split CLI flow into command modules
Isolate process-side effects behind adapter seams and keep wrapper behavior stable while improving command-level testability.
This commit is contained in:
@@ -1,10 +1,11 @@
|
|||||||
---
|
---
|
||||||
id: TASK-81
|
id: TASK-81
|
||||||
title: Refactor launcher into command modules and process adapters
|
title: Refactor launcher into command modules and process adapters
|
||||||
status: To Do
|
status: Done
|
||||||
assignee: []
|
assignee:
|
||||||
|
- opencode-task81-launcher-modules
|
||||||
created_date: '2026-02-18 11:43'
|
created_date: '2026-02-18 11:43'
|
||||||
updated_date: '2026-02-18 11:43'
|
updated_date: '2026-02-22 01:09'
|
||||||
labels:
|
labels:
|
||||||
- launcher
|
- launcher
|
||||||
- cli
|
- cli
|
||||||
@@ -43,15 +44,44 @@ Launcher code is still large and process-control heavy (`launcher/main.ts`, `lau
|
|||||||
|
|
||||||
## Acceptance Criteria
|
## Acceptance Criteria
|
||||||
<!-- AC:BEGIN -->
|
<!-- AC:BEGIN -->
|
||||||
- [ ] #1 Launcher commands are implemented as focused modules
|
- [x] #1 Launcher commands are implemented as focused modules
|
||||||
- [ ] #2 Process side effects are isolated behind adapter interfaces
|
- [x] #2 Process side effects are isolated behind adapter interfaces
|
||||||
- [ ] #3 Existing CLI behavior and exit codes remain compatible
|
- [x] #3 Existing CLI behavior and exit codes remain compatible
|
||||||
- [ ] #4 Launcher testability improves via mocked adapter tests
|
- [x] #4 Launcher testability improves via mocked adapter tests
|
||||||
<!-- AC:END -->
|
<!-- AC:END -->
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
<!-- SECTION:PLAN:BEGIN -->
|
||||||
|
1) Add `launcher/process-adapter.ts` and `launcher/commands/context.ts` so command handlers receive a shared context and explicit process/stdout/signal/exit seam instead of hardcoding `process.*` calls.
|
||||||
|
2) Extract early-return command branches from `launcher/main.ts` into focused modules under `launcher/commands/` (`config`, `doctor`, `mpv`, `app`, `texthooker`) while preserving existing outputs and exit-code behavior.
|
||||||
|
3) Extract `jellyfin` and default playback orchestration into dedicated command modules so `launcher/main.ts` becomes thin command dispatch + lifecycle wiring.
|
||||||
|
4) Add adapter-mocked command tests (`launcher/commands/command-modules.test.ts`) and keep integration regressions in `launcher/main.test.ts` to prove behavior parity.
|
||||||
|
5) Wire new command test file into launcher/core source test scripts, run required gates (`bun run test:launcher`, `bun run test:core:src`), then finalize TASK-81 notes/AC/DoD without commit.
|
||||||
|
<!-- SECTION:PLAN:END -->
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
<!-- SECTION:NOTES:BEGIN -->
|
||||||
|
Planning complete. Detailed execution plan saved at docs/plans/2026-02-22-task-81-launcher-command-modules-process-adapters.md. Proceeding directly per user request to execute via writing-plans + executing-plans flow without commit.
|
||||||
|
|
||||||
|
Implemented launcher refactor by extracting command handlers into `launcher/commands/*` (`config`, `doctor`, `mpv`, `app`, `jellyfin`, `playback`) and converting `launcher/main.ts` into thin context+dispatch orchestration.
|
||||||
|
|
||||||
|
Added explicit process side-effect seam in `launcher/process-adapter.ts` and routed command output/exit/signal behavior through adapter-aware command modules.
|
||||||
|
|
||||||
|
Added mocked-adapter regression tests in `launcher/commands/command-modules.test.ts` and wired that file into `test:launcher:src` + `test:core:src` scripts.
|
||||||
|
|
||||||
|
Verification: `bun test launcher/commands/command-modules.test.ts launcher/main.test.ts launcher/parse-args.test.ts` and `bun run test:launcher && bun run test:core:src` all passing.
|
||||||
|
<!-- SECTION:NOTES:END -->
|
||||||
|
|
||||||
|
## Final Summary
|
||||||
|
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
|
||||||
|
Refactored launcher command branching into focused command modules and introduced a dedicated process adapter interface to isolate output/exit/signal side effects from orchestration code. Preserved existing CLI behavior by keeping helper flows intact, added adapter-mocked command tests, and validated compatibility with full launcher and core source test gates.
|
||||||
|
<!-- SECTION:FINAL_SUMMARY:END -->
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
<!-- DOD:BEGIN -->
|
<!-- DOD:BEGIN -->
|
||||||
- [ ] #1 Launcher tests and core test gate pass
|
- [x] #1 Launcher tests and core test gate pass
|
||||||
- [ ] #2 No regression in wrapper command behavior
|
- [x] #2 No regression in wrapper command behavior
|
||||||
<!-- DOD:END -->
|
<!-- DOD:END -->
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# Agent Session: opencode-task81-launcher-modules-20260222T005725Z-8oh8
|
||||||
|
|
||||||
|
- alias: `opencode-task81-launcher-modules`
|
||||||
|
- mission: `Execute TASK-81 launcher command-module/process-adapter refactor via writing-plans + executing-plans (no commit).`
|
||||||
|
- status: `done`
|
||||||
|
- started_utc: `2026-02-22T00:57:25Z`
|
||||||
|
- last_update_utc: `2026-02-22T01:09:30Z`
|
||||||
|
|
||||||
|
## Intent
|
||||||
|
|
||||||
|
- Load TASK-81 from Backlog MCP and capture full implementation plan before code edits.
|
||||||
|
- Refactor launcher into focused command modules and process adapters without CLI behavior drift.
|
||||||
|
- Improve launcher test seams with adapter-mocked unit/integration tests.
|
||||||
|
|
||||||
|
## Planned Files
|
||||||
|
|
||||||
|
- `launcher/main.ts`
|
||||||
|
- `launcher/*.ts`
|
||||||
|
- `launcher/commands/*.ts`
|
||||||
|
- `launcher/**/*.test.ts`
|
||||||
|
- `package.json`
|
||||||
|
- `docs/development.md`
|
||||||
|
|
||||||
|
## Files Touched
|
||||||
|
|
||||||
|
- `docs/subagents/agents/opencode-task81-launcher-modules-20260222T005725Z-8oh8.md`
|
||||||
|
- `docs/subagents/INDEX.md`
|
||||||
|
- `docs/subagents/collaboration.md`
|
||||||
|
- `docs/plans/2026-02-22-task-81-launcher-command-modules-process-adapters.md`
|
||||||
|
- `launcher/main.ts`
|
||||||
|
- `launcher/process-adapter.ts`
|
||||||
|
- `launcher/config-path.ts`
|
||||||
|
- `launcher/commands/context.ts`
|
||||||
|
- `launcher/commands/config-command.ts`
|
||||||
|
- `launcher/commands/doctor-command.ts`
|
||||||
|
- `launcher/commands/mpv-command.ts`
|
||||||
|
- `launcher/commands/app-command.ts`
|
||||||
|
- `launcher/commands/jellyfin-command.ts`
|
||||||
|
- `launcher/commands/playback-command.ts`
|
||||||
|
- `launcher/commands/command-modules.test.ts`
|
||||||
|
- `package.json`
|
||||||
|
|
||||||
|
## Assumptions
|
||||||
|
|
||||||
|
- TASK-81 scope is launcher architecture and tests only; keep command UX/exit semantics stable.
|
||||||
|
- Existing TASK-73/74 changes provide stable baseline tests to preserve behavior.
|
||||||
|
- Parallel subagents can split command extraction and adapter seams where file overlap is controlled.
|
||||||
|
|
||||||
|
## Phase Log
|
||||||
|
|
||||||
|
- `2026-02-22T00:57:25Z` Session started; read subagent protocol docs + backlog workflow + TASK-81 details.
|
||||||
|
- `2026-02-22T01:01:30Z` Wrote TASK-81 implementation plan at `docs/plans/2026-02-22-task-81-launcher-command-modules-process-adapters.md` and recorded plan/status in Backlog MCP.
|
||||||
|
- `2026-02-22T01:07:40Z` Implemented command-module extraction + process adapter seam and rewired `launcher/main.ts` into thin dispatcher.
|
||||||
|
- `2026-02-22T01:09:30Z` Added adapter-mocked command tests and updated test scripts; verified `bun run test:launcher && bun run test:core:src` passing.
|
||||||
20
launcher/commands/app-command.ts
Normal file
20
launcher/commands/app-command.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { launchTexthookerOnly, runAppCommandWithInherit } from '../mpv.js';
|
||||||
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
|
export function runAppPassthroughCommand(context: LauncherCommandContext): boolean {
|
||||||
|
const { args, appPath } = context;
|
||||||
|
if (!args.appPassthrough || !appPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
runAppCommandWithInherit(appPath, args.appArgs);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function runTexthookerCommand(context: LauncherCommandContext): boolean {
|
||||||
|
const { args, appPath } = context;
|
||||||
|
if (!args.texthookerOnly || !appPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
launchTexthookerOnly(appPath, args);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
90
launcher/commands/command-modules.test.ts
Normal file
90
launcher/commands/command-modules.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { parseArgs } from '../config.js';
|
||||||
|
import type { ProcessAdapter } from '../process-adapter.js';
|
||||||
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
import { runConfigCommand } from './config-command.js';
|
||||||
|
import { runDoctorCommand } from './doctor-command.js';
|
||||||
|
import { runMpvPreAppCommand } from './mpv-command.js';
|
||||||
|
|
||||||
|
class ExitSignal extends Error {
|
||||||
|
code: number;
|
||||||
|
|
||||||
|
constructor(code: number) {
|
||||||
|
super(`exit:${code}`);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createContext(overrides: Partial<LauncherCommandContext> = {}): LauncherCommandContext {
|
||||||
|
const args = parseArgs([], 'subminer', {});
|
||||||
|
const adapter: ProcessAdapter = {
|
||||||
|
platform: () => 'linux',
|
||||||
|
onSignal: () => {},
|
||||||
|
writeStdout: () => {},
|
||||||
|
exit: (code) => {
|
||||||
|
throw new ExitSignal(code);
|
||||||
|
},
|
||||||
|
setExitCode: () => {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
args,
|
||||||
|
scriptPath: '/tmp/subminer',
|
||||||
|
scriptName: 'subminer',
|
||||||
|
mpvSocketPath: '/tmp/subminer.sock',
|
||||||
|
appPath: '/tmp/subminer.app',
|
||||||
|
launcherJellyfinConfig: {},
|
||||||
|
processAdapter: adapter,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('config command writes newline-terminated path via process adapter', () => {
|
||||||
|
const writes: string[] = [];
|
||||||
|
const context = createContext();
|
||||||
|
context.args.configPath = true;
|
||||||
|
context.processAdapter = {
|
||||||
|
...context.processAdapter,
|
||||||
|
writeStdout: (text) => writes.push(text),
|
||||||
|
};
|
||||||
|
|
||||||
|
const handled = runConfigCommand(context, {
|
||||||
|
existsSync: () => true,
|
||||||
|
readFileSync: () => '',
|
||||||
|
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(handled, true);
|
||||||
|
assert.deepEqual(writes, ['/tmp/SubMiner/config.jsonc\n']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('doctor command exits non-zero for missing hard dependencies', () => {
|
||||||
|
const context = createContext({ appPath: null });
|
||||||
|
context.args.doctor = true;
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
runDoctorCommand(context, {
|
||||||
|
commandExists: () => false,
|
||||||
|
configExists: () => true,
|
||||||
|
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||||
|
}),
|
||||||
|
(error: unknown) => error instanceof ExitSignal && error.code === 1,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
||||||
|
const context = createContext();
|
||||||
|
context.args.mpvStatus = true;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
async () => {
|
||||||
|
await runMpvPreAppCommand(context, {
|
||||||
|
waitForUnixSocketReady: async () => false,
|
||||||
|
launchMpvIdleDetached: async () => {},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(error: unknown) => error instanceof ExitSignal && error.code === 1,
|
||||||
|
);
|
||||||
|
});
|
||||||
43
launcher/commands/config-command.ts
Normal file
43
launcher/commands/config-command.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import { fail } from '../log.js';
|
||||||
|
import { resolveMainConfigPath } from '../config-path.js';
|
||||||
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
|
interface ConfigCommandDeps {
|
||||||
|
existsSync(path: string): boolean;
|
||||||
|
readFileSync(path: string, encoding: BufferEncoding): string;
|
||||||
|
resolveMainConfigPath(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDeps: ConfigCommandDeps = {
|
||||||
|
existsSync: fs.existsSync,
|
||||||
|
readFileSync: fs.readFileSync,
|
||||||
|
resolveMainConfigPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function runConfigCommand(
|
||||||
|
context: LauncherCommandContext,
|
||||||
|
deps: ConfigCommandDeps = defaultDeps,
|
||||||
|
): boolean {
|
||||||
|
const { args, processAdapter } = context;
|
||||||
|
if (args.configPath) {
|
||||||
|
processAdapter.writeStdout(`${deps.resolveMainConfigPath()}\n`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.configShow) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = deps.resolveMainConfigPath();
|
||||||
|
if (!deps.existsSync(configPath)) {
|
||||||
|
fail(`Config file not found: ${configPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contents = deps.readFileSync(configPath, 'utf8');
|
||||||
|
processAdapter.writeStdout(contents);
|
||||||
|
if (!contents.endsWith('\n')) {
|
||||||
|
processAdapter.writeStdout('\n');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
12
launcher/commands/context.ts
Normal file
12
launcher/commands/context.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Args, LauncherJellyfinConfig } from '../types.js';
|
||||||
|
import type { ProcessAdapter } from '../process-adapter.js';
|
||||||
|
|
||||||
|
export interface LauncherCommandContext {
|
||||||
|
args: Args;
|
||||||
|
scriptPath: string;
|
||||||
|
scriptName: string;
|
||||||
|
mpvSocketPath: string;
|
||||||
|
appPath: string | null;
|
||||||
|
launcherJellyfinConfig: LauncherJellyfinConfig;
|
||||||
|
processAdapter: ProcessAdapter;
|
||||||
|
}
|
||||||
85
launcher/commands/doctor-command.ts
Normal file
85
launcher/commands/doctor-command.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import { log } from '../log.js';
|
||||||
|
import { commandExists } from '../util.js';
|
||||||
|
import { resolveMainConfigPath } from '../config-path.js';
|
||||||
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
|
interface DoctorCommandDeps {
|
||||||
|
commandExists(command: string): boolean;
|
||||||
|
configExists(path: string): boolean;
|
||||||
|
resolveMainConfigPath(): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDeps: DoctorCommandDeps = {
|
||||||
|
commandExists,
|
||||||
|
configExists: fs.existsSync,
|
||||||
|
resolveMainConfigPath,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function runDoctorCommand(
|
||||||
|
context: LauncherCommandContext,
|
||||||
|
deps: DoctorCommandDeps = defaultDeps,
|
||||||
|
): boolean {
|
||||||
|
const { args, appPath, mpvSocketPath, processAdapter } = context;
|
||||||
|
if (!args.doctor) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const configPath = deps.resolveMainConfigPath();
|
||||||
|
const mpvFound = deps.commandExists('mpv');
|
||||||
|
const checks: Array<{ label: string; ok: boolean; detail: string }> = [
|
||||||
|
{
|
||||||
|
label: 'app binary',
|
||||||
|
ok: Boolean(appPath),
|
||||||
|
detail: appPath || 'not found (set SUBMINER_APPIMAGE_PATH)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'mpv',
|
||||||
|
ok: mpvFound,
|
||||||
|
detail: mpvFound ? 'found' : 'missing',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'yt-dlp',
|
||||||
|
ok: deps.commandExists('yt-dlp'),
|
||||||
|
detail: deps.commandExists('yt-dlp') ? 'found' : 'missing (optional unless YouTube URLs)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'ffmpeg',
|
||||||
|
ok: deps.commandExists('ffmpeg'),
|
||||||
|
detail: deps.commandExists('ffmpeg')
|
||||||
|
? 'found'
|
||||||
|
: 'missing (optional unless subtitle generation)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'fzf',
|
||||||
|
ok: deps.commandExists('fzf'),
|
||||||
|
detail: deps.commandExists('fzf') ? 'found' : 'missing (optional if using rofi)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'rofi',
|
||||||
|
ok: deps.commandExists('rofi'),
|
||||||
|
detail: deps.commandExists('rofi') ? 'found' : 'missing (optional if using fzf)',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'config',
|
||||||
|
ok: deps.configExists(configPath),
|
||||||
|
detail: configPath,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'mpv socket path',
|
||||||
|
ok: true,
|
||||||
|
detail: mpvSocketPath,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const hasHardFailure = checks.some((entry) =>
|
||||||
|
entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const check of checks) {
|
||||||
|
log(check.ok ? 'info' : 'warn', args.logLevel, `[doctor] ${check.label}: ${check.detail}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
processAdapter.exit(hasHardFailure ? 1 : 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
71
launcher/commands/jellyfin-command.ts
Normal file
71
launcher/commands/jellyfin-command.ts
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { fail } from '../log.js';
|
||||||
|
import { runAppCommandWithInherit } from '../mpv.js';
|
||||||
|
import { commandExists } from '../util.js';
|
||||||
|
import { runJellyfinPlayMenu } from '../jellyfin.js';
|
||||||
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
|
export async function runJellyfinCommand(context: LauncherCommandContext): Promise<boolean> {
|
||||||
|
const { args, appPath, scriptPath, mpvSocketPath, launcherJellyfinConfig } = context;
|
||||||
|
if (!appPath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.jellyfin) {
|
||||||
|
const forwarded = ['--jellyfin'];
|
||||||
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.jellyfinLogin) {
|
||||||
|
const serverUrl = args.jellyfinServer || launcherJellyfinConfig.serverUrl || '';
|
||||||
|
const username = args.jellyfinUsername || launcherJellyfinConfig.username || '';
|
||||||
|
const password = args.jellyfinPassword || '';
|
||||||
|
if (!serverUrl || !username || !password) {
|
||||||
|
fail(
|
||||||
|
'--jellyfin-login requires server, username, and password. Pass flags or run `subminer --jellyfin`.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const forwarded = [
|
||||||
|
'--jellyfin-login',
|
||||||
|
'--jellyfin-server',
|
||||||
|
serverUrl,
|
||||||
|
'--jellyfin-username',
|
||||||
|
username,
|
||||||
|
'--jellyfin-password',
|
||||||
|
password,
|
||||||
|
];
|
||||||
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.jellyfinLogout) {
|
||||||
|
const forwarded = ['--jellyfin-logout'];
|
||||||
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.jellyfinPlay) {
|
||||||
|
if (!args.useRofi && !commandExists('fzf')) {
|
||||||
|
fail('fzf not found. Install fzf or use -R for rofi.');
|
||||||
|
}
|
||||||
|
if (args.useRofi && !commandExists('rofi')) {
|
||||||
|
fail('rofi not found. Install rofi or omit -R for fzf.');
|
||||||
|
}
|
||||||
|
await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (args.jellyfinDiscovery) {
|
||||||
|
const forwarded = ['--start'];
|
||||||
|
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||||
|
runAppCommandWithInherit(appPath, forwarded);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
args.jellyfin ||
|
||||||
|
args.jellyfinLogin ||
|
||||||
|
args.jellyfinLogout ||
|
||||||
|
args.jellyfinPlay ||
|
||||||
|
args.jellyfinDiscovery,
|
||||||
|
);
|
||||||
|
}
|
||||||
62
launcher/commands/mpv-command.ts
Normal file
62
launcher/commands/mpv-command.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { fail, log } from '../log.js';
|
||||||
|
import { waitForUnixSocketReady, launchMpvIdleDetached } from '../mpv.js';
|
||||||
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
|
interface MpvCommandDeps {
|
||||||
|
waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>;
|
||||||
|
launchMpvIdleDetached(
|
||||||
|
socketPath: string,
|
||||||
|
appPath: string,
|
||||||
|
args: LauncherCommandContext['args'],
|
||||||
|
): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultDeps: MpvCommandDeps = {
|
||||||
|
waitForUnixSocketReady,
|
||||||
|
launchMpvIdleDetached,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function runMpvPreAppCommand(
|
||||||
|
context: LauncherCommandContext,
|
||||||
|
deps: MpvCommandDeps = defaultDeps,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { args, mpvSocketPath, processAdapter } = context;
|
||||||
|
if (args.mpvSocket) {
|
||||||
|
processAdapter.writeStdout(`${mpvSocketPath}\n`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.mpvStatus) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 500);
|
||||||
|
log(
|
||||||
|
ready ? 'info' : 'warn',
|
||||||
|
args.logLevel,
|
||||||
|
`[mpv] socket ${ready ? 'ready' : 'not ready'}: ${mpvSocketPath}`,
|
||||||
|
);
|
||||||
|
processAdapter.exit(ready ? 0 : 1);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runMpvPostAppCommand(
|
||||||
|
context: LauncherCommandContext,
|
||||||
|
deps: MpvCommandDeps = defaultDeps,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { args, appPath, mpvSocketPath } = context;
|
||||||
|
if (!args.mpvIdle) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!appPath) {
|
||||||
|
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||||
|
}
|
||||||
|
|
||||||
|
await deps.launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
||||||
|
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||||
|
if (!ready) {
|
||||||
|
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
|
||||||
|
}
|
||||||
|
log('info', args.logLevel, `[mpv] idle instance ready on ${mpvSocketPath}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
208
launcher/commands/playback-command.ts
Normal file
208
launcher/commands/playback-command.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fail, log } from '../log.js';
|
||||||
|
import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from '../util.js';
|
||||||
|
import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
||||||
|
import {
|
||||||
|
loadSubtitleIntoMpv,
|
||||||
|
startMpv,
|
||||||
|
startOverlay,
|
||||||
|
state,
|
||||||
|
stopOverlay,
|
||||||
|
waitForUnixSocketReady,
|
||||||
|
} from '../mpv.js';
|
||||||
|
import { generateYoutubeSubtitles } from '../youtube.js';
|
||||||
|
import type { Args } from '../types.js';
|
||||||
|
import type { LauncherCommandContext } from './context.js';
|
||||||
|
|
||||||
|
function checkDependencies(args: Args): void {
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
if (!commandExists('mpv')) missing.push('mpv');
|
||||||
|
|
||||||
|
if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('yt-dlp')) {
|
||||||
|
missing.push('yt-dlp');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
args.targetKind === 'url' &&
|
||||||
|
isYoutubeTarget(args.target) &&
|
||||||
|
args.youtubeSubgenMode !== 'off' &&
|
||||||
|
!commandExists('ffmpeg')
|
||||||
|
) {
|
||||||
|
missing.push('ffmpeg');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) fail(`Missing dependencies: ${missing.join(' ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkPickerDependencies(args: Args): void {
|
||||||
|
if (args.useRofi) {
|
||||||
|
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!commandExists('fzf')) fail('Missing dependency: fzf');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseTarget(
|
||||||
|
args: Args,
|
||||||
|
scriptPath: string,
|
||||||
|
): Promise<{ target: string; kind: 'file' | 'url' } | null> {
|
||||||
|
if (args.target) {
|
||||||
|
return { target: args.target, kind: args.targetKind as 'file' | 'url' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchDir = realpathMaybe(resolvePathMaybe(args.directory));
|
||||||
|
if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) {
|
||||||
|
fail(`Directory not found: ${searchDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const videos = collectVideos(searchDir, args.recursive);
|
||||||
|
if (videos.length === 0) {
|
||||||
|
fail(`No video files found in: ${searchDir}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
log('info', args.logLevel, `Browsing: ${searchDir} (${videos.length} videos found)`);
|
||||||
|
|
||||||
|
const selected = args.useRofi
|
||||||
|
? showRofiMenu(videos, searchDir, args.recursive, scriptPath, args.logLevel)
|
||||||
|
: showFzfMenu(videos);
|
||||||
|
|
||||||
|
if (!selected) return null;
|
||||||
|
return { target: selected, kind: 'file' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerCleanup(context: LauncherCommandContext): void {
|
||||||
|
const { args, processAdapter } = context;
|
||||||
|
processAdapter.onSignal('SIGINT', () => {
|
||||||
|
stopOverlay(args);
|
||||||
|
processAdapter.exit(130);
|
||||||
|
});
|
||||||
|
processAdapter.onSignal('SIGTERM', () => {
|
||||||
|
stopOverlay(args);
|
||||||
|
processAdapter.exit(143);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
||||||
|
const { args, appPath, scriptPath, mpvSocketPath, processAdapter } = context;
|
||||||
|
if (!appPath) {
|
||||||
|
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.target) {
|
||||||
|
checkPickerDependencies(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetChoice = await chooseTarget(args, scriptPath);
|
||||||
|
if (!targetChoice) {
|
||||||
|
log('info', args.logLevel, 'No video selected, exiting');
|
||||||
|
processAdapter.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkDependencies({
|
||||||
|
...args,
|
||||||
|
target: targetChoice ? targetChoice.target : args.target,
|
||||||
|
targetKind: targetChoice ? targetChoice.kind : 'url',
|
||||||
|
});
|
||||||
|
|
||||||
|
registerCleanup(context);
|
||||||
|
|
||||||
|
const selectedTarget = targetChoice
|
||||||
|
? {
|
||||||
|
target: targetChoice.target,
|
||||||
|
kind: targetChoice.kind as 'file' | 'url',
|
||||||
|
}
|
||||||
|
: { target: args.target, kind: 'url' as const };
|
||||||
|
|
||||||
|
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
||||||
|
let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined;
|
||||||
|
|
||||||
|
if (isYoutubeUrl && args.youtubeSubgenMode === 'preprocess') {
|
||||||
|
log('info', args.logLevel, 'YouTube subtitle mode: preprocess');
|
||||||
|
const generated = await generateYoutubeSubtitles(selectedTarget.target, args);
|
||||||
|
preloadedSubtitles = {
|
||||||
|
primaryPath: generated.primaryPath,
|
||||||
|
secondaryPath: generated.secondaryPath,
|
||||||
|
};
|
||||||
|
log(
|
||||||
|
'info',
|
||||||
|
args.logLevel,
|
||||||
|
`YouTube preprocess result: primary=${generated.primaryPath ? 'ready' : 'missing'}, secondary=${generated.secondaryPath ? 'ready' : 'missing'}`,
|
||||||
|
);
|
||||||
|
} else if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
|
||||||
|
log('info', args.logLevel, 'YouTube subtitle mode: automatic (background)');
|
||||||
|
} else if (isYoutubeUrl) {
|
||||||
|
log('info', args.logLevel, 'YouTube subtitle mode: off');
|
||||||
|
}
|
||||||
|
|
||||||
|
startMpv(
|
||||||
|
selectedTarget.target,
|
||||||
|
selectedTarget.kind,
|
||||||
|
args,
|
||||||
|
mpvSocketPath,
|
||||||
|
appPath,
|
||||||
|
preloadedSubtitles,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
|
||||||
|
void generateYoutubeSubtitles(selectedTarget.target, args, async (lang, subtitlePath) => {
|
||||||
|
try {
|
||||||
|
await loadSubtitleIntoMpv(mpvSocketPath, subtitlePath, lang === 'primary', args.logLevel);
|
||||||
|
} catch (error) {
|
||||||
|
log(
|
||||||
|
'warn',
|
||||||
|
args.logLevel,
|
||||||
|
`Generated subtitle ready but failed to load in mpv: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
log(
|
||||||
|
'warn',
|
||||||
|
args.logLevel,
|
||||||
|
`Background subtitle generation failed: ${(error as Error).message}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||||
|
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
|
||||||
|
if (shouldStartOverlay) {
|
||||||
|
if (ready) {
|
||||||
|
log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
'info',
|
||||||
|
args.logLevel,
|
||||||
|
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await startOverlay(appPath, args, mpvSocketPath);
|
||||||
|
} else if (ready) {
|
||||||
|
log(
|
||||||
|
'info',
|
||||||
|
args.logLevel,
|
||||||
|
'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log(
|
||||||
|
'info',
|
||||||
|
args.logLevel,
|
||||||
|
'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
if (!state.mpvProc) {
|
||||||
|
stopOverlay(args);
|
||||||
|
resolve();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.mpvProc.on('exit', (code) => {
|
||||||
|
stopOverlay(args);
|
||||||
|
processAdapter.setExitCode(code ?? 0);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
11
launcher/config-path.ts
Normal file
11
launcher/config-path.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import { resolveConfigFilePath } from '../src/config/path-resolution.js';
|
||||||
|
|
||||||
|
export function resolveMainConfigPath(): string {
|
||||||
|
return resolveConfigFilePath({
|
||||||
|
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||||
|
homeDir: os.homedir(),
|
||||||
|
existsSync: fs.existsSync,
|
||||||
|
});
|
||||||
|
}
|
||||||
414
launcher/main.ts
414
launcher/main.ts
@@ -1,404 +1,98 @@
|
|||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import os from 'node:os';
|
|
||||||
import { resolveConfigFilePath } from '../src/config/path-resolution.js';
|
|
||||||
import type { Args } from './types.js';
|
|
||||||
import { log, fail } from './log.js';
|
|
||||||
import { commandExists, isYoutubeTarget, resolvePathMaybe, realpathMaybe } from './util.js';
|
|
||||||
import {
|
import {
|
||||||
parseArgs,
|
|
||||||
loadLauncherYoutubeSubgenConfig,
|
|
||||||
loadLauncherJellyfinConfig,
|
loadLauncherJellyfinConfig,
|
||||||
|
loadLauncherYoutubeSubgenConfig,
|
||||||
|
parseArgs,
|
||||||
readPluginRuntimeConfig,
|
readPluginRuntimeConfig,
|
||||||
} from './config.js';
|
} from './config.js';
|
||||||
import { showRofiMenu, showFzfMenu, collectVideos } from './picker.js';
|
import { fail, log } from './log.js';
|
||||||
import {
|
import { findAppBinary, state } from './mpv.js';
|
||||||
state,
|
import { nodeProcessAdapter } from './process-adapter.js';
|
||||||
startMpv,
|
import type { LauncherCommandContext } from './commands/context.js';
|
||||||
startOverlay,
|
import { runDoctorCommand } from './commands/doctor-command.js';
|
||||||
stopOverlay,
|
import { runConfigCommand } from './commands/config-command.js';
|
||||||
launchTexthookerOnly,
|
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
|
||||||
findAppBinary,
|
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
|
||||||
loadSubtitleIntoMpv,
|
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||||
runAppCommandWithInherit,
|
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||||
launchMpvIdleDetached,
|
|
||||||
waitForUnixSocketReady,
|
|
||||||
} from './mpv.js';
|
|
||||||
import { generateYoutubeSubtitles } from './youtube.js';
|
|
||||||
import { runJellyfinPlayMenu } from './jellyfin.js';
|
|
||||||
|
|
||||||
function checkDependencies(args: Args): void {
|
function createCommandContext(
|
||||||
const missing: string[] = [];
|
args: ReturnType<typeof parseArgs>,
|
||||||
|
|
||||||
if (!commandExists('mpv')) missing.push('mpv');
|
|
||||||
|
|
||||||
if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('yt-dlp')) {
|
|
||||||
missing.push('yt-dlp');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
args.targetKind === 'url' &&
|
|
||||||
isYoutubeTarget(args.target) &&
|
|
||||||
args.youtubeSubgenMode !== 'off' &&
|
|
||||||
!commandExists('ffmpeg')
|
|
||||||
) {
|
|
||||||
missing.push('ffmpeg');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missing.length > 0) fail(`Missing dependencies: ${missing.join(' ')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function checkPickerDependencies(args: Args): void {
|
|
||||||
if (args.useRofi) {
|
|
||||||
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!commandExists('fzf')) fail('Missing dependency: fzf');
|
|
||||||
}
|
|
||||||
|
|
||||||
async function chooseTarget(
|
|
||||||
args: Args,
|
|
||||||
scriptPath: string,
|
scriptPath: string,
|
||||||
): Promise<{ target: string; kind: 'file' | 'url' } | null> {
|
mpvSocketPath: string,
|
||||||
if (args.target) {
|
appPath: string | null,
|
||||||
return { target: args.target, kind: args.targetKind as 'file' | 'url' };
|
): LauncherCommandContext {
|
||||||
}
|
return {
|
||||||
|
args,
|
||||||
const searchDir = realpathMaybe(resolvePathMaybe(args.directory));
|
scriptPath,
|
||||||
if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) {
|
scriptName: path.basename(scriptPath),
|
||||||
fail(`Directory not found: ${searchDir}`);
|
mpvSocketPath,
|
||||||
}
|
appPath,
|
||||||
|
launcherJellyfinConfig: loadLauncherJellyfinConfig(),
|
||||||
const videos = collectVideos(searchDir, args.recursive);
|
processAdapter: nodeProcessAdapter,
|
||||||
if (videos.length === 0) {
|
};
|
||||||
fail(`No video files found in: ${searchDir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
log('info', args.logLevel, `Browsing: ${searchDir} (${videos.length} videos found)`);
|
|
||||||
|
|
||||||
const selected = args.useRofi
|
|
||||||
? showRofiMenu(videos, searchDir, args.recursive, scriptPath, args.logLevel)
|
|
||||||
: showFzfMenu(videos);
|
|
||||||
|
|
||||||
if (!selected) return null;
|
|
||||||
return { target: selected, kind: 'file' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerCleanup(args: Args): void {
|
function ensureAppPath(context: LauncherCommandContext): string {
|
||||||
process.on('SIGINT', () => {
|
if (context.appPath) {
|
||||||
stopOverlay(args);
|
return context.appPath;
|
||||||
process.exit(130);
|
|
||||||
});
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
stopOverlay(args);
|
|
||||||
process.exit(143);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveMainConfigPath(): string {
|
|
||||||
return resolveConfigFilePath({
|
|
||||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
|
||||||
homeDir: os.homedir(),
|
|
||||||
existsSync: fs.existsSync,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function runDoctor(args: Args, appPath: string | null, mpvSocketPath: string): never {
|
|
||||||
const checks: Array<{ label: string; ok: boolean; detail: string }> = [
|
|
||||||
{
|
|
||||||
label: 'app binary',
|
|
||||||
ok: Boolean(appPath),
|
|
||||||
detail: appPath || 'not found (set SUBMINER_APPIMAGE_PATH)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'mpv',
|
|
||||||
ok: commandExists('mpv'),
|
|
||||||
detail: commandExists('mpv') ? 'found' : 'missing',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'yt-dlp',
|
|
||||||
ok: commandExists('yt-dlp'),
|
|
||||||
detail: commandExists('yt-dlp') ? 'found' : 'missing (optional unless YouTube URLs)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'ffmpeg',
|
|
||||||
ok: commandExists('ffmpeg'),
|
|
||||||
detail: commandExists('ffmpeg') ? 'found' : 'missing (optional unless subtitle generation)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'fzf',
|
|
||||||
ok: commandExists('fzf'),
|
|
||||||
detail: commandExists('fzf') ? 'found' : 'missing (optional if using rofi)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'rofi',
|
|
||||||
ok: commandExists('rofi'),
|
|
||||||
detail: commandExists('rofi') ? 'found' : 'missing (optional if using fzf)',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'config',
|
|
||||||
ok: fs.existsSync(resolveMainConfigPath()),
|
|
||||||
detail: resolveMainConfigPath(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'mpv socket path',
|
|
||||||
ok: true,
|
|
||||||
detail: mpvSocketPath,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const hasHardFailure = checks.some((entry) =>
|
|
||||||
entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false,
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const check of checks) {
|
|
||||||
log(check.ok ? 'info' : 'warn', args.logLevel, `[doctor] ${check.label}: ${check.detail}`);
|
|
||||||
}
|
}
|
||||||
process.exit(hasHardFailure ? 1 : 0);
|
if (context.processAdapter.platform() === 'darwin') {
|
||||||
|
fail(
|
||||||
|
'SubMiner app binary not found. Install SubMiner.app to /Applications or ~/Applications, or set SUBMINER_APPIMAGE_PATH.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const scriptPath = process.argv[1] || 'subminer';
|
const scriptPath = process.argv[1] || 'subminer';
|
||||||
const scriptName = path.basename(scriptPath);
|
const scriptName = path.basename(scriptPath);
|
||||||
const launcherConfig = loadLauncherYoutubeSubgenConfig();
|
const launcherConfig = loadLauncherYoutubeSubgenConfig();
|
||||||
const launcherJellyfinConfig = loadLauncherJellyfinConfig();
|
|
||||||
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig);
|
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig);
|
||||||
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
|
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
|
||||||
const mpvSocketPath = pluginRuntimeConfig.socketPath;
|
const appPath = findAppBinary(scriptPath);
|
||||||
|
|
||||||
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
|
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
|
||||||
|
|
||||||
const appPath = findAppBinary(process.argv[1] || 'subminer');
|
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig.socketPath, appPath);
|
||||||
if (args.doctor) {
|
|
||||||
runDoctor(args, appPath, mpvSocketPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.configPath) {
|
if (runDoctorCommand(context)) {
|
||||||
process.stdout.write(`${resolveMainConfigPath()}\n`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.configShow) {
|
if (runConfigCommand(context)) {
|
||||||
const configPath = resolveMainConfigPath();
|
|
||||||
if (!fs.existsSync(configPath)) {
|
|
||||||
fail(`Config file not found: ${configPath}`);
|
|
||||||
}
|
|
||||||
const contents = fs.readFileSync(configPath, 'utf8');
|
|
||||||
process.stdout.write(contents);
|
|
||||||
if (!contents.endsWith('\n')) {
|
|
||||||
process.stdout.write('\n');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.mpvSocket) {
|
if (await runMpvPreAppCommand(context)) {
|
||||||
process.stdout.write(`${mpvSocketPath}\n`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.mpvStatus) {
|
const resolvedAppPath = ensureAppPath(context);
|
||||||
const ready = await waitForUnixSocketReady(mpvSocketPath, 500);
|
state.appPath = resolvedAppPath;
|
||||||
log(
|
const appContext: LauncherCommandContext = {
|
||||||
ready ? 'info' : 'warn',
|
...context,
|
||||||
args.logLevel,
|
appPath: resolvedAppPath,
|
||||||
`[mpv] socket ${ready ? 'ready' : 'not ready'}: ${mpvSocketPath}`,
|
};
|
||||||
);
|
|
||||||
process.exit(ready ? 0 : 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!appPath) {
|
if (runAppPassthroughCommand(appContext)) {
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
fail(
|
|
||||||
'SubMiner app binary not found. Install SubMiner.app to /Applications or ~/Applications, or set SUBMINER_APPIMAGE_PATH.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
|
||||||
}
|
|
||||||
state.appPath = appPath;
|
|
||||||
|
|
||||||
if (args.appPassthrough) {
|
|
||||||
runAppCommandWithInherit(appPath, args.appArgs);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.mpvIdle) {
|
|
||||||
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
|
||||||
const ready = await waitForUnixSocketReady(mpvSocketPath, 8000);
|
|
||||||
if (!ready) {
|
|
||||||
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
|
|
||||||
}
|
|
||||||
log('info', args.logLevel, `[mpv] idle instance ready on ${mpvSocketPath}`);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.texthookerOnly) {
|
if (await runMpvPostAppCommand(appContext)) {
|
||||||
launchTexthookerOnly(appPath, args);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.jellyfin) {
|
if (runTexthookerCommand(appContext)) {
|
||||||
const forwarded = ['--jellyfin'];
|
return;
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.jellyfinLogin) {
|
if (await runJellyfinCommand(appContext)) {
|
||||||
const serverUrl = args.jellyfinServer || launcherJellyfinConfig.serverUrl || '';
|
return;
|
||||||
const username = args.jellyfinUsername || launcherJellyfinConfig.username || '';
|
|
||||||
const password = args.jellyfinPassword || '';
|
|
||||||
if (!serverUrl || !username || !password) {
|
|
||||||
fail(
|
|
||||||
'--jellyfin-login requires server, username, and password. Pass flags or run `subminer --jellyfin`.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const forwarded = [
|
|
||||||
'--jellyfin-login',
|
|
||||||
'--jellyfin-server',
|
|
||||||
serverUrl,
|
|
||||||
'--jellyfin-username',
|
|
||||||
username,
|
|
||||||
'--jellyfin-password',
|
|
||||||
password,
|
|
||||||
];
|
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (args.jellyfinLogout) {
|
await runPlaybackCommand(appContext);
|
||||||
const forwarded = ['--jellyfin-logout'];
|
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.jellyfinPlay) {
|
|
||||||
if (!args.useRofi && !commandExists('fzf')) {
|
|
||||||
fail('fzf not found. Install fzf or use -R for rofi.');
|
|
||||||
}
|
|
||||||
if (args.useRofi && !commandExists('rofi')) {
|
|
||||||
fail('rofi not found. Install rofi or omit -R for fzf.');
|
|
||||||
}
|
|
||||||
await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.jellyfinDiscovery) {
|
|
||||||
const forwarded = ['--start'];
|
|
||||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
|
||||||
runAppCommandWithInherit(appPath, forwarded);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!args.target) {
|
|
||||||
checkPickerDependencies(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetChoice = await chooseTarget(args, process.argv[1] || 'subminer');
|
|
||||||
if (!targetChoice) {
|
|
||||||
log('info', args.logLevel, 'No video selected, exiting');
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
checkDependencies({
|
|
||||||
...args,
|
|
||||||
target: targetChoice ? targetChoice.target : args.target,
|
|
||||||
targetKind: targetChoice ? targetChoice.kind : 'url',
|
|
||||||
});
|
|
||||||
|
|
||||||
registerCleanup(args);
|
|
||||||
|
|
||||||
let selectedTarget = targetChoice
|
|
||||||
? {
|
|
||||||
target: targetChoice.target,
|
|
||||||
kind: targetChoice.kind as 'file' | 'url',
|
|
||||||
}
|
|
||||||
: { target: args.target, kind: 'url' as const };
|
|
||||||
|
|
||||||
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
|
||||||
let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined;
|
|
||||||
|
|
||||||
if (isYoutubeUrl && args.youtubeSubgenMode === 'preprocess') {
|
|
||||||
log('info', args.logLevel, 'YouTube subtitle mode: preprocess');
|
|
||||||
const generated = await generateYoutubeSubtitles(selectedTarget.target, args);
|
|
||||||
preloadedSubtitles = {
|
|
||||||
primaryPath: generated.primaryPath,
|
|
||||||
secondaryPath: generated.secondaryPath,
|
|
||||||
};
|
|
||||||
log(
|
|
||||||
'info',
|
|
||||||
args.logLevel,
|
|
||||||
`YouTube preprocess result: primary=${generated.primaryPath ? 'ready' : 'missing'}, secondary=${generated.secondaryPath ? 'ready' : 'missing'}`,
|
|
||||||
);
|
|
||||||
} else if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
|
|
||||||
log('info', args.logLevel, 'YouTube subtitle mode: automatic (background)');
|
|
||||||
} else if (isYoutubeUrl) {
|
|
||||||
log('info', args.logLevel, 'YouTube subtitle mode: off');
|
|
||||||
}
|
|
||||||
|
|
||||||
startMpv(
|
|
||||||
selectedTarget.target,
|
|
||||||
selectedTarget.kind,
|
|
||||||
args,
|
|
||||||
mpvSocketPath,
|
|
||||||
appPath,
|
|
||||||
preloadedSubtitles,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
|
|
||||||
void generateYoutubeSubtitles(selectedTarget.target, args, async (lang, subtitlePath) => {
|
|
||||||
try {
|
|
||||||
await loadSubtitleIntoMpv(mpvSocketPath, subtitlePath, lang === 'primary', args.logLevel);
|
|
||||||
} catch (error) {
|
|
||||||
log(
|
|
||||||
'warn',
|
|
||||||
args.logLevel,
|
|
||||||
`Generated subtitle ready but failed to load in mpv: ${(error as Error).message}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}).catch((error) => {
|
|
||||||
log(
|
|
||||||
'warn',
|
|
||||||
args.logLevel,
|
|
||||||
`Background subtitle generation failed: ${(error as Error).message}`,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
|
|
||||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
|
|
||||||
if (shouldStartOverlay) {
|
|
||||||
if (ready) {
|
|
||||||
log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
|
||||||
} else {
|
|
||||||
log(
|
|
||||||
'info',
|
|
||||||
args.logLevel,
|
|
||||||
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await startOverlay(appPath, args, mpvSocketPath);
|
|
||||||
} else if (ready) {
|
|
||||||
log(
|
|
||||||
'info',
|
|
||||||
args.logLevel,
|
|
||||||
'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)',
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
log(
|
|
||||||
'info',
|
|
||||||
args.logLevel,
|
|
||||||
'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
if (!state.mpvProc) {
|
|
||||||
stopOverlay(args);
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
state.mpvProc.on('exit', (code) => {
|
|
||||||
stopOverlay(args);
|
|
||||||
process.exitCode = code ?? 0;
|
|
||||||
resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch((error: unknown) => {
|
main().catch((error: unknown) => {
|
||||||
|
|||||||
21
launcher/process-adapter.ts
Normal file
21
launcher/process-adapter.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface ProcessAdapter {
|
||||||
|
platform(): NodeJS.Platform;
|
||||||
|
onSignal(signal: NodeJS.Signals, handler: () => void): void;
|
||||||
|
writeStdout(text: string): void;
|
||||||
|
exit(code: number): never;
|
||||||
|
setExitCode(code: number): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const nodeProcessAdapter: ProcessAdapter = {
|
||||||
|
platform: () => process.platform,
|
||||||
|
onSignal: (signal, handler) => {
|
||||||
|
process.on(signal, handler);
|
||||||
|
},
|
||||||
|
writeStdout: (text) => {
|
||||||
|
process.stdout.write(text);
|
||||||
|
},
|
||||||
|
exit: (code) => process.exit(code),
|
||||||
|
setExitCode: (code) => {
|
||||||
|
process.exitCode = code;
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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/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: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/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",
|
"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/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: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