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

14
backlog/config.yml Normal file
View File

@@ -0,0 +1,14 @@
project_name: "SubMiner"
default_status: "To Do"
statuses: ["To Do", "In Progress", "Done"]
labels: []
date_format: yyyy-mm-dd
max_column_width: 20
auto_open_browser: true
default_port: 6420
remote_operations: true
auto_commit: false
bypass_git_hooks: false
check_active_branches: true
active_branch_days: 30
task_prefix: "task"

View File

@@ -0,0 +1,75 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
inferAniSkipMetadataForFile,
buildSubminerScriptOpts,
parseAniSkipGuessitJson,
} from './aniskip-metadata';
test('parseAniSkipGuessitJson extracts title season and episode', () => {
const parsed = parseAniSkipGuessitJson(
JSON.stringify({ title: 'My Show', season: 2, episode: 7 }),
'/tmp/My.Show.S02E07.mkv',
);
assert.deepEqual(parsed, {
title: 'My Show',
season: 2,
episode: 7,
source: 'guessit',
});
});
test('parseAniSkipGuessitJson prefers series over episode title', () => {
const parsed = parseAniSkipGuessitJson(
JSON.stringify({
title: 'What Is This, a Picnic',
series: 'Solo Leveling',
season: 1,
episode: 10,
}),
'/tmp/S01E10-What.Is.This,.a.Picnic-Bluray-1080p.mkv',
);
assert.deepEqual(parsed, {
title: 'Solo Leveling',
season: 1,
episode: 10,
source: 'guessit',
});
});
test('inferAniSkipMetadataForFile falls back to filename title when guessit unavailable', () => {
const parsed = inferAniSkipMetadataForFile('/tmp/Another_Show_-_03.mkv', {
commandExists: () => false,
runGuessit: () => null,
});
assert.equal(parsed.title.length > 0, true);
assert.equal(parsed.source, 'fallback');
});
test('inferAniSkipMetadataForFile falls back to anime directory title when filename is episode-only', () => {
const parsed = inferAniSkipMetadataForFile(
'/truenas/jellyfin/anime/Solo Leveling/Season-1/S01E10-What.Is.This,.a.Picnic-Bluray-1080p.mkv',
{
commandExists: () => false,
runGuessit: () => null,
},
);
assert.equal(parsed.title, 'Solo Leveling');
assert.equal(parsed.season, 1);
assert.equal(parsed.episode, 10);
assert.equal(parsed.source, 'fallback');
});
test('buildSubminerScriptOpts includes aniskip metadata fields', () => {
const opts = buildSubminerScriptOpts('/tmp/SubMiner.AppImage', '/tmp/subminer.sock', {
title: 'Frieren: Beyond Journey\'s End',
season: 1,
episode: 5,
source: 'guessit',
});
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
assert.match(opts, /subminer-aniskip_title=Frieren: Beyond Journey's End/);
assert.match(opts, /subminer-aniskip_season=1/);
assert.match(opts, /subminer-aniskip_episode=5/);
});

View File

@@ -0,0 +1,196 @@
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { commandExists } from './util.js';
export interface AniSkipMetadata {
title: string;
season: number | null;
episode: number | null;
source: 'guessit' | 'fallback';
}
interface InferAniSkipDeps {
commandExists: (name: string) => boolean;
runGuessit: (mediaPath: string) => string | null;
}
function toPositiveInt(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value) && value > 0) {
return Math.floor(value);
}
if (typeof value === 'string') {
const parsed = Number.parseInt(value, 10);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return null;
}
function detectEpisodeFromName(baseName: string): number | null {
const patterns = [/[Ss]\d+[Ee](\d{1,3})/, /(?:^|[\s._-])[Ee][Pp]?[\s._-]*(\d{1,3})(?:$|[\s._-])/, /[-\s](\d{1,3})$/];
for (const pattern of patterns) {
const match = baseName.match(pattern);
if (!match || !match[1]) continue;
const parsed = Number.parseInt(match[1], 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return null;
}
function detectSeasonFromNameOrDir(mediaPath: string): number | null {
const baseName = path.basename(mediaPath, path.extname(mediaPath));
const seasonMatch = baseName.match(/[Ss](\d{1,2})[Ee]\d{1,3}/);
if (seasonMatch && seasonMatch[1]) {
const parsed = Number.parseInt(seasonMatch[1], 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
const parent = path.basename(path.dirname(mediaPath));
const parentMatch = parent.match(/(?:Season|S)[\s._-]*(\d{1,2})/i);
if (parentMatch && parentMatch[1]) {
const parsed = Number.parseInt(parentMatch[1], 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
}
return null;
}
function isSeasonDirectoryName(value: string): boolean {
return /^(?:season|s)[\s._-]*\d{1,2}$/i.test(value.trim());
}
function inferTitleFromPath(mediaPath: string): string {
const directory = path.dirname(mediaPath);
const segments = directory.split(/[\\/]+/).filter((segment) => segment.length > 0);
for (let index = 0; index < segments.length; index += 1) {
const segment = segments[index] || '';
if (!isSeasonDirectoryName(segment)) continue;
const showSegment = segments[index - 1];
if (typeof showSegment === 'string' && showSegment.length > 0) {
const cleaned = cleanupTitle(showSegment);
if (cleaned) return cleaned;
}
}
const parent = path.basename(directory);
if (!isSeasonDirectoryName(parent)) {
const cleanedParent = cleanupTitle(parent);
if (cleanedParent) return cleanedParent;
}
const grandParent = path.basename(path.dirname(directory));
const cleanedGrandParent = cleanupTitle(grandParent);
return cleanedGrandParent;
}
function cleanupTitle(value: string): string {
return value
.replace(/\.[^/.]+$/, '')
.replace(/\[[^\]]+\]/g, ' ')
.replace(/\([^)]+\)/g, ' ')
.replace(/[Ss]\d+[Ee]\d+/g, ' ')
.replace(/[Ee][Pp]?[\s._-]*\d+/g, ' ')
.replace(/[_\-.]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function parseAniSkipGuessitJson(stdout: string, mediaPath: string): AniSkipMetadata | null {
const payload = stdout.trim();
if (!payload) return null;
try {
const parsed = JSON.parse(payload) as {
title?: unknown;
title_original?: unknown;
series?: unknown;
season?: unknown;
episode?: unknown;
episode_list?: unknown;
};
const rawTitle =
(typeof parsed.series === 'string' && parsed.series) ||
(typeof parsed.title === 'string' && parsed.title) ||
(typeof parsed.title_original === 'string' && parsed.title_original) ||
'';
const title = cleanupTitle(rawTitle) || inferTitleFromPath(mediaPath);
if (!title) return null;
const season = toPositiveInt(parsed.season);
const episodeFromDirect = toPositiveInt(parsed.episode);
const episodeFromList =
Array.isArray(parsed.episode_list) && parsed.episode_list.length > 0
? toPositiveInt(parsed.episode_list[0])
: null;
return {
title,
season,
episode: episodeFromDirect ?? episodeFromList,
source: 'guessit',
};
} catch {
return null;
}
}
function defaultRunGuessit(mediaPath: string): string | null {
const fileName = path.basename(mediaPath);
const result = spawnSync('guessit', ['--json', fileName], {
cwd: path.dirname(mediaPath),
encoding: 'utf8',
maxBuffer: 2_000_000,
windowsHide: true,
});
if (result.error || result.status !== 0) return null;
return result.stdout || null;
}
export function inferAniSkipMetadataForFile(
mediaPath: string,
deps: InferAniSkipDeps = { commandExists, runGuessit: defaultRunGuessit },
): AniSkipMetadata {
if (deps.commandExists('guessit')) {
const stdout = deps.runGuessit(mediaPath);
if (typeof stdout === 'string') {
const parsed = parseAniSkipGuessitJson(stdout, mediaPath);
if (parsed) return parsed;
}
}
const baseName = path.basename(mediaPath, path.extname(mediaPath));
const pathTitle = inferTitleFromPath(mediaPath);
const fallbackTitle = pathTitle || cleanupTitle(baseName) || baseName;
return {
title: fallbackTitle,
season: detectSeasonFromNameOrDir(mediaPath),
episode: detectEpisodeFromName(baseName),
source: 'fallback',
};
}
function sanitizeScriptOptValue(value: string): string {
return value.replace(/,/g, ' ').replace(/[\r\n]/g, ' ').replace(/\s+/g, ' ').trim();
}
export function buildSubminerScriptOpts(
appPath: string,
socketPath: string,
aniSkipMetadata: AniSkipMetadata | null,
): string {
const parts = [
`subminer-binary_path=${sanitizeScriptOptValue(appPath)}`,
`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`,
];
if (aniSkipMetadata && aniSkipMetadata.title) {
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
}
if (aniSkipMetadata && aniSkipMetadata.season && aniSkipMetadata.season > 0) {
parts.push(`subminer-aniskip_season=${aniSkipMetadata.season}`);
}
if (aniSkipMetadata && aniSkipMetadata.episode && aniSkipMetadata.episode > 0) {
parts.push(`subminer-aniskip_episode=${aniSkipMetadata.episode}`);
}
return parts.join(',');
}

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

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

11
launcher/config-path.ts Normal file
View 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,
});
}

21
launcher/config.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { execFileSync } from 'node:child_process';
import path from 'node:path';
test('launcher root help lists subcommands', () => {
const output = execFileSync(
'bun',
['run', path.join(process.cwd(), 'launcher/main.ts'), '-h'],
{ encoding: 'utf8' },
);
assert.match(output, /Commands:/);
assert.match(output, /jellyfin\|jf/);
assert.match(output, /yt\|youtube/);
assert.match(output, /doctor/);
assert.match(output, /config/);
assert.match(output, /mpv/);
assert.match(output, /texthooker/);
assert.match(output, /app\|bin/);
});

61
launcher/config.ts Normal file
View File

@@ -0,0 +1,61 @@
import { fail } from './log.js';
import type {
Args,
LauncherJellyfinConfig,
LauncherYoutubeSubgenConfig,
LogLevel,
PluginRuntimeConfig,
} from './types.js';
import {
applyInvocationsToArgs,
applyRootOptionsToArgs,
createDefaultArgs,
} from './config/args-normalizer.js';
import { parseCliPrograms, resolveTopLevelCommand } from './config/cli-parser-builder.js';
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
import { readPluginRuntimeConfig as readPluginRuntimeConfigValue } from './config/plugin-runtime-config.js';
import { readLauncherMainConfigObject } from './config/shared-config-reader.js';
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
export function loadLauncherYoutubeSubgenConfig(): LauncherYoutubeSubgenConfig {
const root = readLauncherMainConfigObject();
if (!root) return {};
return parseLauncherYoutubeSubgenConfig(root);
}
export function loadLauncherJellyfinConfig(): LauncherJellyfinConfig {
const root = readLauncherMainConfigObject();
if (!root) return {};
return parseLauncherJellyfinConfig(root);
}
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
return readPluginRuntimeConfigValue(logLevel);
}
export function parseArgs(
argv: string[],
scriptName: string,
launcherConfig: LauncherYoutubeSubgenConfig,
): Args {
const topLevelCommand = resolveTopLevelCommand(argv);
const parsed = createDefaultArgs(launcherConfig);
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
parsed.appPassthrough = true;
parsed.appArgs = argv.slice(topLevelCommand.index + 1);
return parsed;
}
let cliResult: ReturnType<typeof parseCliPrograms>;
try {
cliResult = parseCliPrograms(argv, scriptName);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
fail(message);
}
applyRootOptionsToArgs(parsed, cliResult.options, cliResult.rootTarget);
applyInvocationsToArgs(parsed, cliResult.invocations);
return parsed;
}

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

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

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

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

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

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

399
launcher/jellyfin.ts Normal file
View File

@@ -0,0 +1,399 @@
import path from 'node:path';
import fs from 'node:fs';
import { spawnSync } from 'node:child_process';
import type {
Args,
JellyfinSessionConfig,
JellyfinLibraryEntry,
JellyfinItemEntry,
JellyfinGroupEntry,
} from './types.js';
import { log, fail } from './log.js';
import { commandExists, resolvePathMaybe } from './util.js';
import {
pickLibrary,
pickItem,
pickGroup,
promptOptionalJellyfinSearch,
findRofiTheme,
} from './picker.js';
import { loadLauncherJellyfinConfig } from './config.js';
import {
runAppCommandWithInheritLogged,
launchMpvIdleDetached,
waitForUnixSocketReady,
} from './mpv.js';
export function sanitizeServerUrl(value: string): string {
return value.trim().replace(/\/+$/, '');
}
export async function jellyfinApiRequest<T>(
session: JellyfinSessionConfig,
requestPath: string,
): Promise<T> {
const url = `${session.serverUrl}${requestPath}`;
const response = await fetch(url, {
headers: {
'X-Emby-Token': session.accessToken,
Authorization: `MediaBrowser Token="${session.accessToken}"`,
},
});
if (response.status === 401 || response.status === 403) {
fail('Jellyfin token invalid/expired. Run --jellyfin-login or --jellyfin.');
}
if (!response.ok) {
fail(`Jellyfin API failed: ${response.status} ${response.statusText}`);
}
return (await response.json()) as T;
}
function itemPreviewUrl(session: JellyfinSessionConfig, id: string): string {
return `${session.serverUrl}/Items/${id}/Images/Primary?maxHeight=720&quality=85&api_key=${encodeURIComponent(session.accessToken)}`;
}
function jellyfinIconCacheDir(session: JellyfinSessionConfig): string {
const serverKey = session.serverUrl.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 96);
const userKey = session.userId.replace(/[^a-zA-Z0-9]+/g, '_').slice(0, 96);
const baseDir = session.iconCacheDir
? resolvePathMaybe(session.iconCacheDir)
: path.join('/tmp', 'subminer-jellyfin-icons');
return path.join(baseDir, serverKey, userKey);
}
function jellyfinIconPath(session: JellyfinSessionConfig, id: string): string {
const safeId = id.replace(/[^a-zA-Z0-9._-]+/g, '_');
return path.join(jellyfinIconCacheDir(session), `${safeId}.jpg`);
}
function ensureJellyfinIcon(session: JellyfinSessionConfig, id: string): string | null {
if (!session.pullPictures || !id || !commandExists('curl')) return null;
const iconPath = jellyfinIconPath(session, id);
try {
if (fs.existsSync(iconPath) && fs.statSync(iconPath).size > 0) {
return iconPath;
}
} catch {
// continue to download
}
try {
fs.mkdirSync(path.dirname(iconPath), { recursive: true });
} catch {
return null;
}
const result = spawnSync('curl', ['-fsSL', '-o', iconPath, itemPreviewUrl(session, id)], {
stdio: 'ignore',
});
if (result.error || result.status !== 0) return null;
try {
if (fs.existsSync(iconPath) && fs.statSync(iconPath).size > 0) {
return iconPath;
}
} catch {
return null;
}
return null;
}
export function formatJellyfinItemDisplay(item: Record<string, unknown>): string {
const type = typeof item.Type === 'string' ? item.Type : 'Item';
const name = typeof item.Name === 'string' ? item.Name : 'Untitled';
if (type === 'Episode') {
const series = typeof item.SeriesName === 'string' ? item.SeriesName : '';
const season =
typeof item.ParentIndexNumber === 'number'
? String(item.ParentIndexNumber).padStart(2, '0')
: '00';
const episode =
typeof item.IndexNumber === 'number' ? String(item.IndexNumber).padStart(2, '0') : '00';
return `${series} S${season}E${episode} ${name}`.trim();
}
return `${name} (${type})`;
}
export async function resolveJellyfinSelection(
args: Args,
session: JellyfinSessionConfig,
themePath: string | null = null,
): Promise<string> {
const asNumberOrNull = (value: unknown): number | null => {
if (typeof value !== 'number' || !Number.isFinite(value)) return null;
return value;
};
const compareByName = (left: string, right: string): number =>
left.localeCompare(right, undefined, { sensitivity: 'base', numeric: true });
const sortEntries = (
entries: Array<{
id: string;
type: string;
name: string;
parentIndex: number | null;
index: number | null;
display: string;
}>,
) =>
entries.sort((left, right) => {
if (left.type === 'Episode' && right.type === 'Episode') {
const leftSeason = left.parentIndex ?? Number.MAX_SAFE_INTEGER;
const rightSeason = right.parentIndex ?? Number.MAX_SAFE_INTEGER;
if (leftSeason !== rightSeason) return leftSeason - rightSeason;
const leftEpisode = left.index ?? Number.MAX_SAFE_INTEGER;
const rightEpisode = right.index ?? Number.MAX_SAFE_INTEGER;
if (leftEpisode !== rightEpisode) return leftEpisode - rightEpisode;
}
if (left.type !== right.type) {
const leftEpisodeLike = left.type === 'Episode';
const rightEpisodeLike = right.type === 'Episode';
if (leftEpisodeLike && !rightEpisodeLike) return -1;
if (!leftEpisodeLike && rightEpisodeLike) return 1;
}
return compareByName(left.display, right.display);
});
const libsPayload = await jellyfinApiRequest<{ Items?: Array<Record<string, unknown>> }>(
session,
`/Users/${session.userId}/Views`,
);
const libraries: JellyfinLibraryEntry[] = (libsPayload.Items || [])
.map((item) => ({
id: typeof item.Id === 'string' ? item.Id : '',
name: typeof item.Name === 'string' ? item.Name : 'Untitled',
kind:
typeof item.CollectionType === 'string'
? item.CollectionType
: typeof item.Type === 'string'
? item.Type
: 'unknown',
}))
.filter((item) => item.id.length > 0);
let libraryId = session.defaultLibraryId;
if (!libraryId) {
libraryId = pickLibrary(session, libraries, args.useRofi, ensureJellyfinIcon, '', themePath);
if (!libraryId) fail('No Jellyfin library selected.');
}
const searchTerm = await promptOptionalJellyfinSearch(args.useRofi, themePath);
const fetchItemsPaged = async (parentId: string) => {
const out: Array<Record<string, unknown>> = [];
let startIndex = 0;
while (true) {
const payload = await jellyfinApiRequest<{
Items?: Array<Record<string, unknown>>;
TotalRecordCount?: number;
}>(
session,
`/Users/${session.userId}/Items?ParentId=${encodeURIComponent(parentId)}&Recursive=false&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`,
);
const page = payload.Items || [];
if (page.length === 0) break;
out.push(...page);
startIndex += page.length;
const total = typeof payload.TotalRecordCount === 'number' ? payload.TotalRecordCount : null;
if (total !== null && startIndex >= total) break;
if (page.length < 500) break;
}
return out;
};
const topLevelEntries = await fetchItemsPaged(libraryId);
const groups: JellyfinGroupEntry[] = topLevelEntries
.filter((item) => {
const type = typeof item.Type === 'string' ? item.Type : '';
return (
type === 'Series' || type === 'Folder' || type === 'CollectionFolder' || type === 'Season'
);
})
.map((item) => {
const type = typeof item.Type === 'string' ? item.Type : 'Folder';
const name = typeof item.Name === 'string' ? item.Name : 'Untitled';
return {
id: typeof item.Id === 'string' ? item.Id : '',
name,
type,
display: `${name} (${type})`,
};
})
.filter((entry) => entry.id.length > 0);
let contentParentId = libraryId;
let contentRecursive = true;
const selectedGroupId = pickGroup(
session,
groups,
args.useRofi,
ensureJellyfinIcon,
searchTerm,
themePath,
);
if (selectedGroupId) {
contentParentId = selectedGroupId;
const nextLevelEntries = await fetchItemsPaged(selectedGroupId);
const seasons: JellyfinGroupEntry[] = nextLevelEntries
.filter((item) => {
const type = typeof item.Type === 'string' ? item.Type : '';
return type === 'Season' || type === 'Folder';
})
.map((item) => {
const type = typeof item.Type === 'string' ? item.Type : 'Season';
const name = typeof item.Name === 'string' ? item.Name : 'Untitled';
return {
id: typeof item.Id === 'string' ? item.Id : '',
name,
type,
display: `${name} (${type})`,
};
})
.filter((entry) => entry.id.length > 0);
if (seasons.length > 0) {
const seasonsById = new Map(seasons.map((entry) => [entry.id, entry]));
const selectedSeasonId = pickGroup(
session,
seasons,
args.useRofi,
ensureJellyfinIcon,
'',
themePath,
);
if (!selectedSeasonId) fail('No Jellyfin season selected.');
contentParentId = selectedSeasonId;
const selectedSeason = seasonsById.get(selectedSeasonId);
if (selectedSeason?.type === 'Season') {
contentRecursive = false;
}
}
}
const fetchPage = async (startIndex: number) =>
jellyfinApiRequest<{
Items?: Array<Record<string, unknown>>;
TotalRecordCount?: number;
}>(
session,
`/Users/${session.userId}/Items?ParentId=${encodeURIComponent(contentParentId)}&Recursive=${contentRecursive ? 'true' : 'false'}&SortBy=SortName&SortOrder=Ascending&Limit=500&StartIndex=${startIndex}`,
);
const allEntries: Array<Record<string, unknown>> = [];
let startIndex = 0;
while (true) {
const payload = await fetchPage(startIndex);
const page = payload.Items || [];
if (page.length === 0) break;
allEntries.push(...page);
startIndex += page.length;
const total = typeof payload.TotalRecordCount === 'number' ? payload.TotalRecordCount : null;
if (total !== null && startIndex >= total) break;
if (page.length < 500) break;
}
let items: JellyfinItemEntry[] = sortEntries(
allEntries
.filter((item) => {
const type = typeof item.Type === 'string' ? item.Type : '';
return type === 'Movie' || type === 'Episode' || type === 'Audio';
})
.map((item) => ({
id: typeof item.Id === 'string' ? item.Id : '',
name: typeof item.Name === 'string' ? item.Name : '',
type: typeof item.Type === 'string' ? item.Type : 'Item',
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item),
}))
.filter((item) => item.id.length > 0),
).map(({ id, name, type, display }) => ({
id,
name,
type,
display,
}));
if (items.length === 0) {
items = sortEntries(
allEntries
.filter((item) => {
const type = typeof item.Type === 'string' ? item.Type : '';
if (type === 'Folder' || type === 'CollectionFolder') return false;
const mediaType = typeof item.MediaType === 'string' ? item.MediaType.toLowerCase() : '';
if (mediaType === 'video' || mediaType === 'audio') return true;
return (
type === 'Movie' ||
type === 'Episode' ||
type === 'Audio' ||
type === 'Video' ||
type === 'MusicVideo'
);
})
.map((item) => ({
id: typeof item.Id === 'string' ? item.Id : '',
name: typeof item.Name === 'string' ? item.Name : '',
type: typeof item.Type === 'string' ? item.Type : 'Item',
parentIndex: asNumberOrNull(item.ParentIndexNumber),
index: asNumberOrNull(item.IndexNumber),
display: formatJellyfinItemDisplay(item),
}))
.filter((item) => item.id.length > 0),
).map(({ id, name, type, display }) => ({
id,
name,
type,
display,
}));
}
const itemId = pickItem(session, items, args.useRofi, ensureJellyfinIcon, '', themePath);
if (!itemId) fail('No Jellyfin item selected.');
return itemId;
}
export async function runJellyfinPlayMenu(
appPath: string,
args: Args,
scriptPath: string,
mpvSocketPath: string,
): Promise<never> {
const config = loadLauncherJellyfinConfig();
const envAccessToken = (process.env.SUBMINER_JELLYFIN_ACCESS_TOKEN || '').trim();
const envUserId = (process.env.SUBMINER_JELLYFIN_USER_ID || '').trim();
const session: JellyfinSessionConfig = {
serverUrl: sanitizeServerUrl(args.jellyfinServer || config.serverUrl || ''),
accessToken: envAccessToken,
userId: envUserId,
defaultLibraryId: config.defaultLibraryId || '',
pullPictures: config.pullPictures === true,
iconCacheDir: config.iconCacheDir || '',
};
if (!session.serverUrl || !session.accessToken || !session.userId) {
fail(
'Missing Jellyfin session. Set SUBMINER_JELLYFIN_ACCESS_TOKEN and SUBMINER_JELLYFIN_USER_ID, then retry.',
);
}
const rofiTheme = args.useRofi ? findRofiTheme(scriptPath) : null;
if (args.useRofi && !rofiTheme) {
log('warn', args.logLevel, 'Rofi theme not found for Jellyfin picker; using rofi defaults.');
}
const itemId = await resolveJellyfinSelection(args, session, rofiTheme);
log('debug', args.logLevel, `Jellyfin selection resolved: itemId=${itemId}`);
log('debug', args.logLevel, `Ensuring MPV IPC socket is ready: ${mpvSocketPath}`);
let mpvReady = false;
if (fs.existsSync(mpvSocketPath)) {
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 250);
}
if (!mpvReady) {
await launchMpvIdleDetached(mpvSocketPath, appPath, args);
mpvReady = await waitForUnixSocketReady(mpvSocketPath, 8000);
}
log('debug', args.logLevel, `MPV socket ready check result: ${mpvReady ? 'ready' : 'not ready'}`);
if (!mpvReady) {
fail(`MPV IPC socket not ready: ${mpvSocketPath}`);
}
const forwarded = ['--start', '--jellyfin-play', '--jellyfin-item-id', itemId];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
}

497
launcher/jimaku.ts Normal file
View File

@@ -0,0 +1,497 @@
import fs from 'node:fs';
import path from 'node:path';
import http from 'node:http';
import https from 'node:https';
import { spawnSync } from 'node:child_process';
import type { Args, JimakuLanguagePreference } from './types.js';
import { DEFAULT_JIMAKU_API_BASE_URL } from './types.js';
import { commandExists } from './util.js';
export interface JimakuEntry {
id: number;
name: string;
english_name?: string | null;
japanese_name?: string | null;
flags?: {
anime?: boolean;
movie?: boolean;
adult?: boolean;
external?: boolean;
unverified?: boolean;
};
}
interface JimakuFileEntry {
name: string;
url: string;
size: number;
last_modified: string;
}
interface JimakuApiError {
error: string;
code?: number;
retryAfter?: number;
}
type JimakuApiResponse<T> = { ok: true; data: T } | { ok: false; error: JimakuApiError };
type JimakuDownloadResult = { ok: true; path: string } | { ok: false; error: JimakuApiError };
interface JimakuConfig {
apiKey: string;
apiKeyCommand: string;
apiBaseUrl: string;
languagePreference: JimakuLanguagePreference;
maxEntryResults: number;
}
interface JimakuMediaInfo {
title: string;
season: number | null;
episode: number | null;
confidence: 'high' | 'medium' | 'low';
filename: string;
rawTitle: string;
}
function getRetryAfter(headers: http.IncomingHttpHeaders): number | undefined {
const value = headers['x-ratelimit-reset-after'];
if (!value) return undefined;
const raw = Array.isArray(value) ? value[0] : value;
const parsed = Number.parseFloat(raw);
if (!Number.isFinite(parsed)) return undefined;
return parsed;
}
export function matchEpisodeFromName(name: string): {
season: number | null;
episode: number | null;
index: number | null;
confidence: 'high' | 'medium' | 'low';
} {
const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i);
if (seasonEpisode && seasonEpisode.index !== undefined) {
return {
season: Number.parseInt(seasonEpisode[1], 10),
episode: Number.parseInt(seasonEpisode[2], 10),
index: seasonEpisode.index,
confidence: 'high',
};
}
const alt = name.match(/(\d{1,2})x(\d{1,3})/i);
if (alt && alt.index !== undefined) {
return {
season: Number.parseInt(alt[1], 10),
episode: Number.parseInt(alt[2], 10),
index: alt.index,
confidence: 'high',
};
}
const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i);
if (epOnly && epOnly.index !== undefined) {
return {
season: null,
episode: Number.parseInt(epOnly[1], 10),
index: epOnly.index,
confidence: 'medium',
};
}
const numeric = name.match(/(?:^|[-–—]\s*)(\d{1,3})\s*[-–—]/);
if (numeric && numeric.index !== undefined) {
return {
season: null,
episode: Number.parseInt(numeric[1], 10),
index: numeric.index,
confidence: 'medium',
};
}
return { season: null, episode: null, index: null, confidence: 'low' };
}
function detectSeasonFromDir(mediaPath: string): number | null {
const parent = path.basename(path.dirname(mediaPath));
const match = parent.match(/(?:Season|S)\s*(\d{1,2})/i);
if (!match) return null;
const parsed = Number.parseInt(match[1], 10);
return Number.isFinite(parsed) ? parsed : null;
}
function parseGuessitOutput(mediaPath: string, stdout: string): JimakuMediaInfo | null {
const payload = stdout.trim();
if (!payload) return null;
try {
const parsed = JSON.parse(payload) as {
title?: string;
title_original?: string;
series?: string;
season?: number | string;
episode?: number | string;
episode_list?: Array<number | string>;
};
const season =
typeof parsed.season === 'number'
? parsed.season
: typeof parsed.season === 'string'
? Number.parseInt(parsed.season, 10)
: null;
const directEpisode =
typeof parsed.episode === 'number'
? parsed.episode
: typeof parsed.episode === 'string'
? Number.parseInt(parsed.episode, 10)
: null;
const episodeFromList =
parsed.episode_list && parsed.episode_list.length > 0
? Number.parseInt(String(parsed.episode_list[0]), 10)
: null;
const episodeValue =
directEpisode !== null && Number.isFinite(directEpisode) ? directEpisode : episodeFromList;
const episode = Number.isFinite(episodeValue as number) ? (episodeValue as number) : null;
const title = (parsed.title || parsed.title_original || parsed.series || '').trim();
const hasStructuredData =
title.length > 0 ||
Number.isFinite(season as number) ||
Number.isFinite(episodeValue as number);
if (!hasStructuredData) return null;
return {
title: title || '',
season: Number.isFinite(season as number) ? season : detectSeasonFromDir(mediaPath),
episode: episode,
confidence: 'high',
filename: path.basename(mediaPath),
rawTitle: path.basename(mediaPath).replace(/\.[^/.]+$/, ''),
};
} catch {
return null;
}
}
function parseMediaInfoWithGuessit(mediaPath: string): JimakuMediaInfo | null {
if (!commandExists('guessit')) return null;
try {
const fileName = path.basename(mediaPath);
const result = spawnSync('guessit', ['--json', fileName], {
cwd: path.dirname(mediaPath),
encoding: 'utf8',
maxBuffer: 2_000_000,
windowsHide: true,
});
if (result.error || result.status !== 0) return null;
return parseGuessitOutput(mediaPath, result.stdout || '');
} catch {
return null;
}
}
function cleanupTitle(value: string): string {
return value
.replace(/^[\s-–—]+/, '')
.replace(/[\s-–—]+$/, '')
.replace(/\s+/g, ' ')
.trim();
}
function formatLangScore(name: string, pref: JimakuLanguagePreference): number {
if (pref === 'none') return 0;
const upper = name.toUpperCase();
const hasJa =
/(^|[\W_])JA([\W_]|$)/.test(upper) ||
/(^|[\W_])JPN([\W_]|$)/.test(upper) ||
upper.includes('.JA.');
const hasEn =
/(^|[\W_])EN([\W_]|$)/.test(upper) ||
/(^|[\W_])ENG([\W_]|$)/.test(upper) ||
upper.includes('.EN.');
if (pref === 'ja') {
if (hasJa) return 2;
if (hasEn) return 1;
} else if (pref === 'en') {
if (hasEn) return 2;
if (hasJa) return 1;
}
return 0;
}
export async function resolveJimakuApiKey(config: JimakuConfig): Promise<string | null> {
if (config.apiKey && config.apiKey.trim()) {
return config.apiKey.trim();
}
if (config.apiKeyCommand && config.apiKeyCommand.trim()) {
try {
const commandResult = spawnSync(config.apiKeyCommand, {
shell: true,
encoding: 'utf8',
timeout: 10000,
});
if (commandResult.error) return null;
const key = (commandResult.stdout || '').trim();
return key.length > 0 ? key : null;
} catch {
return null;
}
}
return null;
}
export function jimakuFetchJson<T>(
endpoint: string,
query: Record<string, string | number | boolean | null | undefined>,
options: { baseUrl: string; apiKey: string },
): Promise<JimakuApiResponse<T>> {
const url = new URL(endpoint, options.baseUrl);
for (const [key, value] of Object.entries(query)) {
if (value === null || value === undefined) continue;
url.searchParams.set(key, String(value));
}
return new Promise((resolve) => {
const requestUrl = new URL(url.toString());
const transport = requestUrl.protocol === 'https:' ? https : http;
const req = transport.request(
requestUrl,
{
method: 'GET',
headers: {
Authorization: options.apiKey,
'User-Agent': 'SubMiner',
},
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk.toString();
});
res.on('end', () => {
const status = res.statusCode || 0;
if (status >= 200 && status < 300) {
try {
const parsed = JSON.parse(data) as T;
resolve({ ok: true, data: parsed });
} catch {
resolve({
ok: false,
error: { error: 'Failed to parse Jimaku response JSON.' },
});
}
return;
}
let errorMessage = `Jimaku API error (HTTP ${status})`;
try {
const parsed = JSON.parse(data) as { error?: string };
if (parsed && parsed.error) {
errorMessage = parsed.error;
}
} catch {
// ignore parse errors
}
resolve({
ok: false,
error: {
error: errorMessage,
code: status || undefined,
retryAfter: status === 429 ? getRetryAfter(res.headers) : undefined,
},
});
});
},
);
req.on('error', (error) => {
resolve({
ok: false,
error: { error: `Jimaku request failed: ${(error as Error).message}` },
});
});
req.end();
});
}
export function parseMediaInfo(mediaPath: string | null): JimakuMediaInfo {
if (!mediaPath) {
return {
title: '',
season: null,
episode: null,
confidence: 'low',
filename: '',
rawTitle: '',
};
}
const guessitInfo = parseMediaInfoWithGuessit(mediaPath);
if (guessitInfo) return guessitInfo;
const filename = path.basename(mediaPath);
let name = filename.replace(/\.[^/.]+$/, '');
name = name.replace(/\[[^\]]*]/g, ' ');
name = name.replace(/\(\d{4}\)/g, ' ');
name = name.replace(/[._]/g, ' ');
name = name.replace(/[–—]/g, '-');
name = name.replace(/\s+/g, ' ').trim();
const parsed = matchEpisodeFromName(name);
let titlePart = name;
if (parsed.index !== null) {
titlePart = name.slice(0, parsed.index);
}
const seasonFromDir = parsed.season ?? detectSeasonFromDir(mediaPath);
const title = cleanupTitle(titlePart || name);
return {
title,
season: seasonFromDir,
episode: parsed.episode,
confidence: parsed.confidence,
filename,
rawTitle: name,
};
}
export function sortJimakuFiles(
files: JimakuFileEntry[],
pref: JimakuLanguagePreference,
): JimakuFileEntry[] {
if (pref === 'none') return files;
return [...files].sort((a, b) => {
const scoreDiff = formatLangScore(b.name, pref) - formatLangScore(a.name, pref);
if (scoreDiff !== 0) return scoreDiff;
return a.name.localeCompare(b.name);
});
}
export async function downloadToFile(
url: string,
destPath: string,
headers: Record<string, string>,
redirectCount = 0,
): Promise<JimakuDownloadResult> {
if (redirectCount > 3) {
return {
ok: false,
error: { error: 'Too many redirects while downloading subtitle.' },
};
}
return new Promise((resolve) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'https:' ? https : http;
const req = transport.get(parsedUrl, { headers }, (res) => {
const status = res.statusCode || 0;
if ([301, 302, 303, 307, 308].includes(status) && res.headers.location) {
const redirectUrl = new URL(res.headers.location, parsedUrl).toString();
res.resume();
downloadToFile(redirectUrl, destPath, headers, redirectCount + 1).then(resolve);
return;
}
if (status < 200 || status >= 300) {
res.resume();
resolve({
ok: false,
error: {
error: `Failed to download subtitle (HTTP ${status}).`,
code: status,
},
});
return;
}
const fileStream = fs.createWriteStream(destPath);
res.pipe(fileStream);
fileStream.on('finish', () => {
fileStream.close(() => {
resolve({ ok: true, path: destPath });
});
});
fileStream.on('error', (err: Error) => {
resolve({
ok: false,
error: { error: `Failed to save subtitle: ${err.message}` },
});
});
});
req.on('error', (err) => {
resolve({
ok: false,
error: {
error: `Download request failed: ${(err as Error).message}`,
},
});
});
});
}
export function isValidSubtitleCandidateFile(filename: string): boolean {
const ext = path.extname(filename).toLowerCase();
return ext === '.srt' || ext === '.vtt' || ext === '.ass' || ext === '.ssa' || ext === '.sub';
}
export function mapPreferenceToLanguages(preference: JimakuLanguagePreference): string[] {
if (preference === 'en') return ['en', 'eng'];
if (preference === 'none') return [];
return ['ja', 'jpn'];
}
export function normalizeJimakuSearchInput(mediaPath: string): string {
const trimmed = (mediaPath || '').trim();
if (!trimmed) return '';
if (!/^https?:\/\/.*/.test(trimmed)) return trimmed;
try {
const url = new URL(trimmed);
const titleParam =
url.searchParams.get('title') || url.searchParams.get('name') || url.searchParams.get('q');
if (titleParam && titleParam.trim()) return titleParam.trim();
const pathParts = url.pathname.split('/').filter(Boolean).reverse();
const candidate = pathParts.find((part) => {
const decoded = decodeURIComponent(part || '').replace(/\.[^/.]+$/, '');
const lowered = decoded.toLowerCase();
return lowered.length > 2 && !/^[0-9.]+$/.test(lowered) && !/^[a-f0-9]{16,}$/i.test(lowered);
});
const fallback = candidate || url.hostname.replace(/^www\./, '');
return sanitizeJimakuQueryInput(decodeURIComponent(fallback));
} catch {
return trimmed;
}
}
export function sanitizeJimakuQueryInput(value: string): string {
return value
.replace(/^\s*-\s*/, '')
.replace(/[^\w\s\-'".:(),]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export function buildJimakuConfig(args: Args): {
apiKey: string;
apiKeyCommand: string;
apiBaseUrl: string;
languagePreference: JimakuLanguagePreference;
maxEntryResults: number;
} {
return {
apiKey: args.jimakuApiKey,
apiKeyCommand: args.jimakuApiKeyCommand,
apiBaseUrl: args.jimakuApiBaseUrl || DEFAULT_JIMAKU_API_BASE_URL,
languagePreference: args.jimakuLanguagePreference,
maxEntryResults: args.jimakuMaxEntryResults || 10,
};
}

59
launcher/log.ts Normal file
View File

@@ -0,0 +1,59 @@
import fs from 'node:fs';
import path from 'node:path';
import type { LogLevel } from './types.js';
import { DEFAULT_MPV_LOG_FILE } from './types.js';
export const COLORS = {
red: '\x1b[0;31m',
green: '\x1b[0;32m',
yellow: '\x1b[0;33m',
cyan: '\x1b[0;36m',
reset: '\x1b[0m',
};
export const LOG_PRI: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
export function shouldLog(level: LogLevel, configured: LogLevel): boolean {
return LOG_PRI[level] >= LOG_PRI[configured];
}
export function getMpvLogPath(): string {
const envPath = process.env.SUBMINER_MPV_LOG?.trim();
if (envPath) return envPath;
return DEFAULT_MPV_LOG_FILE;
}
export function appendToMpvLog(message: string): void {
const logPath = getMpvLogPath();
try {
fs.mkdirSync(path.dirname(logPath), { recursive: true });
fs.appendFileSync(logPath, `[${new Date().toISOString()}] ${message}\n`, { encoding: 'utf8' });
} catch {
// ignore logging failures
}
}
export function log(level: LogLevel, configured: LogLevel, message: string): void {
if (!shouldLog(level, configured)) return;
const color =
level === 'info'
? COLORS.green
: level === 'warn'
? COLORS.yellow
: level === 'error'
? COLORS.red
: COLORS.cyan;
process.stdout.write(`${color}[${level.toUpperCase()}]${COLORS.reset} ${message}\n`);
appendToMpvLog(`[${level.toUpperCase()}] ${message}`);
}
export function fail(message: string): never {
process.stderr.write(`${COLORS.red}[ERROR]${COLORS.reset} ${message}\n`);
appendToMpvLog(`[ERROR] ${message}`);
process.exit(1);
}

209
launcher/main.test.ts Normal file
View File

@@ -0,0 +1,209 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawnSync } from 'node:child_process';
import { resolveConfigFilePath } from '../src/config/path-resolution.js';
type RunResult = {
status: number | null;
stdout: string;
stderr: string;
};
function withTempDir<T>(fn: (dir: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-test-'));
try {
return fn(dir);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
const result = spawnSync(process.execPath, ['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv], {
env,
encoding: 'utf8',
});
return {
status: result.status,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
function makeTestEnv(homeDir: string, xdgConfigHome: string): NodeJS.ProcessEnv {
return {
...process.env,
HOME: homeDir,
XDG_CONFIG_HOME: xdgConfigHome,
};
}
test('config path uses XDG_CONFIG_HOME override', () => {
withTempDir((root) => {
const xdgConfigHome = path.join(root, 'xdg');
const homeDir = path.join(root, 'home');
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.json'), '{"source":"xdg"}');
const result = runLauncher(['config', 'path'], makeTestEnv(homeDir, xdgConfigHome));
assert.equal(result.status, 0);
assert.equal(result.stdout.trim(), path.join(xdgConfigHome, 'SubMiner', 'config.json'));
});
});
test('config discovery ignores lowercase subminer candidate', () => {
const homeDir = '/home/tester';
const xdgConfigHome = '/tmp/xdg-config';
const expected = path.join(xdgConfigHome, 'SubMiner', 'config.jsonc');
const foundPaths = new Set([path.join(xdgConfigHome, 'subminer', 'config.json')]);
const resolved = resolveConfigFilePath({
xdgConfigHome,
homeDir,
existsSync: (candidate) => foundPaths.has(path.normalize(candidate)),
});
assert.equal(resolved, expected);
});
test('config path prefers jsonc over json for same directory', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.json'), '{"format":"json"}');
fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), '{"format":"jsonc"}');
const result = runLauncher(['config', 'path'], makeTestEnv(homeDir, xdgConfigHome));
assert.equal(result.status, 0);
assert.equal(result.stdout.trim(), path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'));
});
});
test('config show prints config body and appends trailing newline', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
fs.mkdirSync(path.join(xdgConfigHome, 'SubMiner'), { recursive: true });
fs.writeFileSync(path.join(xdgConfigHome, 'SubMiner', 'config.jsonc'), '{"logLevel":"debug"}');
const result = runLauncher(['config', 'show'], makeTestEnv(homeDir, xdgConfigHome));
assert.equal(result.status, 0);
assert.equal(result.stdout, '{"logLevel":"debug"}\n');
});
});
test('mpv socket command returns socket path from plugin runtime config', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const expectedSocket = path.join(root, 'custom', 'subminer.sock');
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${expectedSocket}\n`,
);
const result = runLauncher(['mpv', 'socket'], makeTestEnv(homeDir, xdgConfigHome));
assert.equal(result.status, 0);
assert.equal(result.stdout.trim(), expectedSocket);
});
});
test('mpv status exits non-zero when socket is not ready', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const result = runLauncher(['mpv', 'status'], makeTestEnv(homeDir, xdgConfigHome));
assert.equal(result.status, 1);
assert.match(result.stdout, /socket not ready/i);
});
});
test('doctor reports checks and exits non-zero without hard dependencies', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
PATH: '',
};
const result = runLauncher(['doctor'], env);
assert.equal(result.status, 1);
assert.match(result.stdout, /\[doctor\] app binary:/);
assert.match(result.stdout, /\[doctor\] mpv:/);
assert.match(result.stdout, /\[doctor\] config:/);
});
});
test('jellyfin discovery routes to app --start with log-level forwarding', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(['jellyfin', 'discovery', '--log-level', 'debug'], env);
assert.equal(result.status, 0);
assert.equal(fs.readFileSync(capturePath, 'utf8'), '--start\n--log-level\ndebug\n');
});
});
test('jellyfin login routes credentials to app command', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const appPath = path.join(root, 'fake-subminer.sh');
const capturePath = path.join(root, 'captured-args.txt');
fs.writeFileSync(
appPath,
'#!/bin/sh\nif [ -n "$SUBMINER_TEST_CAPTURE" ]; then printf "%s\\n" "$@" > "$SUBMINER_TEST_CAPTURE"; fi\nexit 0\n',
);
fs.chmodSync(appPath, 0o755);
const env = {
...makeTestEnv(homeDir, xdgConfigHome),
SUBMINER_APPIMAGE_PATH: appPath,
SUBMINER_TEST_CAPTURE: capturePath,
};
const result = runLauncher(
[
'jellyfin',
'login',
'--server',
'https://jf.example.test',
'--username',
'alice',
'--password',
'secret',
],
env,
);
assert.equal(result.status, 0);
assert.equal(
fs.readFileSync(capturePath, 'utf8'),
'--jellyfin-login\n--jellyfin-server\nhttps://jf.example.test\n--jellyfin-username\nalice\n--jellyfin-password\nsecret\n',
);
});
});

101
launcher/main.ts Normal file
View File

@@ -0,0 +1,101 @@
import path from 'node:path';
import {
loadLauncherJellyfinConfig,
loadLauncherYoutubeSubgenConfig,
parseArgs,
readPluginRuntimeConfig,
} from './config.js';
import { fail, log } from './log.js';
import { findAppBinary, state } from './mpv.js';
import { nodeProcessAdapter } from './process-adapter.js';
import type { LauncherCommandContext } from './commands/context.js';
import { runDoctorCommand } from './commands/doctor-command.js';
import { runConfigCommand } from './commands/config-command.js';
import { runMpvPostAppCommand, runMpvPreAppCommand } from './commands/mpv-command.js';
import { runAppPassthroughCommand, runTexthookerCommand } from './commands/app-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
function createCommandContext(
args: ReturnType<typeof parseArgs>,
scriptPath: string,
mpvSocketPath: string,
appPath: string | null,
): LauncherCommandContext {
return {
args,
scriptPath,
scriptName: path.basename(scriptPath),
mpvSocketPath,
appPath,
launcherJellyfinConfig: loadLauncherJellyfinConfig(),
processAdapter: nodeProcessAdapter,
};
}
function ensureAppPath(context: LauncherCommandContext): string {
if (context.appPath) {
return context.appPath;
}
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> {
const scriptPath = process.argv[1] || 'subminer';
const scriptName = path.basename(scriptPath);
const launcherConfig = loadLauncherYoutubeSubgenConfig();
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig);
const pluginRuntimeConfig = readPluginRuntimeConfig(args.logLevel);
const appPath = findAppBinary(scriptPath);
log('debug', args.logLevel, `Wrapper log level set to: ${args.logLevel}`);
const context = createCommandContext(args, scriptPath, pluginRuntimeConfig.socketPath, appPath);
if (runDoctorCommand(context)) {
return;
}
if (runConfigCommand(context)) {
return;
}
if (await runMpvPreAppCommand(context)) {
return;
}
const resolvedAppPath = ensureAppPath(context);
state.appPath = resolvedAppPath;
const appContext: LauncherCommandContext = {
...context,
appPath: resolvedAppPath,
};
if (runAppPassthroughCommand(appContext)) {
return;
}
if (await runMpvPostAppCommand(appContext)) {
return;
}
if (runTexthookerCommand(appContext)) {
return;
}
if (await runJellyfinCommand(appContext)) {
return;
}
await runPlaybackCommand(appContext);
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
fail(message);
});

61
launcher/mpv.test.ts Normal file
View File

@@ -0,0 +1,61 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { EventEmitter } from 'node:events';
import { waitForUnixSocketReady } from './mpv';
import * as mpvModule from './mpv';
function createTempSocketPath(): { dir: string; socketPath: string } {
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-mpv-tests');
fs.mkdirSync(baseDir, { recursive: true });
const dir = fs.mkdtempSync(path.join(baseDir, 'case-'));
return { dir, socketPath: path.join(dir, 'mpv.sock') };
}
test('mpv module exposes only canonical socket readiness helper', () => {
assert.equal('waitForSocket' in mpvModule, false);
});
test('waitForUnixSocketReady returns false when socket never appears', async () => {
const { dir, socketPath } = createTempSocketPath();
try {
const ready = await waitForUnixSocketReady(socketPath, 120);
assert.equal(ready, false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('waitForUnixSocketReady returns false when path exists but is not socket', async () => {
const { dir, socketPath } = createTempSocketPath();
try {
fs.writeFileSync(socketPath, 'not-a-socket');
const ready = await waitForUnixSocketReady(socketPath, 200);
assert.equal(ready, false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('waitForUnixSocketReady returns true when socket becomes connectable before timeout', async () => {
const { dir, socketPath } = createTempSocketPath();
fs.writeFileSync(socketPath, '');
const originalCreateConnection = net.createConnection;
try {
net.createConnection = (() => {
const socket = new EventEmitter() as net.Socket;
socket.destroy = (() => socket) as net.Socket['destroy'];
socket.setTimeout = (() => socket) as net.Socket['setTimeout'];
setTimeout(() => socket.emit('connect'), 25);
return socket;
}) as typeof net.createConnection;
const ready = await waitForUnixSocketReady(socketPath, 400);
assert.equal(ready, true);
} finally {
net.createConnection = originalCreateConnection;
fs.rmSync(dir, { recursive: true, force: true });
}
});

708
launcher/mpv.ts Normal file
View File

@@ -0,0 +1,708 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import { log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, inferAniSkipMetadataForFile } from './aniskip-metadata.js';
import {
commandExists,
isExecutable,
resolveBinaryPathCandidate,
realpathMaybe,
isYoutubeTarget,
uniqueNormalizedLangCodes,
sleep,
normalizeLangCode,
} from './util.js';
export const state = {
overlayProc: null as ReturnType<typeof spawn> | null,
mpvProc: null as ReturnType<typeof spawn> | null,
youtubeSubgenChildren: new Set<ReturnType<typeof spawn>>(),
appPath: '' as string,
overlayManagedByLauncher: false,
stopRequested: false,
};
const DETACHED_IDLE_MPV_PID_FILE = path.join(os.tmpdir(), 'subminer-idle-mpv.pid');
function readTrackedDetachedMpvPid(): number | null {
try {
const raw = fs.readFileSync(DETACHED_IDLE_MPV_PID_FILE, 'utf8').trim();
const pid = Number.parseInt(raw, 10);
return Number.isInteger(pid) && pid > 0 ? pid : null;
} catch {
return null;
}
}
function clearTrackedDetachedMpvPid(): void {
try {
fs.rmSync(DETACHED_IDLE_MPV_PID_FILE, { force: true });
} catch {
// ignore
}
}
function trackDetachedMpvPid(pid: number): void {
try {
fs.writeFileSync(DETACHED_IDLE_MPV_PID_FILE, String(pid), 'utf8');
} catch {
// ignore
}
}
function isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function processLooksLikeMpv(pid: number): boolean {
if (process.platform !== 'linux') return true;
try {
const cmdline = fs.readFileSync(`/proc/${pid}/cmdline`, 'utf8');
return cmdline.includes('mpv');
} catch {
return false;
}
}
async function terminateTrackedDetachedMpv(logLevel: LogLevel): Promise<void> {
const pid = readTrackedDetachedMpvPid();
if (!pid) return;
if (!isProcessAlive(pid)) {
clearTrackedDetachedMpvPid();
return;
}
if (!processLooksLikeMpv(pid)) {
clearTrackedDetachedMpvPid();
return;
}
try {
process.kill(pid, 'SIGTERM');
} catch {
clearTrackedDetachedMpvPid();
return;
}
const deadline = Date.now() + 1500;
while (Date.now() < deadline) {
if (!isProcessAlive(pid)) {
clearTrackedDetachedMpvPid();
return;
}
await sleep(100);
}
try {
process.kill(pid, 'SIGKILL');
} catch {
// ignore
}
clearTrackedDetachedMpvPid();
log('debug', logLevel, `Terminated stale detached mpv pid=${pid}`);
}
export function makeTempDir(prefix: string): string {
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
}
export function detectBackend(backend: Backend): Exclude<Backend, 'auto'> {
if (backend !== 'auto') return backend;
if (process.platform === 'darwin') return 'macos';
const xdgCurrentDesktop = (process.env.XDG_CURRENT_DESKTOP || '').toLowerCase();
const xdgSessionDesktop = (process.env.XDG_SESSION_DESKTOP || '').toLowerCase();
const xdgSessionType = (process.env.XDG_SESSION_TYPE || '').toLowerCase();
const hasWayland = Boolean(process.env.WAYLAND_DISPLAY) || xdgSessionType === 'wayland';
if (
process.env.HYPRLAND_INSTANCE_SIGNATURE ||
xdgCurrentDesktop.includes('hyprland') ||
xdgSessionDesktop.includes('hyprland')
) {
return 'hyprland';
}
if (hasWayland && commandExists('hyprctl')) return 'hyprland';
if (process.env.DISPLAY) return 'x11';
fail('Could not detect display backend');
}
function resolveMacAppBinaryCandidate(candidate: string): string {
const direct = resolveBinaryPathCandidate(candidate);
if (!direct) return '';
if (process.platform !== 'darwin') {
return isExecutable(direct) ? direct : '';
}
if (isExecutable(direct)) {
return direct;
}
const appIndex = direct.indexOf('.app/');
const appPath =
direct.endsWith('.app') && direct.includes('.app')
? direct
: appIndex >= 0
? direct.slice(0, appIndex + '.app'.length)
: '';
if (!appPath) return '';
const candidates = [
path.join(appPath, 'Contents', 'MacOS', 'SubMiner'),
path.join(appPath, 'Contents', 'MacOS', 'subminer'),
];
for (const candidateBinary of candidates) {
if (isExecutable(candidateBinary)) {
return candidateBinary;
}
}
return '';
}
export function findAppBinary(selfPath: string): string | null {
const envPaths = [process.env.SUBMINER_APPIMAGE_PATH, process.env.SUBMINER_BINARY_PATH].filter(
(candidate): candidate is string => Boolean(candidate),
);
for (const envPath of envPaths) {
const resolved = resolveMacAppBinaryCandidate(envPath);
if (resolved) {
return resolved;
}
}
const candidates: string[] = [];
if (process.platform === 'darwin') {
candidates.push('/Applications/SubMiner.app/Contents/MacOS/SubMiner');
candidates.push('/Applications/SubMiner.app/Contents/MacOS/subminer');
candidates.push(path.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/SubMiner'));
candidates.push(path.join(os.homedir(), 'Applications/SubMiner.app/Contents/MacOS/subminer'));
}
candidates.push(path.join(os.homedir(), '.local/bin/SubMiner.AppImage'));
candidates.push('/opt/SubMiner/SubMiner.AppImage');
for (const candidate of candidates) {
if (isExecutable(candidate)) return candidate;
}
const fromPath = process.env.PATH?.split(path.delimiter)
.map((dir) => path.join(dir, 'subminer'))
.find((candidate) => isExecutable(candidate));
if (fromPath) {
const resolvedSelf = realpathMaybe(selfPath);
const resolvedCandidate = realpathMaybe(fromPath);
if (resolvedSelf !== resolvedCandidate) return fromPath;
}
return null;
}
export function sendMpvCommand(socketPath: string, command: unknown[]): Promise<void> {
return new Promise((resolve, reject) => {
const socket = net.createConnection(socketPath);
socket.once('connect', () => {
socket.write(`${JSON.stringify({ command })}\n`);
socket.end();
resolve();
});
socket.once('error', (error) => {
reject(error);
});
});
}
interface MpvResponseEnvelope {
request_id?: number;
error?: string;
data?: unknown;
}
export function sendMpvCommandWithResponse(
socketPath: string,
command: unknown[],
timeoutMs = 5000,
): Promise<unknown> {
return new Promise((resolve, reject) => {
const requestId = Date.now() + Math.floor(Math.random() * 1000);
const socket = net.createConnection(socketPath);
let buffer = '';
const cleanup = (): void => {
try {
socket.destroy();
} catch {
// ignore
}
};
const timer = setTimeout(() => {
cleanup();
reject(new Error(`MPV command timed out after ${timeoutMs}ms`));
}, timeoutMs);
const finish = (value: unknown): void => {
clearTimeout(timer);
cleanup();
resolve(value);
};
socket.once('connect', () => {
const message = JSON.stringify({ command, request_id: requestId });
socket.write(`${message}\n`);
});
socket.on('data', (chunk: Buffer) => {
buffer += chunk.toString();
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() ?? '';
for (const line of lines) {
if (!line.trim()) continue;
let parsed: MpvResponseEnvelope;
try {
parsed = JSON.parse(line);
} catch {
continue;
}
if (parsed.request_id !== requestId) continue;
if (parsed.error && parsed.error !== 'success') {
reject(new Error(`MPV error: ${parsed.error}`));
cleanup();
clearTimeout(timer);
return;
}
finish(parsed.data);
return;
}
});
socket.once('error', (error) => {
clearTimeout(timer);
cleanup();
reject(error);
});
});
}
export async function getMpvTracks(socketPath: string): Promise<MpvTrack[]> {
const response = await sendMpvCommandWithResponse(
socketPath,
['get_property', 'track-list'],
8000,
);
if (!Array.isArray(response)) return [];
return response
.filter((track): track is MpvTrack => {
if (!track || typeof track !== 'object') return false;
const candidate = track as Record<string, unknown>;
return candidate.type === 'sub';
})
.map((track) => {
const candidate = track as Record<string, unknown>;
return {
type: typeof candidate.type === 'string' ? candidate.type : undefined,
id:
typeof candidate.id === 'number'
? candidate.id
: typeof candidate.id === 'string'
? Number.parseInt(candidate.id, 10)
: undefined,
lang: typeof candidate.lang === 'string' ? candidate.lang : undefined,
title: typeof candidate.title === 'string' ? candidate.title : undefined,
};
});
}
function isPreferredStreamLang(candidate: string, preferred: string[]): boolean {
const normalized = normalizeLangCode(candidate);
if (!normalized) return false;
if (preferred.includes(normalized)) return true;
if (normalized === 'ja' && preferred.includes('jpn')) return true;
if (normalized === 'jpn' && preferred.includes('ja')) return true;
if (normalized === 'en' && preferred.includes('eng')) return true;
if (normalized === 'eng' && preferred.includes('en')) return true;
return false;
}
export function findPreferredSubtitleTrack(
tracks: MpvTrack[],
preferredLanguages: string[],
): MpvTrack | null {
const normalizedPreferred = uniqueNormalizedLangCodes(preferredLanguages);
const subtitleTracks = tracks.filter((track) => track.type === 'sub');
if (normalizedPreferred.length === 0) return subtitleTracks[0] ?? null;
for (const lang of normalizedPreferred) {
const matched = subtitleTracks.find(
(track) => track.lang && isPreferredStreamLang(track.lang, [lang]),
);
if (matched) return matched;
}
return null;
}
export async function waitForSubtitleTrackList(
socketPath: string,
logLevel: LogLevel,
): Promise<MpvTrack[]> {
const maxAttempts = 40;
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
const tracks = await getMpvTracks(socketPath).catch(() => [] as MpvTrack[]);
if (tracks.length > 0) return tracks;
if (attempt % 10 === 0) {
log('debug', logLevel, `Waiting for mpv tracks (${attempt}/${maxAttempts})`);
}
await sleep(250);
}
return [];
}
export async function loadSubtitleIntoMpv(
socketPath: string,
subtitlePath: string,
select: boolean,
logLevel: LogLevel,
): Promise<void> {
for (let attempt = 1; ; attempt += 1) {
const mpvExited =
state.mpvProc !== null &&
state.mpvProc.exitCode !== null &&
state.mpvProc.exitCode !== undefined;
if (mpvExited) {
throw new Error(`mpv exited before subtitle could be loaded: ${subtitlePath}`);
}
if (!fs.existsSync(socketPath)) {
if (attempt % 20 === 0) {
log(
'debug',
logLevel,
`Waiting for mpv socket before loading subtitle (${attempt} attempts): ${path.basename(subtitlePath)}`,
);
}
await sleep(250);
continue;
}
try {
await sendMpvCommand(
socketPath,
select ? ['sub-add', subtitlePath, 'select'] : ['sub-add', subtitlePath],
);
log('info', logLevel, `Loaded generated subtitle into mpv: ${path.basename(subtitlePath)}`);
return;
} catch {
if (attempt % 20 === 0) {
log(
'debug',
logLevel,
`Retrying subtitle load into mpv (${attempt} attempts): ${path.basename(subtitlePath)}`,
);
}
await sleep(250);
}
}
}
export function startMpv(
target: string,
targetKind: 'file' | 'url',
args: Args,
socketPath: string,
appPath: string,
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
): void {
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
fail(`Video file not found: ${target}`);
}
if (targetKind === 'url') {
log('info', args.logLevel, `Playing URL: ${target}`);
} else {
log('info', args.logLevel, `Playing: ${path.basename(target)}`);
}
const mpvArgs: string[] = [];
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
if (targetKind === 'url' && isYoutubeTarget(target)) {
log('info', args.logLevel, 'Applying URL playback options');
mpvArgs.push('--ytdl=yes', '--ytdl-raw-options=');
if (isYoutubeTarget(target)) {
const subtitleLangs = uniqueNormalizedLangCodes([
...args.youtubePrimarySubLangs,
...args.youtubeSecondarySubLangs,
]).join(',');
const audioLangs = uniqueNormalizedLangCodes(args.youtubeAudioLangs).join(',');
log('info', args.logLevel, 'Applying YouTube playback options');
log('debug', args.logLevel, `YouTube subtitle langs: ${subtitleLangs}`);
log('debug', args.logLevel, `YouTube audio langs: ${audioLangs}`);
mpvArgs.push(`--ytdl-format=${DEFAULT_YOUTUBE_YTDL_FORMAT}`, `--alang=${audioLangs}`);
if (args.youtubeSubgenMode === 'off') {
mpvArgs.push(
'--sub-auto=fuzzy',
`--slang=${subtitleLangs}`,
'--ytdl-raw-options-append=write-auto-subs=',
'--ytdl-raw-options-append=write-subs=',
'--ytdl-raw-options-append=sub-format=vtt/best',
`--ytdl-raw-options-append=sub-langs=${subtitleLangs}`,
);
}
}
}
if (preloadedSubtitles?.primaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.primaryPath}`);
}
if (preloadedSubtitles?.secondaryPath) {
mpvArgs.push(`--sub-file=${preloadedSubtitles.secondaryPath}`);
}
const aniSkipMetadata =
targetKind === 'file' ? inferAniSkipMetadataForFile(target) : null;
const scriptOpts = buildSubminerScriptOpts(appPath, socketPath, aniSkipMetadata);
if (aniSkipMetadata) {
log(
'debug',
args.logLevel,
`AniSkip metadata (${aniSkipMetadata.source}): title="${aniSkipMetadata.title}" season=${aniSkipMetadata.season ?? '-'} episode=${aniSkipMetadata.episode ?? '-'}`,
);
}
mpvArgs.push(`--script-opts=${scriptOpts}`);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
try {
fs.rmSync(socketPath, { force: true });
} catch {
// ignore
}
mpvArgs.push(`--input-ipc-server=${socketPath}`);
mpvArgs.push(target);
state.mpvProc = spawn('mpv', mpvArgs, { stdio: 'inherit' });
}
export function startOverlay(appPath: string, args: Args, socketPath: string): Promise<void> {
const backend = detectBackend(args.backend);
log('info', args.logLevel, `Starting SubMiner overlay (backend: ${backend})...`);
const overlayArgs = ['--start', '--backend', backend, '--socket', socketPath];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker');
state.overlayProc = spawn(appPath, overlayArgs, {
stdio: 'inherit',
env: { ...process.env, SUBMINER_MPV_LOG: getMpvLogPath() },
});
state.overlayManagedByLauncher = true;
return new Promise((resolve) => {
setTimeout(resolve, 2000);
});
}
export function launchTexthookerOnly(appPath: string, args: Args): never {
const overlayArgs = ['--texthooker'];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
log('info', args.logLevel, 'Launching texthooker mode...');
const result = spawnSync(appPath, overlayArgs, { stdio: 'inherit' });
process.exit(result.status ?? 0);
}
export function stopOverlay(args: Args): void {
if (state.stopRequested) return;
state.stopRequested = true;
if (state.overlayManagedByLauncher && state.appPath) {
log('info', args.logLevel, 'Stopping SubMiner overlay...');
const stopArgs = ['--stop'];
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
spawnSync(state.appPath, stopArgs, { stdio: 'ignore' });
if (state.overlayProc && !state.overlayProc.killed) {
try {
state.overlayProc.kill('SIGTERM');
} catch {
// ignore
}
}
}
if (state.mpvProc && !state.mpvProc.killed) {
try {
state.mpvProc.kill('SIGTERM');
} catch {
// ignore
}
}
for (const child of state.youtubeSubgenChildren) {
if (!child.killed) {
try {
child.kill('SIGTERM');
} catch {
// ignore
}
}
}
state.youtubeSubgenChildren.clear();
void terminateTrackedDetachedMpv(args.logLevel);
}
function buildAppEnv(): NodeJS.ProcessEnv {
const env: Record<string, string | undefined> = {
...process.env,
SUBMINER_MPV_LOG: getMpvLogPath(),
};
const layers = env.VK_INSTANCE_LAYERS;
if (typeof layers === 'string' && layers.trim().length > 0) {
const filtered = layers
.split(':')
.map((part) => part.trim())
.filter((part) => part.length > 0 && !/lsfg/i.test(part));
if (filtered.length > 0) {
env.VK_INSTANCE_LAYERS = filtered.join(':');
} else {
delete env.VK_INSTANCE_LAYERS;
}
}
return env;
}
export function runAppCommandWithInherit(appPath: string, appArgs: string[]): never {
const result = spawnSync(appPath, appArgs, {
stdio: 'inherit',
env: buildAppEnv(),
});
if (result.error) {
fail(`Failed to run app command: ${result.error.message}`);
}
process.exit(result.status ?? 0);
}
export function runAppCommandWithInheritLogged(
appPath: string,
appArgs: string[],
logLevel: LogLevel,
label: string,
): never {
log('debug', logLevel, `${label}: launching app with args: ${appArgs.join(' ')}`);
const result = spawnSync(appPath, appArgs, {
stdio: 'inherit',
env: buildAppEnv(),
});
if (result.error) {
fail(`Failed to run app command: ${result.error.message}`);
}
log('debug', logLevel, `${label}: app command exited with status ${result.status ?? 0}`);
process.exit(result.status ?? 0);
}
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
const startArgs = ['--start'];
if (logLevel !== 'info') startArgs.push('--log-level', logLevel);
const proc = spawn(appPath, startArgs, {
stdio: 'ignore',
detached: true,
env: buildAppEnv(),
});
proc.unref();
}
export function launchMpvIdleDetached(
socketPath: string,
appPath: string,
args: Args,
): Promise<void> {
return (async () => {
await terminateTrackedDetachedMpv(args.logLevel);
try {
fs.rmSync(socketPath, { force: true });
} catch {
// ignore
}
const mpvArgs: string[] = [];
if (args.profile) mpvArgs.push(`--profile=${args.profile}`);
mpvArgs.push(...DEFAULT_MPV_SUBMINER_ARGS);
mpvArgs.push('--idle=yes');
mpvArgs.push(
`--script-opts=subminer-binary_path=${appPath},subminer-socket_path=${socketPath}`,
);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(`--input-ipc-server=${socketPath}`);
const proc = spawn('mpv', mpvArgs, {
stdio: 'ignore',
detached: true,
});
if (typeof proc.pid === 'number' && proc.pid > 0) {
trackDetachedMpvPid(proc.pid);
}
proc.unref();
})();
}
async function sleepMs(ms: number): Promise<void> {
await new Promise<void>((resolve) => setTimeout(resolve, ms));
}
async function canConnectUnixSocket(socketPath: string): Promise<boolean> {
return await new Promise<boolean>((resolve) => {
const socket = net.createConnection(socketPath);
let settled = false;
const finish = (value: boolean) => {
if (settled) return;
settled = true;
try {
socket.destroy();
} catch {
// ignore
}
resolve(value);
};
socket.once('connect', () => finish(true));
socket.once('error', () => finish(false));
socket.setTimeout(400, () => finish(false));
});
}
export async function waitForUnixSocketReady(
socketPath: string,
timeoutMs: number,
): Promise<boolean> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
if (fs.existsSync(socketPath)) {
const ready = await canConnectUnixSocket(socketPath);
if (ready) return true;
}
} catch {
// ignore transient fs errors
}
await sleepMs(150);
}
return false;
}

View File

@@ -0,0 +1,45 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { parseArgs } from './config';
test('parseArgs captures passthrough args for app subcommand', () => {
const parsed = parseArgs(['app', '--anilist', '--log-level', 'debug'], 'subminer', {});
assert.equal(parsed.appPassthrough, true);
assert.deepEqual(parsed.appArgs, ['--anilist', '--log-level', 'debug']);
});
test('parseArgs supports bin alias for app subcommand', () => {
const parsed = parseArgs(['bin', '--anilist-status'], 'subminer', {});
assert.equal(parsed.appPassthrough, true);
assert.deepEqual(parsed.appArgs, ['--anilist-status']);
});
test('parseArgs keeps all args after app verbatim', () => {
const parsed = parseArgs(['app', '--start', '--anilist-setup', '-h'], 'subminer', {});
assert.equal(parsed.appPassthrough, true);
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);
});

487
launcher/picker.ts Normal file
View File

@@ -0,0 +1,487 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawnSync } from 'node:child_process';
import type {
LogLevel,
JellyfinSessionConfig,
JellyfinLibraryEntry,
JellyfinItemEntry,
JellyfinGroupEntry,
} from './types.js';
import { VIDEO_EXTENSIONS, ROFI_THEME_FILE } from './types.js';
import { log, fail } from './log.js';
import { commandExists, realpathMaybe } from './util.js';
export function escapeShellSingle(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
export function showRofiFlatMenu(
items: string[],
prompt: string,
initialQuery = '',
themePath: string | null = null,
): string {
const args = ['-dmenu', '-i', '-matching', 'fuzzy', '-p', prompt];
if (themePath) {
args.push('-theme', themePath);
} else {
args.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
}
if (initialQuery.trim().length > 0) {
args.push('-filter', initialQuery.trim());
}
const result = spawnSync('rofi', args, {
input: `${items.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) {
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
}
return (result.stdout || '').trim();
}
export function showFzfFlatMenu(
lines: string[],
prompt: string,
previewCommand: string,
initialQuery = '',
): string {
const args = [
'--ansi',
'--reverse',
'--ignore-case',
`--prompt=${prompt}`,
'--delimiter=\t',
'--with-nth=2',
'--preview-window=right:50%:wrap',
'--preview',
previewCommand,
];
if (initialQuery.trim().length > 0) {
args.push('--query', initialQuery.trim());
}
const result = spawnSync('fzf', args, {
input: `${lines.join('\n')}\n`,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
});
if (result.error) {
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
}
return (result.stdout || '').trim();
}
export function parseSelectionId(selection: string): string {
if (!selection) return '';
const tab = selection.indexOf('\t');
if (tab === -1) return '';
return selection.slice(0, tab);
}
export function parseSelectionLabel(selection: string): string {
const tab = selection.indexOf('\t');
if (tab === -1) return selection;
return selection.slice(tab + 1);
}
function fuzzySubsequenceMatch(haystack: string, needle: string): boolean {
if (!needle) return true;
let j = 0;
for (let i = 0; i < haystack.length && j < needle.length; i += 1) {
if (haystack[i] === needle[j]) j += 1;
}
return j === needle.length;
}
function matchesMenuQuery(label: string, query: string): boolean {
const normalizedQuery = query.trim().toLowerCase();
if (!normalizedQuery) return true;
const target = label.toLowerCase();
const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
if (tokens.length === 0) return true;
return tokens.every((token) => fuzzySubsequenceMatch(target, token));
}
export async function promptOptionalJellyfinSearch(
useRofi: boolean,
themePath: string | null = null,
): Promise<string> {
if (useRofi && commandExists('rofi')) {
const rofiArgs = ['-dmenu', '-i', '-p', 'Jellyfin Search (optional)'];
if (themePath) {
rofiArgs.push('-theme', themePath);
} else {
rofiArgs.push('-theme-str', 'configuration { font: "Noto Sans CJK JP Regular 8";}');
}
const result = spawnSync('rofi', rofiArgs, {
input: '\n',
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) return '';
return (result.stdout || '').trim();
}
if (!process.stdin.isTTY || !process.stdout.isTTY) return '';
process.stdout.write('Jellyfin search term (optional, press Enter to skip): ');
const chunks: Buffer[] = [];
return await new Promise<string>((resolve) => {
const onData = (data: Buffer) => {
const line = data.toString('utf8');
if (line.includes('\n') || line.includes('\r')) {
chunks.push(Buffer.from(line, 'utf8'));
process.stdin.off('data', onData);
const text = Buffer.concat(chunks).toString('utf8').trim();
resolve(text);
return;
}
chunks.push(data);
};
process.stdin.on('data', onData);
});
}
interface RofiIconEntry {
label: string;
iconPath?: string;
}
function showRofiIconMenu(
entries: RofiIconEntry[],
prompt: string,
initialQuery = '',
themePath: string | null = null,
): number {
if (entries.length === 0) return -1;
const rofiArgs = ['-dmenu', '-i', '-show-icons', '-format', 'i', '-p', prompt];
if (initialQuery) rofiArgs.push('-filter', initialQuery);
if (themePath) {
rofiArgs.push('-theme', themePath);
rofiArgs.push('-theme-str', 'configuration { show-icons: true; }');
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
} else {
rofiArgs.push(
'-theme-str',
'configuration { font: "Noto Sans CJK JP Regular 8"; show-icons: true; }',
);
rofiArgs.push('-theme-str', 'element-icon { enabled: true; size: 3em; }');
}
const lines = entries.map((entry) =>
entry.iconPath ? `${entry.label}\u0000icon\u001f${entry.iconPath}` : entry.label,
);
const input = Buffer.from(`${lines.join('\n')}\n`, 'utf8');
const result = spawnSync('rofi', rofiArgs, {
input,
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) return -1;
const out = (result.stdout || '').trim();
if (!out) return -1;
const idx = Number.parseInt(out, 10);
return Number.isFinite(idx) ? idx : -1;
}
export function pickLibrary(
session: JellyfinSessionConfig,
libraries: JellyfinLibraryEntry[],
useRofi: boolean,
ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null,
initialQuery = '',
themePath: string | null = null,
): string {
const visibleLibraries =
initialQuery.trim().length > 0
? libraries.filter((lib) => matchesMenuQuery(`${lib.name} ${lib.kind}`, initialQuery))
: libraries;
if (visibleLibraries.length === 0) fail('No Jellyfin libraries found.');
if (useRofi) {
const entries = visibleLibraries.map((lib) => ({
label: `${lib.name} [${lib.kind}]`,
iconPath: ensureIcon(session, lib.id) || undefined,
}));
const idx = showRofiIconMenu(entries, 'Jellyfin Library', initialQuery, themePath);
return idx >= 0 ? visibleLibraries[idx].id : '';
}
const lines = visibleLibraries.map((lib) => `${lib.id}\t${lib.name} [${lib.kind}]`);
const preview =
commandExists('chafa') && commandExists('curl')
? `
id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim()
: 'echo "Install curl + chafa for image preview"';
const picked = showFzfFlatMenu(lines, 'Jellyfin Library: ', preview, initialQuery);
return parseSelectionId(picked);
}
export function pickItem(
session: JellyfinSessionConfig,
items: JellyfinItemEntry[],
useRofi: boolean,
ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null,
initialQuery = '',
themePath: string | null = null,
): string {
const visibleItems =
initialQuery.trim().length > 0
? items.filter((item) => matchesMenuQuery(item.display, initialQuery))
: items;
if (visibleItems.length === 0) fail('No playable Jellyfin items found.');
if (useRofi) {
const entries = visibleItems.map((item) => ({
label: item.display,
iconPath: ensureIcon(session, item.id) || undefined,
}));
const idx = showRofiIconMenu(entries, 'Jellyfin Item', initialQuery, themePath);
return idx >= 0 ? visibleItems[idx].id : '';
}
const lines = visibleItems.map((item) => `${item.id}\t${item.display}`);
const preview =
commandExists('chafa') && commandExists('curl')
? `
id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim()
: 'echo "Install curl + chafa for image preview"';
const picked = showFzfFlatMenu(lines, 'Jellyfin Item: ', preview, initialQuery);
return parseSelectionId(picked);
}
export function pickGroup(
session: JellyfinSessionConfig,
groups: JellyfinGroupEntry[],
useRofi: boolean,
ensureIcon: (session: JellyfinSessionConfig, id: string) => string | null,
initialQuery = '',
themePath: string | null = null,
): string {
const visibleGroups =
initialQuery.trim().length > 0
? groups.filter((group) => matchesMenuQuery(group.display, initialQuery))
: groups;
if (visibleGroups.length === 0) return '';
if (useRofi) {
const entries = visibleGroups.map((group) => ({
label: group.display,
iconPath: ensureIcon(session, group.id) || undefined,
}));
const idx = showRofiIconMenu(entries, 'Jellyfin Anime/Folder', initialQuery, themePath);
return idx >= 0 ? visibleGroups[idx].id : '';
}
const lines = visibleGroups.map((group) => `${group.id}\t${group.display}`);
const preview =
commandExists('chafa') && commandExists('curl')
? `
id={1}
url=${escapeShellSingle(session.serverUrl)}/Items/$id/Images/Primary?maxHeight=720\\&quality=85\\&api_key=${escapeShellSingle(session.accessToken)}
curl -fsSL "$url" 2>/dev/null | chafa --format=symbols --symbols=vhalf+wide --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} - 2>/dev/null
`.trim()
: 'echo "Install curl + chafa for image preview"';
const picked = showFzfFlatMenu(lines, 'Jellyfin Anime/Folder: ', preview, initialQuery);
return parseSelectionId(picked);
}
export function formatPickerLaunchError(
picker: 'rofi' | 'fzf',
error: NodeJS.ErrnoException,
): string {
if (error.code === 'ENOENT') {
return picker === 'rofi'
? 'rofi not found. Install rofi or use --no-rofi to use fzf.'
: 'fzf not found. Install fzf or use --rofi to use rofi.';
}
return `Failed to launch ${picker}: ${error.message}`;
}
export function collectVideos(dir: string, recursive: boolean): string[] {
const root = path.resolve(dir);
const out: string[] = [];
const walk = (current: string): void => {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
if (recursive) walk(full);
continue;
}
if (!entry.isFile()) continue;
const ext = path.extname(entry.name).slice(1).toLowerCase();
if (VIDEO_EXTENSIONS.has(ext)) out.push(full);
}
};
walk(root);
return out.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }));
}
export function buildRofiMenu(videos: string[], dir: string, recursive: boolean): Buffer {
const chunks: Buffer[] = [];
for (const video of videos) {
const display = recursive ? path.relative(dir, video) : path.basename(video);
const line = `${display}\0icon\x1fthumbnail://${video}\n`;
chunks.push(Buffer.from(line, 'utf8'));
}
return Buffer.concat(chunks);
}
export function findRofiTheme(scriptPath: string): string | null {
const envTheme = process.env.SUBMINER_ROFI_THEME;
if (envTheme && fs.existsSync(envTheme)) return envTheme;
const scriptDir = path.dirname(realpathMaybe(scriptPath));
const candidates: string[] = [];
if (process.platform === 'darwin') {
candidates.push(
path.join(os.homedir(), 'Library/Application Support/SubMiner/themes', ROFI_THEME_FILE),
);
} else {
const xdgDataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share');
candidates.push(path.join(xdgDataHome, 'SubMiner/themes', ROFI_THEME_FILE));
candidates.push(path.join('/usr/local/share/SubMiner/themes', ROFI_THEME_FILE));
candidates.push(path.join('/usr/share/SubMiner/themes', ROFI_THEME_FILE));
}
candidates.push(path.join(scriptDir, 'assets', 'themes', ROFI_THEME_FILE));
candidates.push(path.join(scriptDir, 'themes', ROFI_THEME_FILE));
candidates.push(path.join(scriptDir, ROFI_THEME_FILE));
for (const candidate of candidates) {
if (fs.existsSync(candidate)) return candidate;
}
return null;
}
export function showRofiMenu(
videos: string[],
dir: string,
recursive: boolean,
scriptPath: string,
logLevel: LogLevel,
): string {
const args = [
'-dmenu',
'-i',
'-p',
'Select Video ',
'-show-icons',
'-theme-str',
'configuration { font: "Noto Sans CJK JP Regular 8";}',
];
const theme = findRofiTheme(scriptPath);
if (theme) {
args.push('-theme', theme);
} else {
log(
'warn',
logLevel,
'Rofi theme not found; using rofi defaults (set SUBMINER_ROFI_THEME to override)',
);
}
const result = spawnSync('rofi', args, {
input: buildRofiMenu(videos, dir, recursive),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'ignore'],
});
if (result.error) {
fail(formatPickerLaunchError('rofi', result.error as NodeJS.ErrnoException));
}
const selection = (result.stdout || '').trim();
if (!selection) return '';
return path.join(dir, selection);
}
export function buildFzfMenu(videos: string[]): string {
return videos.map((video) => `${path.basename(video)}\t${video}`).join('\n');
}
export function showFzfMenu(videos: string[]): string {
const chafaFormat = process.env.TMUX
? '--format=symbols --symbols=vhalf+wide --color-space=din99d'
: '--format=kitty';
const previewCmd = commandExists('chafa')
? `
video={2}
thumb_dir="$HOME/.cache/thumbnails/large"
video_uri="file://$(realpath "$video")"
if command -v md5sum >/dev/null 2>&1; then
thumb_hash=$(echo -n "$video_uri" | md5sum | cut -d' ' -f1)
else
thumb_hash=$(echo -n "$video_uri" | md5 -q)
fi
thumb_path="$thumb_dir/$thumb_hash.png"
get_thumb() {
if [[ -f "$thumb_path" ]]; then
echo "$thumb_path"
elif command -v ffmpegthumbnailer >/dev/null 2>&1; then
tmp="/tmp/subminer-preview.jpg"
ffmpegthumbnailer -i "$video" -o "$tmp" -s 512 -q 5 2>/dev/null && echo "$tmp"
elif command -v ffmpeg >/dev/null 2>&1; then
tmp="/tmp/subminer-preview.jpg"
ffmpeg -y -i "$video" -ss 00:00:05 -vframes 1 -vf "scale=512:-1" "$tmp" 2>/dev/null && echo "$tmp"
fi
}
thumb=$(get_thumb)
[[ -n "$thumb" ]] && chafa ${chafaFormat} --size=${'${FZF_PREVIEW_COLUMNS}'}x${'${FZF_PREVIEW_LINES}'} "$thumb" 2>/dev/null
`.trim()
: 'echo "Install chafa for thumbnail preview"';
const result = spawnSync(
'fzf',
[
'--ansi',
'--reverse',
'--prompt=Select Video: ',
'--delimiter=\t',
'--with-nth=1',
'--preview-window=right:50%:wrap',
'--preview',
previewCmd,
],
{
input: buildFzfMenu(videos),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'inherit'],
},
);
if (result.error) {
fail(formatPickerLaunchError('fzf', result.error as NodeJS.ErrnoException));
}
const selection = (result.stdout || '').trim();
if (!selection) return '';
const tabIndex = selection.indexOf('\t');
if (tabIndex === -1) return '';
return selection.slice(tabIndex + 1);
}

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

304
launcher/smoke.e2e.test.ts Normal file
View File

@@ -0,0 +1,304 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn, spawnSync } from 'node:child_process';
type RunResult = {
status: number | null;
stdout: string;
stderr: string;
};
type SmokeCase = {
root: string;
artifactsDir: string;
binDir: string;
xdgConfigHome: string;
homeDir: string;
socketDir: string;
socketPath: string;
videoPath: string;
fakeAppPath: string;
fakeMpvPath: string;
mpvOverlayLogPath: string;
};
function writeExecutable(filePath: string, body: string): void {
fs.writeFileSync(filePath, body);
fs.chmodSync(filePath, 0o755);
}
function createSmokeCase(name: string): SmokeCase {
const baseDir = path.join(process.cwd(), '.tmp', 'launcher-smoke');
fs.mkdirSync(baseDir, { recursive: true });
const root = fs.mkdtempSync(path.join(baseDir, `${name}-`));
const artifactsDir = path.join(root, 'artifacts');
const binDir = path.join(root, 'bin');
const xdgConfigHome = path.join(root, 'xdg');
const homeDir = path.join(root, 'home');
const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-smoke-sock-'));
const socketPath = path.join(socketDir, 'subminer.sock');
const videoPath = path.join(root, 'video.mkv');
const fakeAppPath = path.join(binDir, 'fake-subminer');
const fakeMpvPath = path.join(binDir, 'mpv');
const mpvOverlayLogPath = path.join(artifactsDir, 'mpv-overlay.log');
fs.mkdirSync(artifactsDir, { recursive: true });
fs.mkdirSync(binDir, { recursive: true });
fs.mkdirSync(path.join(xdgConfigHome, 'mpv', 'script-opts'), { recursive: true });
fs.writeFileSync(videoPath, 'fake video fixture');
fs.writeFileSync(
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
`socket_path=${socketPath}\n`,
);
const fakeMpvLogPath = path.join(artifactsDir, 'fake-mpv.log');
const fakeAppLogPath = path.join(artifactsDir, 'fake-app.log');
const fakeAppStartLogPath = path.join(artifactsDir, 'fake-app-start.log');
const fakeAppStopLogPath = path.join(artifactsDir, 'fake-app-stop.log');
writeExecutable(
fakeMpvPath,
`#!/usr/bin/env bun
const fs = require('node:fs');
const net = require('node:net');
const path = require('node:path');
const logPath = ${JSON.stringify(fakeMpvLogPath)};
const args = process.argv.slice(2);
const socketArg = args.find((arg) => arg.startsWith('--input-ipc-server='));
const socketPath = socketArg ? socketArg.slice('--input-ipc-server='.length) : '';
fs.appendFileSync(logPath, JSON.stringify({ argv: args, socketPath }) + '\\n');
if (!socketPath) {
process.exit(2);
}
try {
fs.rmSync(socketPath, { force: true });
} catch {}
fs.mkdirSync(path.dirname(socketPath), { recursive: true });
const server = net.createServer((socket) => socket.end());
server.on('error', (error) => {
fs.appendFileSync(logPath, JSON.stringify({ error: String(error) }) + '\\n');
process.exit(3);
});
server.listen(socketPath);
const closeAndExit = () => {
server.close(() => process.exit(0));
};
setTimeout(closeAndExit, 3000);
process.on('SIGTERM', closeAndExit);
`,
);
writeExecutable(
fakeAppPath,
`#!/usr/bin/env bun
const fs = require('node:fs');
const logPath = ${JSON.stringify(fakeAppLogPath)};
const startPath = ${JSON.stringify(fakeAppStartLogPath)};
const stopPath = ${JSON.stringify(fakeAppStopLogPath)};
const entry = {
argv: process.argv.slice(2),
subminerMpvLog: process.env.SUBMINER_MPV_LOG || '',
};
fs.appendFileSync(logPath, JSON.stringify(entry) + '\\n');
if (entry.argv.includes('--start')) {
fs.appendFileSync(startPath, JSON.stringify(entry) + '\\n');
}
if (entry.argv.includes('--stop')) {
fs.appendFileSync(stopPath, JSON.stringify(entry) + '\\n');
}
process.exit(0);
`,
);
return {
root,
artifactsDir,
binDir,
xdgConfigHome,
homeDir,
socketDir,
socketPath,
videoPath,
fakeAppPath,
fakeMpvPath,
mpvOverlayLogPath,
};
}
function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
return {
...process.env,
HOME: smokeCase.homeDir,
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
SUBMINER_MPV_LOG: smokeCase.mpvOverlayLogPath,
PATH: `${smokeCase.binDir}${path.delimiter}${process.env.PATH || ''}`,
};
}
function runLauncher(
smokeCase: SmokeCase,
argv: string[],
env: NodeJS.ProcessEnv,
label: string,
): RunResult {
const result = spawnSync(
process.execPath,
['run', path.join(process.cwd(), 'launcher/main.ts'), ...argv],
{
env,
encoding: 'utf8',
timeout: 15000,
},
);
const stdout = result.stdout || '';
const stderr = result.stderr || '';
fs.writeFileSync(path.join(smokeCase.artifactsDir, `${label}.stdout.log`), stdout);
fs.writeFileSync(path.join(smokeCase.artifactsDir, `${label}.stderr.log`), stderr);
return {
status: result.status,
stdout,
stderr,
};
}
async function withSmokeCase(
name: string,
fn: (smokeCase: SmokeCase) => Promise<void>,
): Promise<void> {
const smokeCase = createSmokeCase(name);
let completed = false;
try {
await fn(smokeCase);
completed = true;
} catch (error) {
process.stderr.write(`[launcher-smoke] preserved artifacts: ${smokeCase.root}\n`);
throw error;
} finally {
if (completed) {
fs.rmSync(smokeCase.root, { recursive: true, force: true });
}
fs.rmSync(smokeCase.socketDir, { recursive: true, force: true });
}
}
function readJsonLines(filePath: string): Array<Record<string, unknown>> {
if (!fs.existsSync(filePath)) return [];
return fs
.readFileSync(filePath, 'utf8')
.split(/\r?\n/)
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line) as Record<string, unknown>);
}
async function waitForJsonLines(
filePath: string,
minCount: number,
timeoutMs = 1500,
): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (readJsonLines(filePath).length >= minCount) {
return;
}
await new Promise<void>((resolve) => setTimeout(resolve, 50));
}
}
test('launcher mpv status returns ready when socket is connectable', async () => {
await withSmokeCase('mpv-status', async (smokeCase) => {
const env = makeTestEnv(smokeCase);
const fakeMpv = spawn(smokeCase.fakeMpvPath, [`--input-ipc-server=${smokeCase.socketPath}`], {
env,
stdio: 'ignore',
});
try {
await new Promise<void>((resolve) => setTimeout(resolve, 120));
const result = runLauncher(
smokeCase,
['mpv', 'status', '--log-level', 'debug'],
env,
'mpv-status',
);
assert.equal(result.status, 0);
assert.match(result.stdout, /socket ready/i);
} finally {
if (fakeMpv.exitCode === null) {
await new Promise<void>((resolve) => {
fakeMpv.once('close', () => resolve());
});
}
}
});
});
test(
'launcher start-overlay run forwards socket/backend and stops overlay after mpv exits',
{ timeout: 20000 },
async () => {
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
const env = makeTestEnv(smokeCase);
const result = runLauncher(
smokeCase,
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
env,
'overlay-start-stop',
);
assert.equal(result.status, 0);
assert.match(result.stdout, /Starting SubMiner overlay/i);
const appStartPath = path.join(smokeCase.artifactsDir, 'fake-app-start.log');
const appStopPath = path.join(smokeCase.artifactsDir, 'fake-app-stop.log');
await waitForJsonLines(appStartPath, 1);
await waitForJsonLines(appStopPath, 1);
const appStartEntries = readJsonLines(appStartPath);
const appStopEntries = readJsonLines(appStopPath);
const mpvEntries = readJsonLines(path.join(smokeCase.artifactsDir, 'fake-mpv.log'));
assert.equal(appStartEntries.length, 1);
assert.equal(appStopEntries.length, 1);
assert.equal(mpvEntries.length >= 1, true);
const appStartArgs = appStartEntries[0]?.argv;
assert.equal(Array.isArray(appStartArgs), true);
assert.equal((appStartArgs as string[]).includes('--start'), true);
assert.equal((appStartArgs as string[]).includes('--backend'), true);
assert.equal((appStartArgs as string[]).includes('x11'), true);
assert.equal((appStartArgs as string[]).includes('--socket'), true);
assert.equal((appStartArgs as string[]).includes(smokeCase.socketPath), true);
assert.equal(appStartEntries[0]?.subminerMpvLog, smokeCase.mpvOverlayLogPath);
const appStopArgs = appStopEntries[0]?.argv;
assert.deepEqual(appStopArgs, ['--stop']);
const mpvFirstArgs = mpvEntries[0]?.argv;
assert.equal(Array.isArray(mpvFirstArgs), true);
assert.equal(
(mpvFirstArgs as string[]).some(
(arg) => arg === `--input-ipc-server=${smokeCase.socketPath}`,
),
true,
);
assert.equal((mpvFirstArgs as string[]).includes(smokeCase.videoPath), true);
});
},
);

196
launcher/types.ts Normal file
View File

@@ -0,0 +1,196 @@
import path from 'node:path';
import os from 'node:os';
export const VIDEO_EXTENSIONS = new Set([
'mkv',
'mp4',
'avi',
'webm',
'mov',
'flv',
'wmv',
'm4v',
'ts',
'm2ts',
]);
export const ROFI_THEME_FILE = 'subminer.rasi';
export const DEFAULT_SOCKET_PATH = '/tmp/subminer-socket';
export const DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS = ['ja', 'jpn'];
export const DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS = ['en', 'eng'];
export const YOUTUBE_SUB_EXTENSIONS = new Set(['.srt', '.vtt', '.ass']);
export const YOUTUBE_AUDIO_EXTENSIONS = new Set([
'.m4a',
'.mp3',
'.webm',
'.opus',
'.wav',
'.aac',
'.flac',
]);
export const DEFAULT_YOUTUBE_SUBGEN_OUT_DIR = path.join(
os.homedir(),
'.cache',
'subminer',
'youtube-subs',
);
export const DEFAULT_MPV_LOG_FILE = path.join(
os.homedir(),
'.config',
'SubMiner',
'logs',
`SubMiner-${new Date().toISOString().slice(0, 10)}.log`,
);
export const DEFAULT_YOUTUBE_YTDL_FORMAT = 'bestvideo*+bestaudio/best';
export const DEFAULT_JIMAKU_API_BASE_URL = 'https://jimaku.cc';
export const DEFAULT_MPV_SUBMINER_ARGS = [
'--sub-auto=fuzzy',
'--sub-file-paths=.;subs;subtitles',
'--sid=auto',
'--secondary-sid=auto',
'--secondary-sub-visibility=no',
'--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
'--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us',
] as const;
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export type YoutubeSubgenMode = 'automatic' | 'preprocess' | 'off';
export type Backend = 'auto' | 'hyprland' | 'x11' | 'macos';
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
export interface Args {
backend: Backend;
directory: string;
recursive: boolean;
profile: string;
startOverlay: boolean;
youtubeSubgenMode: YoutubeSubgenMode;
whisperBin: string;
whisperModel: string;
youtubeSubgenOutDir: string;
youtubeSubgenAudioFormat: string;
youtubeSubgenKeepTemp: boolean;
youtubePrimarySubLangs: string[];
youtubeSecondarySubLangs: string[];
youtubeAudioLangs: string[];
youtubeWhisperSourceLanguage: string;
useTexthooker: boolean;
autoStartOverlay: boolean;
texthookerOnly: boolean;
useRofi: boolean;
logLevel: LogLevel;
target: string;
targetKind: '' | 'file' | 'url';
jimakuApiKey: string;
jimakuApiKeyCommand: string;
jimakuApiBaseUrl: string;
jimakuLanguagePreference: JimakuLanguagePreference;
jimakuMaxEntryResults: number;
jellyfin: boolean;
jellyfinLogin: boolean;
jellyfinLogout: boolean;
jellyfinPlay: boolean;
jellyfinDiscovery: boolean;
doctor: boolean;
configPath: boolean;
configShow: boolean;
mpvIdle: boolean;
mpvSocket: boolean;
mpvStatus: boolean;
appPassthrough: boolean;
appArgs: string[];
jellyfinServer: string;
jellyfinUsername: string;
jellyfinPassword: string;
}
export interface LauncherYoutubeSubgenConfig {
mode?: YoutubeSubgenMode;
whisperBin?: string;
whisperModel?: string;
primarySubLanguages?: string[];
secondarySubLanguages?: string[];
jimakuApiKey?: string;
jimakuApiKeyCommand?: string;
jimakuApiBaseUrl?: string;
jimakuLanguagePreference?: JimakuLanguagePreference;
jimakuMaxEntryResults?: number;
}
export interface LauncherJellyfinConfig {
enabled?: boolean;
serverUrl?: string;
username?: string;
defaultLibraryId?: string;
pullPictures?: boolean;
iconCacheDir?: string;
}
export interface PluginRuntimeConfig {
socketPath: string;
}
export interface CommandExecOptions {
allowFailure?: boolean;
captureStdout?: boolean;
logLevel?: LogLevel;
commandLabel?: string;
streamOutput?: boolean;
env?: NodeJS.ProcessEnv;
}
export interface CommandExecResult {
code: number;
stdout: string;
stderr: string;
}
export interface SubtitleCandidate {
path: string;
lang: 'primary' | 'secondary';
ext: string;
size: number;
source: 'manual' | 'auto' | 'whisper' | 'whisper-translate';
}
export interface YoutubeSubgenOutputs {
basename: string;
primaryPath?: string;
secondaryPath?: string;
}
export interface MpvTrack {
type?: string;
id?: number;
lang?: string;
title?: string;
}
export interface JellyfinSessionConfig {
serverUrl: string;
accessToken: string;
userId: string;
defaultLibraryId: string;
pullPictures: boolean;
iconCacheDir: string;
}
export interface JellyfinLibraryEntry {
id: string;
name: string;
kind: string;
}
export interface JellyfinItemEntry {
id: string;
name: string;
type: string;
display: string;
}
export interface JellyfinGroupEntry {
id: string;
name: string;
type: string;
display: string;
}

213
launcher/util.ts Normal file
View File

@@ -0,0 +1,213 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { spawn } from 'node:child_process';
import type { LogLevel, CommandExecOptions, CommandExecResult } from './types.js';
import { log } from './log.js';
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function isExecutable(filePath: string): boolean {
try {
fs.accessSync(filePath, fs.constants.X_OK);
return true;
} catch {
return false;
}
}
export function commandExists(command: string): boolean {
const pathEnv = process.env.PATH ?? '';
for (const dir of pathEnv.split(path.delimiter)) {
if (!dir) continue;
const full = path.join(dir, command);
if (isExecutable(full)) return true;
}
return false;
}
export function resolvePathMaybe(input: string): string {
if (input.startsWith('~')) {
return path.join(os.homedir(), input.slice(1));
}
return input;
}
export function resolveBinaryPathCandidate(input: string): string {
const trimmed = input.trim();
if (!trimmed) return '';
const unquoted = trimmed.replace(/^"(.*)"$/, '$1').replace(/^'(.*)'$/, '$1');
return resolvePathMaybe(unquoted);
}
export function realpathMaybe(filePath: string): string {
try {
return fs.realpathSync(filePath);
} catch {
return path.resolve(filePath);
}
}
export function isUrlTarget(target: string): boolean {
return /^https?:\/\//.test(target) || /^ytsearch:/.test(target);
}
export function isYoutubeTarget(target: string): boolean {
return /^ytsearch:/.test(target) || /^https?:\/\/(www\.)?(youtube\.com|youtu\.be)\//.test(target);
}
export function sanitizeToken(value: string): string {
return String(value)
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
export function normalizeBasename(value: string, fallback: string): string {
const safe = sanitizeToken(value.replace(/[\\/]+/g, '-'));
if (safe) return safe;
const fallbackSafe = sanitizeToken(fallback);
if (fallbackSafe) return fallbackSafe;
return `${Date.now()}`;
}
export function normalizeLangCode(value: string): string {
return value
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]+/g, '');
}
export function uniqueNormalizedLangCodes(values: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const value of values) {
const normalized = normalizeLangCode(value);
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
out.push(normalized);
}
return out;
}
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function parseBoolLike(value: string): boolean | null {
const normalized = value.trim().toLowerCase();
if (normalized === 'yes' || normalized === 'true' || normalized === '1' || normalized === 'on') {
return true;
}
if (normalized === 'no' || normalized === 'false' || normalized === '0' || normalized === 'off') {
return false;
}
return null;
}
export function inferWhisperLanguage(langCodes: string[], fallback: string): string {
for (const lang of uniqueNormalizedLangCodes(langCodes)) {
if (lang === 'jpn') return 'ja';
if (lang.length >= 2) return lang.slice(0, 2);
}
return fallback;
}
export function runExternalCommand(
executable: string,
args: string[],
opts: CommandExecOptions = {},
childTracker?: Set<ReturnType<typeof spawn>>,
): Promise<CommandExecResult> {
const allowFailure = opts.allowFailure === true;
const captureStdout = opts.captureStdout === true;
const configuredLogLevel = opts.logLevel ?? 'info';
const commandLabel = opts.commandLabel || executable;
const streamOutput = opts.streamOutput === true;
return new Promise((resolve, reject) => {
log('debug', configuredLogLevel, `[${commandLabel}] spawn: ${executable} ${args.join(' ')}`);
const child = spawn(executable, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...opts.env },
});
childTracker?.add(child);
let stdout = '';
let stderr = '';
let stdoutBuffer = '';
let stderrBuffer = '';
const flushLines = (
buffer: string,
level: LogLevel,
sink: (remaining: string) => void,
): void => {
const lines = buffer.split(/\r?\n/);
const remaining = lines.pop() ?? '';
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length > 0) {
log(level, configuredLogLevel, `[${commandLabel}] ${trimmed}`);
}
}
sink(remaining);
};
child.stdout.on('data', (chunk: Buffer) => {
const text = chunk.toString();
if (captureStdout) stdout += text;
if (streamOutput) {
stdoutBuffer += text;
flushLines(stdoutBuffer, 'debug', (remaining) => {
stdoutBuffer = remaining;
});
}
});
child.stderr.on('data', (chunk: Buffer) => {
const text = chunk.toString();
stderr += text;
if (streamOutput) {
stderrBuffer += text;
flushLines(stderrBuffer, 'debug', (remaining) => {
stderrBuffer = remaining;
});
}
});
child.on('error', (error) => {
childTracker?.delete(child);
reject(new Error(`Failed to start "${executable}": ${error.message}`));
});
child.on('close', (code) => {
childTracker?.delete(child);
if (streamOutput) {
const trailingOut = stdoutBuffer.trim();
if (trailingOut.length > 0) {
log('debug', configuredLogLevel, `[${commandLabel}] ${trailingOut}`);
}
const trailingErr = stderrBuffer.trim();
if (trailingErr.length > 0) {
log('debug', configuredLogLevel, `[${commandLabel}] ${trailingErr}`);
}
}
log(
code === 0 ? 'debug' : 'warn',
configuredLogLevel,
`[${commandLabel}] exit code ${code ?? 1}`,
);
if (code !== 0 && !allowFailure) {
const commandString = `${executable} ${args.join(' ')}`;
reject(
new Error(`Command failed (${commandString}): ${stderr.trim() || `exit code ${code}`}`),
);
return;
}
resolve({ code: code ?? 1, stdout, stderr });
});
});
}

467
launcher/youtube.ts Normal file
View File

@@ -0,0 +1,467 @@
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import type { Args, SubtitleCandidate, YoutubeSubgenOutputs } from './types.js';
import { YOUTUBE_SUB_EXTENSIONS, YOUTUBE_AUDIO_EXTENSIONS } from './types.js';
import { log } from './log.js';
import {
resolvePathMaybe,
uniqueNormalizedLangCodes,
escapeRegExp,
normalizeBasename,
runExternalCommand,
commandExists,
} from './util.js';
import { state } from './mpv.js';
function toYtdlpLangPattern(langCodes: string[]): string {
return langCodes.map((lang) => `${lang}.*`).join(',');
}
function filenameHasLanguageTag(filenameLower: string, langCode: string): boolean {
const escaped = escapeRegExp(langCode);
const pattern = new RegExp(`(^|[._-])${escaped}([._-]|$)`);
return pattern.test(filenameLower);
}
function classifyLanguage(
filename: string,
primaryLangCodes: string[],
secondaryLangCodes: string[],
): 'primary' | 'secondary' | null {
const lower = filename.toLowerCase();
const primary = primaryLangCodes.some((code) => filenameHasLanguageTag(lower, code));
const secondary = secondaryLangCodes.some((code) => filenameHasLanguageTag(lower, code));
if (primary && !secondary) return 'primary';
if (secondary && !primary) return 'secondary';
return null;
}
function preferredLangLabel(langCodes: string[], fallback: string): string {
return uniqueNormalizedLangCodes(langCodes)[0] || fallback;
}
function sourceTag(source: SubtitleCandidate['source']): string {
if (source === 'manual' || source === 'auto') return `ytdlp-${source}`;
if (source === 'whisper-translate') return 'whisper-translate';
return 'whisper';
}
function pickBestCandidate(candidates: SubtitleCandidate[]): SubtitleCandidate | null {
if (candidates.length === 0) return null;
const scored = [...candidates].sort((a, b) => {
const sourceA = a.source === 'manual' ? 1 : 0;
const sourceB = b.source === 'manual' ? 1 : 0;
if (sourceA !== sourceB) return sourceB - sourceA;
const srtA = a.ext === '.srt' ? 1 : 0;
const srtB = b.ext === '.srt' ? 1 : 0;
if (srtA !== srtB) return srtB - srtA;
return b.size - a.size;
});
return scored[0];
}
function scanSubtitleCandidates(
tempDir: string,
knownSet: Set<string>,
source: 'manual' | 'auto',
primaryLangCodes: string[],
secondaryLangCodes: string[],
): SubtitleCandidate[] {
const entries = fs.readdirSync(tempDir);
const out: SubtitleCandidate[] = [];
for (const name of entries) {
const fullPath = path.join(tempDir, name);
if (knownSet.has(fullPath)) continue;
let stat: fs.Stats;
try {
stat = fs.statSync(fullPath);
} catch {
continue;
}
if (!stat.isFile()) continue;
const ext = path.extname(fullPath).toLowerCase();
if (!YOUTUBE_SUB_EXTENSIONS.has(ext)) continue;
const lang = classifyLanguage(name, primaryLangCodes, secondaryLangCodes);
if (!lang) continue;
out.push({ path: fullPath, lang, ext, size: stat.size, source });
}
return out;
}
async function convertToSrt(
inputPath: string,
tempDir: string,
langLabel: string,
): Promise<string> {
if (path.extname(inputPath).toLowerCase() === '.srt') return inputPath;
const outputPath = path.join(tempDir, `converted.${langLabel}.srt`);
await runExternalCommand('ffmpeg', ['-y', '-loglevel', 'error', '-i', inputPath, outputPath]);
return outputPath;
}
function findAudioFile(tempDir: string, preferredExt: string): string | null {
const entries = fs.readdirSync(tempDir);
const audioFiles: Array<{ path: string; ext: string; mtimeMs: number }> = [];
for (const name of entries) {
const fullPath = path.join(tempDir, name);
let stat: fs.Stats;
try {
stat = fs.statSync(fullPath);
} catch {
continue;
}
if (!stat.isFile()) continue;
const ext = path.extname(name).toLowerCase();
if (!YOUTUBE_AUDIO_EXTENSIONS.has(ext)) continue;
audioFiles.push({ path: fullPath, ext, mtimeMs: stat.mtimeMs });
}
if (audioFiles.length === 0) return null;
const preferred = audioFiles.find((entry) => entry.ext === `.${preferredExt.toLowerCase()}`);
if (preferred) return preferred.path;
audioFiles.sort((a, b) => b.mtimeMs - a.mtimeMs);
return audioFiles[0].path;
}
async function runWhisper(
whisperBin: string,
modelPath: string,
audioPath: string,
language: string,
translate: boolean,
outputPrefix: string,
): Promise<string> {
const args = [
'-m',
modelPath,
'-f',
audioPath,
'--output-srt',
'--output-file',
outputPrefix,
'--language',
language,
];
if (translate) args.push('--translate');
await runExternalCommand(whisperBin, args, {
commandLabel: 'whisper',
streamOutput: true,
});
const outputPath = `${outputPrefix}.srt`;
if (!fs.existsSync(outputPath)) {
throw new Error(`whisper output not found: ${outputPath}`);
}
return outputPath;
}
async function convertAudioForWhisper(inputPath: string, tempDir: string): Promise<string> {
const wavPath = path.join(tempDir, 'whisper-input.wav');
await runExternalCommand('ffmpeg', [
'-y',
'-loglevel',
'error',
'-i',
inputPath,
'-ar',
'16000',
'-ac',
'1',
'-c:a',
'pcm_s16le',
wavPath,
]);
if (!fs.existsSync(wavPath)) {
throw new Error(`Failed to prepare whisper audio input: ${wavPath}`);
}
return wavPath;
}
export function resolveWhisperBinary(args: Args): string | null {
const explicit = args.whisperBin.trim();
if (explicit) return resolvePathMaybe(explicit);
if (commandExists('whisper-cli')) return 'whisper-cli';
return null;
}
export async function generateYoutubeSubtitles(
target: string,
args: Args,
onReady?: (lang: 'primary' | 'secondary', pathToLoad: string) => Promise<void>,
): Promise<YoutubeSubgenOutputs> {
const outDir = path.resolve(resolvePathMaybe(args.youtubeSubgenOutDir));
fs.mkdirSync(outDir, { recursive: true });
const primaryLangCodes = uniqueNormalizedLangCodes(args.youtubePrimarySubLangs);
const secondaryLangCodes = uniqueNormalizedLangCodes(args.youtubeSecondarySubLangs);
const primaryLabel = preferredLangLabel(primaryLangCodes, 'primary');
const secondaryLabel = preferredLangLabel(secondaryLangCodes, 'secondary');
const secondaryCanUseWhisperTranslate =
secondaryLangCodes.includes('en') || secondaryLangCodes.includes('eng');
const ytdlpManualLangs = toYtdlpLangPattern([...primaryLangCodes, ...secondaryLangCodes]);
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-yt-subgen-'));
const knownFiles = new Set<string>();
let keepTemp = args.youtubeSubgenKeepTemp;
const publishTrack = async (
lang: 'primary' | 'secondary',
source: SubtitleCandidate['source'],
selectedPath: string,
basename: string,
): Promise<string> => {
const langLabel = lang === 'primary' ? primaryLabel : secondaryLabel;
const taggedPath = path.join(outDir, `${basename}.${langLabel}.${sourceTag(source)}.srt`);
const aliasPath = path.join(outDir, `${basename}.${langLabel}.srt`);
fs.copyFileSync(selectedPath, taggedPath);
fs.copyFileSync(taggedPath, aliasPath);
log('info', args.logLevel, `Generated subtitle (${langLabel}, ${source}) -> ${aliasPath}`);
if (onReady) await onReady(lang, aliasPath);
return aliasPath;
};
try {
log('debug', args.logLevel, `YouTube subtitle temp dir: ${tempDir}`);
const meta = await runExternalCommand(
'yt-dlp',
['--dump-single-json', '--no-warnings', target],
{
captureStdout: true,
logLevel: args.logLevel,
commandLabel: 'yt-dlp:meta',
},
state.youtubeSubgenChildren,
);
const metadata = JSON.parse(meta.stdout) as { id?: string };
const videoId = metadata.id || `${Date.now()}`;
const basename = normalizeBasename(videoId, videoId);
await runExternalCommand(
'yt-dlp',
[
'--skip-download',
'--no-warnings',
'--write-subs',
'--sub-format',
'srt/vtt/best',
'--sub-langs',
ytdlpManualLangs,
'-o',
path.join(tempDir, '%(id)s.%(ext)s'),
target,
],
{
allowFailure: true,
logLevel: args.logLevel,
commandLabel: 'yt-dlp:manual-subs',
streamOutput: true,
},
state.youtubeSubgenChildren,
);
const manualSubs = scanSubtitleCandidates(
tempDir,
knownFiles,
'manual',
primaryLangCodes,
secondaryLangCodes,
);
for (const sub of manualSubs) knownFiles.add(sub.path);
let primaryCandidates = manualSubs.filter((entry) => entry.lang === 'primary');
let secondaryCandidates = manualSubs.filter((entry) => entry.lang === 'secondary');
const missingAuto: string[] = [];
if (primaryCandidates.length === 0) missingAuto.push(toYtdlpLangPattern(primaryLangCodes));
if (secondaryCandidates.length === 0) missingAuto.push(toYtdlpLangPattern(secondaryLangCodes));
if (missingAuto.length > 0) {
await runExternalCommand(
'yt-dlp',
[
'--skip-download',
'--no-warnings',
'--write-auto-subs',
'--sub-format',
'srt/vtt/best',
'--sub-langs',
missingAuto.join(','),
'-o',
path.join(tempDir, '%(id)s.%(ext)s'),
target,
],
{
allowFailure: true,
logLevel: args.logLevel,
commandLabel: 'yt-dlp:auto-subs',
streamOutput: true,
},
state.youtubeSubgenChildren,
);
const autoSubs = scanSubtitleCandidates(
tempDir,
knownFiles,
'auto',
primaryLangCodes,
secondaryLangCodes,
);
for (const sub of autoSubs) knownFiles.add(sub.path);
primaryCandidates = primaryCandidates.concat(
autoSubs.filter((entry) => entry.lang === 'primary'),
);
secondaryCandidates = secondaryCandidates.concat(
autoSubs.filter((entry) => entry.lang === 'secondary'),
);
}
let primaryAlias = '';
let secondaryAlias = '';
const selectedPrimary = pickBestCandidate(primaryCandidates);
const selectedSecondary = pickBestCandidate(secondaryCandidates);
if (selectedPrimary) {
const srt = await convertToSrt(selectedPrimary.path, tempDir, primaryLabel);
primaryAlias = await publishTrack('primary', selectedPrimary.source, srt, basename);
}
if (selectedSecondary) {
const srt = await convertToSrt(selectedSecondary.path, tempDir, secondaryLabel);
secondaryAlias = await publishTrack('secondary', selectedSecondary.source, srt, basename);
}
const needsPrimaryWhisper = !selectedPrimary;
const needsSecondaryWhisper = !selectedSecondary && secondaryCanUseWhisperTranslate;
if (needsPrimaryWhisper || needsSecondaryWhisper) {
const whisperBin = resolveWhisperBinary(args);
const modelPath = args.whisperModel.trim()
? path.resolve(resolvePathMaybe(args.whisperModel.trim()))
: '';
const hasWhisperFallback = !!whisperBin && !!modelPath && fs.existsSync(modelPath);
if (!hasWhisperFallback) {
log(
'warn',
args.logLevel,
'Whisper fallback is not configured; continuing with available subtitle tracks.',
);
} else {
try {
await runExternalCommand(
'yt-dlp',
[
'-f',
'bestaudio/best',
'--extract-audio',
'--audio-format',
args.youtubeSubgenAudioFormat,
'--no-warnings',
'-o',
path.join(tempDir, '%(id)s.%(ext)s'),
target,
],
{
logLevel: args.logLevel,
commandLabel: 'yt-dlp:audio',
streamOutput: true,
},
state.youtubeSubgenChildren,
);
const audioPath = findAudioFile(tempDir, args.youtubeSubgenAudioFormat);
if (!audioPath) {
throw new Error('Audio extraction succeeded, but no audio file was found.');
}
const whisperAudioPath = await convertAudioForWhisper(audioPath, tempDir);
if (needsPrimaryWhisper) {
try {
const primaryPrefix = path.join(tempDir, `${basename}.${primaryLabel}`);
const primarySrt = await runWhisper(
whisperBin!,
modelPath,
whisperAudioPath,
args.youtubeWhisperSourceLanguage,
false,
primaryPrefix,
);
primaryAlias = await publishTrack('primary', 'whisper', primarySrt, basename);
} catch (error) {
log(
'warn',
args.logLevel,
`Failed to generate primary subtitle via whisper fallback: ${(error as Error).message}`,
);
}
}
if (needsSecondaryWhisper) {
try {
const secondaryPrefix = path.join(tempDir, `${basename}.${secondaryLabel}`);
const secondarySrt = await runWhisper(
whisperBin!,
modelPath,
whisperAudioPath,
args.youtubeWhisperSourceLanguage,
true,
secondaryPrefix,
);
secondaryAlias = await publishTrack(
'secondary',
'whisper-translate',
secondarySrt,
basename,
);
} catch (error) {
log(
'warn',
args.logLevel,
`Failed to generate secondary subtitle via whisper fallback: ${(error as Error).message}`,
);
}
}
} catch (error) {
log(
'warn',
args.logLevel,
`Whisper fallback pipeline failed: ${(error as Error).message}`,
);
}
}
}
if (!secondaryCanUseWhisperTranslate && !selectedSecondary) {
log(
'warn',
args.logLevel,
`Secondary subtitle language (${secondaryLabel}) has no whisper translate fallback; relying on yt-dlp subtitles only.`,
);
}
if (!primaryAlias && !secondaryAlias) {
throw new Error('Failed to generate any subtitle tracks.');
}
if (!primaryAlias || !secondaryAlias) {
log(
'warn',
args.logLevel,
`Generated partial subtitle result: primary=${primaryAlias ? 'ok' : 'missing'}, secondary=${secondaryAlias ? 'ok' : 'missing'}`,
);
}
return {
basename,
primaryPath: primaryAlias || undefined,
secondaryPath: secondaryAlias || undefined,
};
} catch (error) {
keepTemp = true;
throw error;
} finally {
if (keepTemp) {
log('warn', args.logLevel, `Keeping subtitle temp dir: ${tempDir}`);
} else {
try {
fs.rmSync(tempDir, { recursive: true, force: true });
} catch {
// ignore cleanup failures
}
}
}
}

73
plugin/subminer.conf Normal file
View File

@@ -0,0 +1,73 @@
# SubMiner configuration
# Place this file in ~/.config/mpv/script-opts/
# Path to SubMiner binary (leave empty for auto-detection)
# Auto-detection searches common locations, including:
# - macOS: /Applications/SubMiner.app/Contents/MacOS/SubMiner, ~/Applications/SubMiner.app/Contents/MacOS/SubMiner
# - Linux: ~/.local/bin/SubMiner.AppImage, /opt/SubMiner/SubMiner.AppImage, /usr/local/bin/SubMiner, /usr/bin/SubMiner
binary_path=
# Path to mpv IPC socket (must match input-ipc-server in mpv.conf)
socket_path=/tmp/subminer-socket
# Enable texthooker WebSocket server
texthooker_enabled=yes
# Texthooker WebSocket port
texthooker_port=5174
# Window manager backend: auto, hyprland, sway, x11
# "auto" detects based on environment variables
backend=auto
# Automatically start overlay when a file is loaded
auto_start=no
# Automatically show visible overlay when overlay starts
auto_start_visible_overlay=no
# Automatically show invisible overlay when overlay starts
# Values: platform-default, visible, hidden
# platform-default => hidden on Linux, visible on macOS/Windows
auto_start_invisible_overlay=platform-default
# Legacy alias (maps to auto_start_visible_overlay)
# auto_start_overlay=no
# Show OSD messages for overlay status
osd_messages=yes
# Log level for plugin and SubMiner binary: debug, info, warn, error
log_level=info
# Enable AniSkip intro detection + markers.
aniskip_enabled=yes
# Force title (optional). Launcher fills this from guessit when available.
aniskip_title=
# Force season (optional). Launcher fills this from guessit when available.
aniskip_season=
# Force MAL id (optional). Leave blank for title lookup.
aniskip_mal_id=
# Force episode number (optional). Leave blank for filename/title detection.
aniskip_episode=
# Show intro skip OSD button while inside OP range.
aniskip_show_button=yes
# OSD text shown for intro skip action.
# `%s` is replaced by keybinding.
aniskip_button_text=You can skip by pressing %s
# Keybinding to execute intro skip when button is visible.
aniskip_button_key=y-k
# OSD hint duration in seconds (shown during first 3s of intro).
aniskip_button_duration=3
# MPV keybindings provided by plugin/subminer.lua:
# y-s start, y-S stop, y-t toggle visible overlay
# y-i toggle invisible overlay, y-I show invisible overlay, y-u hide invisible overlay

1959
plugin/subminer.lua Normal file

File diff suppressed because it is too large Load Diff