chore: add project management metadata and remaining repository files

This commit is contained in:
2026-02-22 21:43:43 -08:00
parent 64020a9069
commit 4ebabbe639
37 changed files with 7531 additions and 0 deletions

View 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;
}

View 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,
);
});

View 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;
}

View 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;
}

View 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;
}

View 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,
);
}

View 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;
}

View 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();
});
});
}