mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-02-27 18:22:41 -08:00
chore: add project management metadata and remaining repository files
This commit is contained in:
14
backlog/config.yml
Normal file
14
backlog/config.yml
Normal 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"
|
||||
75
launcher/aniskip-metadata.test.ts
Normal file
75
launcher/aniskip-metadata.test.ts
Normal 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/);
|
||||
});
|
||||
196
launcher/aniskip-metadata.ts
Normal file
196
launcher/aniskip-metadata.ts
Normal 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(',');
|
||||
}
|
||||
20
launcher/commands/app-command.ts
Normal file
20
launcher/commands/app-command.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { launchTexthookerOnly, runAppCommandWithInherit } from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
export function runAppPassthroughCommand(context: LauncherCommandContext): boolean {
|
||||
const { args, appPath } = context;
|
||||
if (!args.appPassthrough || !appPath) {
|
||||
return false;
|
||||
}
|
||||
runAppCommandWithInherit(appPath, args.appArgs);
|
||||
return true;
|
||||
}
|
||||
|
||||
export function runTexthookerCommand(context: LauncherCommandContext): boolean {
|
||||
const { args, appPath } = context;
|
||||
if (!args.texthookerOnly || !appPath) {
|
||||
return false;
|
||||
}
|
||||
launchTexthookerOnly(appPath, args);
|
||||
return true;
|
||||
}
|
||||
90
launcher/commands/command-modules.test.ts
Normal file
90
launcher/commands/command-modules.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseArgs } from '../config.js';
|
||||
import type { ProcessAdapter } from '../process-adapter.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { runConfigCommand } from './config-command.js';
|
||||
import { runDoctorCommand } from './doctor-command.js';
|
||||
import { runMpvPreAppCommand } from './mpv-command.js';
|
||||
|
||||
class ExitSignal extends Error {
|
||||
code: number;
|
||||
|
||||
constructor(code: number) {
|
||||
super(`exit:${code}`);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function createContext(overrides: Partial<LauncherCommandContext> = {}): LauncherCommandContext {
|
||||
const args = parseArgs([], 'subminer', {});
|
||||
const adapter: ProcessAdapter = {
|
||||
platform: () => 'linux',
|
||||
onSignal: () => {},
|
||||
writeStdout: () => {},
|
||||
exit: (code) => {
|
||||
throw new ExitSignal(code);
|
||||
},
|
||||
setExitCode: () => {},
|
||||
};
|
||||
|
||||
return {
|
||||
args,
|
||||
scriptPath: '/tmp/subminer',
|
||||
scriptName: 'subminer',
|
||||
mpvSocketPath: '/tmp/subminer.sock',
|
||||
appPath: '/tmp/subminer.app',
|
||||
launcherJellyfinConfig: {},
|
||||
processAdapter: adapter,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('config command writes newline-terminated path via process adapter', () => {
|
||||
const writes: string[] = [];
|
||||
const context = createContext();
|
||||
context.args.configPath = true;
|
||||
context.processAdapter = {
|
||||
...context.processAdapter,
|
||||
writeStdout: (text) => writes.push(text),
|
||||
};
|
||||
|
||||
const handled = runConfigCommand(context, {
|
||||
existsSync: () => true,
|
||||
readFileSync: () => '',
|
||||
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
});
|
||||
|
||||
assert.equal(handled, true);
|
||||
assert.deepEqual(writes, ['/tmp/SubMiner/config.jsonc\n']);
|
||||
});
|
||||
|
||||
test('doctor command exits non-zero for missing hard dependencies', () => {
|
||||
const context = createContext({ appPath: null });
|
||||
context.args.doctor = true;
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
runDoctorCommand(context, {
|
||||
commandExists: () => false,
|
||||
configExists: () => true,
|
||||
resolveMainConfigPath: () => '/tmp/SubMiner/config.jsonc',
|
||||
}),
|
||||
(error: unknown) => error instanceof ExitSignal && error.code === 1,
|
||||
);
|
||||
});
|
||||
|
||||
test('mpv pre-app command exits non-zero when socket is not ready', async () => {
|
||||
const context = createContext();
|
||||
context.args.mpvStatus = true;
|
||||
|
||||
await assert.rejects(
|
||||
async () => {
|
||||
await runMpvPreAppCommand(context, {
|
||||
waitForUnixSocketReady: async () => false,
|
||||
launchMpvIdleDetached: async () => {},
|
||||
});
|
||||
},
|
||||
(error: unknown) => error instanceof ExitSignal && error.code === 1,
|
||||
);
|
||||
});
|
||||
43
launcher/commands/config-command.ts
Normal file
43
launcher/commands/config-command.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import fs from 'node:fs';
|
||||
import { fail } from '../log.js';
|
||||
import { resolveMainConfigPath } from '../config-path.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
interface ConfigCommandDeps {
|
||||
existsSync(path: string): boolean;
|
||||
readFileSync(path: string, encoding: BufferEncoding): string;
|
||||
resolveMainConfigPath(): string;
|
||||
}
|
||||
|
||||
const defaultDeps: ConfigCommandDeps = {
|
||||
existsSync: fs.existsSync,
|
||||
readFileSync: fs.readFileSync,
|
||||
resolveMainConfigPath,
|
||||
};
|
||||
|
||||
export function runConfigCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: ConfigCommandDeps = defaultDeps,
|
||||
): boolean {
|
||||
const { args, processAdapter } = context;
|
||||
if (args.configPath) {
|
||||
processAdapter.writeStdout(`${deps.resolveMainConfigPath()}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!args.configShow) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const configPath = deps.resolveMainConfigPath();
|
||||
if (!deps.existsSync(configPath)) {
|
||||
fail(`Config file not found: ${configPath}`);
|
||||
}
|
||||
|
||||
const contents = deps.readFileSync(configPath, 'utf8');
|
||||
processAdapter.writeStdout(contents);
|
||||
if (!contents.endsWith('\n')) {
|
||||
processAdapter.writeStdout('\n');
|
||||
}
|
||||
return true;
|
||||
}
|
||||
12
launcher/commands/context.ts
Normal file
12
launcher/commands/context.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { Args, LauncherJellyfinConfig } from '../types.js';
|
||||
import type { ProcessAdapter } from '../process-adapter.js';
|
||||
|
||||
export interface LauncherCommandContext {
|
||||
args: Args;
|
||||
scriptPath: string;
|
||||
scriptName: string;
|
||||
mpvSocketPath: string;
|
||||
appPath: string | null;
|
||||
launcherJellyfinConfig: LauncherJellyfinConfig;
|
||||
processAdapter: ProcessAdapter;
|
||||
}
|
||||
85
launcher/commands/doctor-command.ts
Normal file
85
launcher/commands/doctor-command.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import fs from 'node:fs';
|
||||
import { log } from '../log.js';
|
||||
import { commandExists } from '../util.js';
|
||||
import { resolveMainConfigPath } from '../config-path.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
interface DoctorCommandDeps {
|
||||
commandExists(command: string): boolean;
|
||||
configExists(path: string): boolean;
|
||||
resolveMainConfigPath(): string;
|
||||
}
|
||||
|
||||
const defaultDeps: DoctorCommandDeps = {
|
||||
commandExists,
|
||||
configExists: fs.existsSync,
|
||||
resolveMainConfigPath,
|
||||
};
|
||||
|
||||
export function runDoctorCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: DoctorCommandDeps = defaultDeps,
|
||||
): boolean {
|
||||
const { args, appPath, mpvSocketPath, processAdapter } = context;
|
||||
if (!args.doctor) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const configPath = deps.resolveMainConfigPath();
|
||||
const mpvFound = deps.commandExists('mpv');
|
||||
const checks: Array<{ label: string; ok: boolean; detail: string }> = [
|
||||
{
|
||||
label: 'app binary',
|
||||
ok: Boolean(appPath),
|
||||
detail: appPath || 'not found (set SUBMINER_APPIMAGE_PATH)',
|
||||
},
|
||||
{
|
||||
label: 'mpv',
|
||||
ok: mpvFound,
|
||||
detail: mpvFound ? 'found' : 'missing',
|
||||
},
|
||||
{
|
||||
label: 'yt-dlp',
|
||||
ok: deps.commandExists('yt-dlp'),
|
||||
detail: deps.commandExists('yt-dlp') ? 'found' : 'missing (optional unless YouTube URLs)',
|
||||
},
|
||||
{
|
||||
label: 'ffmpeg',
|
||||
ok: deps.commandExists('ffmpeg'),
|
||||
detail: deps.commandExists('ffmpeg')
|
||||
? 'found'
|
||||
: 'missing (optional unless subtitle generation)',
|
||||
},
|
||||
{
|
||||
label: 'fzf',
|
||||
ok: deps.commandExists('fzf'),
|
||||
detail: deps.commandExists('fzf') ? 'found' : 'missing (optional if using rofi)',
|
||||
},
|
||||
{
|
||||
label: 'rofi',
|
||||
ok: deps.commandExists('rofi'),
|
||||
detail: deps.commandExists('rofi') ? 'found' : 'missing (optional if using fzf)',
|
||||
},
|
||||
{
|
||||
label: 'config',
|
||||
ok: deps.configExists(configPath),
|
||||
detail: configPath,
|
||||
},
|
||||
{
|
||||
label: 'mpv socket path',
|
||||
ok: true,
|
||||
detail: mpvSocketPath,
|
||||
},
|
||||
];
|
||||
|
||||
const hasHardFailure = checks.some((entry) =>
|
||||
entry.label === 'app binary' || entry.label === 'mpv' ? !entry.ok : false,
|
||||
);
|
||||
|
||||
for (const check of checks) {
|
||||
log(check.ok ? 'info' : 'warn', args.logLevel, `[doctor] ${check.label}: ${check.detail}`);
|
||||
}
|
||||
|
||||
processAdapter.exit(hasHardFailure ? 1 : 0);
|
||||
return true;
|
||||
}
|
||||
71
launcher/commands/jellyfin-command.ts
Normal file
71
launcher/commands/jellyfin-command.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { fail } from '../log.js';
|
||||
import { runAppCommandWithInherit } from '../mpv.js';
|
||||
import { commandExists } from '../util.js';
|
||||
import { runJellyfinPlayMenu } from '../jellyfin.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
export async function runJellyfinCommand(context: LauncherCommandContext): Promise<boolean> {
|
||||
const { args, appPath, scriptPath, mpvSocketPath, launcherJellyfinConfig } = context;
|
||||
if (!appPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (args.jellyfin) {
|
||||
const forwarded = ['--jellyfin'];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
if (args.jellyfinLogin) {
|
||||
const serverUrl = args.jellyfinServer || launcherJellyfinConfig.serverUrl || '';
|
||||
const username = args.jellyfinUsername || launcherJellyfinConfig.username || '';
|
||||
const password = args.jellyfinPassword || '';
|
||||
if (!serverUrl || !username || !password) {
|
||||
fail(
|
||||
'--jellyfin-login requires server, username, and password. Pass flags or run `subminer --jellyfin`.',
|
||||
);
|
||||
}
|
||||
const forwarded = [
|
||||
'--jellyfin-login',
|
||||
'--jellyfin-server',
|
||||
serverUrl,
|
||||
'--jellyfin-username',
|
||||
username,
|
||||
'--jellyfin-password',
|
||||
password,
|
||||
];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
if (args.jellyfinLogout) {
|
||||
const forwarded = ['--jellyfin-logout'];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
if (args.jellyfinPlay) {
|
||||
if (!args.useRofi && !commandExists('fzf')) {
|
||||
fail('fzf not found. Install fzf or use -R for rofi.');
|
||||
}
|
||||
if (args.useRofi && !commandExists('rofi')) {
|
||||
fail('rofi not found. Install rofi or omit -R for fzf.');
|
||||
}
|
||||
await runJellyfinPlayMenu(appPath, args, scriptPath, mpvSocketPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (args.jellyfinDiscovery) {
|
||||
const forwarded = ['--start'];
|
||||
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
|
||||
runAppCommandWithInherit(appPath, forwarded);
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
args.jellyfin ||
|
||||
args.jellyfinLogin ||
|
||||
args.jellyfinLogout ||
|
||||
args.jellyfinPlay ||
|
||||
args.jellyfinDiscovery,
|
||||
);
|
||||
}
|
||||
62
launcher/commands/mpv-command.ts
Normal file
62
launcher/commands/mpv-command.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { fail, log } from '../log.js';
|
||||
import { waitForUnixSocketReady, launchMpvIdleDetached } from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
interface MpvCommandDeps {
|
||||
waitForUnixSocketReady(socketPath: string, timeoutMs: number): Promise<boolean>;
|
||||
launchMpvIdleDetached(
|
||||
socketPath: string,
|
||||
appPath: string,
|
||||
args: LauncherCommandContext['args'],
|
||||
): Promise<void>;
|
||||
}
|
||||
|
||||
const defaultDeps: MpvCommandDeps = {
|
||||
waitForUnixSocketReady,
|
||||
launchMpvIdleDetached,
|
||||
};
|
||||
|
||||
export async function runMpvPreAppCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: MpvCommandDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
const { args, mpvSocketPath, processAdapter } = context;
|
||||
if (args.mpvSocket) {
|
||||
processAdapter.writeStdout(`${mpvSocketPath}\n`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!args.mpvStatus) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 500);
|
||||
log(
|
||||
ready ? 'info' : 'warn',
|
||||
args.logLevel,
|
||||
`[mpv] socket ${ready ? 'ready' : 'not ready'}: ${mpvSocketPath}`,
|
||||
);
|
||||
processAdapter.exit(ready ? 0 : 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function runMpvPostAppCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: MpvCommandDeps = defaultDeps,
|
||||
): Promise<boolean> {
|
||||
const { args, appPath, mpvSocketPath } = context;
|
||||
if (!args.mpvIdle) {
|
||||
return false;
|
||||
}
|
||||
if (!appPath) {
|
||||
fail('SubMiner app binary not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||
}
|
||||
|
||||
await deps.launchMpvIdleDetached(mpvSocketPath, appPath, args);
|
||||
const ready = await deps.waitForUnixSocketReady(mpvSocketPath, 8000);
|
||||
if (!ready) {
|
||||
fail(`MPV IPC socket not ready after idle launch: ${mpvSocketPath}`);
|
||||
}
|
||||
log('info', args.logLevel, `[mpv] idle instance ready on ${mpvSocketPath}`);
|
||||
return true;
|
||||
}
|
||||
208
launcher/commands/playback-command.ts
Normal file
208
launcher/commands/playback-command.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fail, log } from '../log.js';
|
||||
import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from '../util.js';
|
||||
import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
||||
import {
|
||||
loadSubtitleIntoMpv,
|
||||
startMpv,
|
||||
startOverlay,
|
||||
state,
|
||||
stopOverlay,
|
||||
waitForUnixSocketReady,
|
||||
} from '../mpv.js';
|
||||
import { generateYoutubeSubtitles } from '../youtube.js';
|
||||
import type { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
function checkDependencies(args: Args): void {
|
||||
const missing: string[] = [];
|
||||
|
||||
if (!commandExists('mpv')) missing.push('mpv');
|
||||
|
||||
if (args.targetKind === 'url' && isYoutubeTarget(args.target) && !commandExists('yt-dlp')) {
|
||||
missing.push('yt-dlp');
|
||||
}
|
||||
|
||||
if (
|
||||
args.targetKind === 'url' &&
|
||||
isYoutubeTarget(args.target) &&
|
||||
args.youtubeSubgenMode !== 'off' &&
|
||||
!commandExists('ffmpeg')
|
||||
) {
|
||||
missing.push('ffmpeg');
|
||||
}
|
||||
|
||||
if (missing.length > 0) fail(`Missing dependencies: ${missing.join(' ')}`);
|
||||
}
|
||||
|
||||
function checkPickerDependencies(args: Args): void {
|
||||
if (args.useRofi) {
|
||||
if (!commandExists('rofi')) fail('Missing dependency: rofi');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!commandExists('fzf')) fail('Missing dependency: fzf');
|
||||
}
|
||||
|
||||
async function chooseTarget(
|
||||
args: Args,
|
||||
scriptPath: string,
|
||||
): Promise<{ target: string; kind: 'file' | 'url' } | null> {
|
||||
if (args.target) {
|
||||
return { target: args.target, kind: args.targetKind as 'file' | 'url' };
|
||||
}
|
||||
|
||||
const searchDir = realpathMaybe(resolvePathMaybe(args.directory));
|
||||
if (!fs.existsSync(searchDir) || !fs.statSync(searchDir).isDirectory()) {
|
||||
fail(`Directory not found: ${searchDir}`);
|
||||
}
|
||||
|
||||
const videos = collectVideos(searchDir, args.recursive);
|
||||
if (videos.length === 0) {
|
||||
fail(`No video files found in: ${searchDir}`);
|
||||
}
|
||||
|
||||
log('info', args.logLevel, `Browsing: ${searchDir} (${videos.length} videos found)`);
|
||||
|
||||
const selected = args.useRofi
|
||||
? showRofiMenu(videos, searchDir, args.recursive, scriptPath, args.logLevel)
|
||||
: showFzfMenu(videos);
|
||||
|
||||
if (!selected) return null;
|
||||
return { target: selected, kind: 'file' };
|
||||
}
|
||||
|
||||
function registerCleanup(context: LauncherCommandContext): void {
|
||||
const { args, processAdapter } = context;
|
||||
processAdapter.onSignal('SIGINT', () => {
|
||||
stopOverlay(args);
|
||||
processAdapter.exit(130);
|
||||
});
|
||||
processAdapter.onSignal('SIGTERM', () => {
|
||||
stopOverlay(args);
|
||||
processAdapter.exit(143);
|
||||
});
|
||||
}
|
||||
|
||||
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
||||
const { args, appPath, scriptPath, mpvSocketPath, processAdapter } = context;
|
||||
if (!appPath) {
|
||||
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||
}
|
||||
|
||||
if (!args.target) {
|
||||
checkPickerDependencies(args);
|
||||
}
|
||||
|
||||
const targetChoice = await chooseTarget(args, scriptPath);
|
||||
if (!targetChoice) {
|
||||
log('info', args.logLevel, 'No video selected, exiting');
|
||||
processAdapter.exit(0);
|
||||
}
|
||||
|
||||
checkDependencies({
|
||||
...args,
|
||||
target: targetChoice ? targetChoice.target : args.target,
|
||||
targetKind: targetChoice ? targetChoice.kind : 'url',
|
||||
});
|
||||
|
||||
registerCleanup(context);
|
||||
|
||||
const selectedTarget = targetChoice
|
||||
? {
|
||||
target: targetChoice.target,
|
||||
kind: targetChoice.kind as 'file' | 'url',
|
||||
}
|
||||
: { target: args.target, kind: 'url' as const };
|
||||
|
||||
const isYoutubeUrl = selectedTarget.kind === 'url' && isYoutubeTarget(selectedTarget.target);
|
||||
let preloadedSubtitles: { primaryPath?: string; secondaryPath?: string } | undefined;
|
||||
|
||||
if (isYoutubeUrl && args.youtubeSubgenMode === 'preprocess') {
|
||||
log('info', args.logLevel, 'YouTube subtitle mode: preprocess');
|
||||
const generated = await generateYoutubeSubtitles(selectedTarget.target, args);
|
||||
preloadedSubtitles = {
|
||||
primaryPath: generated.primaryPath,
|
||||
secondaryPath: generated.secondaryPath,
|
||||
};
|
||||
log(
|
||||
'info',
|
||||
args.logLevel,
|
||||
`YouTube preprocess result: primary=${generated.primaryPath ? 'ready' : 'missing'}, secondary=${generated.secondaryPath ? 'ready' : 'missing'}`,
|
||||
);
|
||||
} else if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
|
||||
log('info', args.logLevel, 'YouTube subtitle mode: automatic (background)');
|
||||
} else if (isYoutubeUrl) {
|
||||
log('info', args.logLevel, 'YouTube subtitle mode: off');
|
||||
}
|
||||
|
||||
startMpv(
|
||||
selectedTarget.target,
|
||||
selectedTarget.kind,
|
||||
args,
|
||||
mpvSocketPath,
|
||||
appPath,
|
||||
preloadedSubtitles,
|
||||
);
|
||||
|
||||
if (isYoutubeUrl && args.youtubeSubgenMode === 'automatic') {
|
||||
void generateYoutubeSubtitles(selectedTarget.target, args, async (lang, subtitlePath) => {
|
||||
try {
|
||||
await loadSubtitleIntoMpv(mpvSocketPath, subtitlePath, lang === 'primary', args.logLevel);
|
||||
} catch (error) {
|
||||
log(
|
||||
'warn',
|
||||
args.logLevel,
|
||||
`Generated subtitle ready but failed to load in mpv: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}).catch((error) => {
|
||||
log(
|
||||
'warn',
|
||||
args.logLevel,
|
||||
`Background subtitle generation failed: ${(error as Error).message}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
const ready = await waitForUnixSocketReady(mpvSocketPath, 10000);
|
||||
const shouldStartOverlay = args.startOverlay || args.autoStartOverlay;
|
||||
if (shouldStartOverlay) {
|
||||
if (ready) {
|
||||
log('info', args.logLevel, 'MPV IPC socket ready, starting SubMiner overlay');
|
||||
} else {
|
||||
log(
|
||||
'info',
|
||||
args.logLevel,
|
||||
'MPV IPC socket not ready after timeout, starting SubMiner overlay anyway',
|
||||
);
|
||||
}
|
||||
await startOverlay(appPath, args, mpvSocketPath);
|
||||
} else if (ready) {
|
||||
log(
|
||||
'info',
|
||||
args.logLevel,
|
||||
'MPV IPC socket ready, overlay auto-start disabled (use y-s to start)',
|
||||
);
|
||||
} else {
|
||||
log(
|
||||
'info',
|
||||
args.logLevel,
|
||||
'MPV IPC socket not ready yet, overlay auto-start disabled (use y-s to start)',
|
||||
);
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!state.mpvProc) {
|
||||
stopOverlay(args);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
state.mpvProc.on('exit', (code) => {
|
||||
stopOverlay(args);
|
||||
processAdapter.setExitCode(code ?? 0);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
60
launcher/config-domain-parsers.test.ts
Normal file
60
launcher/config-domain-parsers.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { parseLauncherYoutubeSubgenConfig } from './config/youtube-subgen-config.js';
|
||||
import { parseLauncherJellyfinConfig } from './config/jellyfin-config.js';
|
||||
import { parsePluginRuntimeConfigContent } from './config/plugin-runtime-config.js';
|
||||
|
||||
test('parseLauncherYoutubeSubgenConfig keeps only valid typed values', () => {
|
||||
const parsed = parseLauncherYoutubeSubgenConfig({
|
||||
youtubeSubgen: {
|
||||
mode: 'preprocess',
|
||||
whisperBin: '/usr/bin/whisper',
|
||||
whisperModel: '/models/base.bin',
|
||||
primarySubLanguages: ['ja', 42, 'en'],
|
||||
},
|
||||
secondarySub: {
|
||||
secondarySubLanguages: ['eng', true, 'deu'],
|
||||
},
|
||||
jimaku: {
|
||||
apiKey: 'abc',
|
||||
apiKeyCommand: 'pass show key',
|
||||
apiBaseUrl: 'https://jimaku.cc',
|
||||
languagePreference: 'ja',
|
||||
maxEntryResults: 8.7,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(parsed.mode, 'preprocess');
|
||||
assert.deepEqual(parsed.primarySubLanguages, ['ja', 'en']);
|
||||
assert.deepEqual(parsed.secondarySubLanguages, ['eng', 'deu']);
|
||||
assert.equal(parsed.jimakuLanguagePreference, 'ja');
|
||||
assert.equal(parsed.jimakuMaxEntryResults, 8);
|
||||
});
|
||||
|
||||
test('parseLauncherJellyfinConfig omits legacy token and user id fields', () => {
|
||||
const parsed = parseLauncherJellyfinConfig({
|
||||
jellyfin: {
|
||||
enabled: true,
|
||||
serverUrl: 'https://jf.example',
|
||||
username: 'alice',
|
||||
accessToken: 'legacy-token',
|
||||
userId: 'legacy-user',
|
||||
pullPictures: true,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(parsed.enabled, true);
|
||||
assert.equal(parsed.serverUrl, 'https://jf.example');
|
||||
assert.equal(parsed.username, 'alice');
|
||||
assert.equal(parsed.pullPictures, true);
|
||||
assert.equal('accessToken' in parsed, false);
|
||||
assert.equal('userId' in parsed, false);
|
||||
});
|
||||
|
||||
test('parsePluginRuntimeConfigContent reads socket_path and ignores inline comments', () => {
|
||||
const parsed = parsePluginRuntimeConfigContent(`
|
||||
# comment
|
||||
socket_path = /tmp/custom.sock # trailing comment
|
||||
`);
|
||||
assert.equal(parsed.socketPath, '/tmp/custom.sock');
|
||||
});
|
||||
11
launcher/config-path.ts
Normal file
11
launcher/config-path.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { resolveConfigFilePath } from '../src/config/path-resolution.js';
|
||||
|
||||
export function resolveMainConfigPath(): string {
|
||||
return resolveConfigFilePath({
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
}
|
||||
21
launcher/config.test.ts
Normal file
21
launcher/config.test.ts
Normal 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
61
launcher/config.ts
Normal 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;
|
||||
}
|
||||
257
launcher/config/args-normalizer.ts
Normal file
257
launcher/config/args-normalizer.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import fs from 'node:fs';
|
||||
import { fail } from '../log.js';
|
||||
import type {
|
||||
Args,
|
||||
Backend,
|
||||
LauncherYoutubeSubgenConfig,
|
||||
LogLevel,
|
||||
YoutubeSubgenMode,
|
||||
} from '../types.js';
|
||||
import {
|
||||
DEFAULT_JIMAKU_API_BASE_URL,
|
||||
DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS,
|
||||
DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS,
|
||||
DEFAULT_YOUTUBE_SUBGEN_OUT_DIR,
|
||||
} from '../types.js';
|
||||
import {
|
||||
inferWhisperLanguage,
|
||||
isUrlTarget,
|
||||
resolvePathMaybe,
|
||||
uniqueNormalizedLangCodes,
|
||||
} from '../util.js';
|
||||
import type { CliInvocations } from './cli-parser-builder.js';
|
||||
|
||||
function ensureTarget(target: string, parsed: Args): void {
|
||||
if (isUrlTarget(target)) {
|
||||
parsed.target = target;
|
||||
parsed.targetKind = 'url';
|
||||
return;
|
||||
}
|
||||
const resolved = resolvePathMaybe(target);
|
||||
let stat: fs.Stats | null = null;
|
||||
try {
|
||||
stat = fs.statSync(resolved);
|
||||
} catch {
|
||||
stat = null;
|
||||
}
|
||||
if (stat?.isFile()) {
|
||||
parsed.target = resolved;
|
||||
parsed.targetKind = 'file';
|
||||
return;
|
||||
}
|
||||
if (stat?.isDirectory()) {
|
||||
parsed.directory = resolved;
|
||||
return;
|
||||
}
|
||||
fail(`Not a file, directory, or supported URL: ${target}`);
|
||||
}
|
||||
|
||||
function parseLogLevel(value: string): LogLevel {
|
||||
if (value === 'debug' || value === 'info' || value === 'warn' || value === 'error') {
|
||||
return value;
|
||||
}
|
||||
fail(`Invalid log level: ${value} (must be debug, info, warn, or error)`);
|
||||
}
|
||||
|
||||
function parseYoutubeMode(value: string): YoutubeSubgenMode {
|
||||
const normalized = value.toLowerCase();
|
||||
if (normalized === 'automatic' || normalized === 'preprocess' || normalized === 'off') {
|
||||
return normalized as YoutubeSubgenMode;
|
||||
}
|
||||
fail(`Invalid yt-subgen mode: ${value} (must be automatic, preprocess, or off)`);
|
||||
}
|
||||
|
||||
function parseBackend(value: string): Backend {
|
||||
if (value === 'auto' || value === 'hyprland' || value === 'x11' || value === 'macos') {
|
||||
return value as Backend;
|
||||
}
|
||||
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
|
||||
}
|
||||
|
||||
export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): Args {
|
||||
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase();
|
||||
const defaultMode: YoutubeSubgenMode =
|
||||
envMode === 'preprocess' || envMode === 'off' || envMode === 'automatic'
|
||||
? (envMode as YoutubeSubgenMode)
|
||||
: launcherConfig.mode
|
||||
? launcherConfig.mode
|
||||
: 'automatic';
|
||||
const configuredSecondaryLangs = uniqueNormalizedLangCodes(
|
||||
launcherConfig.secondarySubLanguages ?? [],
|
||||
);
|
||||
const configuredPrimaryLangs = uniqueNormalizedLangCodes(
|
||||
launcherConfig.primarySubLanguages ?? [],
|
||||
);
|
||||
const primarySubLangs =
|
||||
configuredPrimaryLangs.length > 0
|
||||
? configuredPrimaryLangs
|
||||
: [...DEFAULT_YOUTUBE_PRIMARY_SUB_LANGS];
|
||||
const secondarySubLangs =
|
||||
configuredSecondaryLangs.length > 0
|
||||
? configuredSecondaryLangs
|
||||
: [...DEFAULT_YOUTUBE_SECONDARY_SUB_LANGS];
|
||||
const youtubeAudioLangs = uniqueNormalizedLangCodes([...primarySubLangs, ...secondarySubLangs]);
|
||||
|
||||
const parsed: Args = {
|
||||
backend: 'auto',
|
||||
directory: '.',
|
||||
recursive: false,
|
||||
profile: 'subminer',
|
||||
startOverlay: false,
|
||||
youtubeSubgenMode: defaultMode,
|
||||
whisperBin: process.env.SUBMINER_WHISPER_BIN || launcherConfig.whisperBin || '',
|
||||
whisperModel: process.env.SUBMINER_WHISPER_MODEL || launcherConfig.whisperModel || '',
|
||||
youtubeSubgenOutDir: process.env.SUBMINER_YT_SUBGEN_OUT_DIR || DEFAULT_YOUTUBE_SUBGEN_OUT_DIR,
|
||||
youtubeSubgenAudioFormat: process.env.SUBMINER_YT_SUBGEN_AUDIO_FORMAT || 'm4a',
|
||||
youtubeSubgenKeepTemp: process.env.SUBMINER_YT_SUBGEN_KEEP_TEMP === '1',
|
||||
jimakuApiKey: process.env.SUBMINER_JIMAKU_API_KEY || '',
|
||||
jimakuApiKeyCommand: process.env.SUBMINER_JIMAKU_API_KEY_COMMAND || '',
|
||||
jimakuApiBaseUrl: process.env.SUBMINER_JIMAKU_API_BASE_URL || DEFAULT_JIMAKU_API_BASE_URL,
|
||||
jimakuLanguagePreference: launcherConfig.jimakuLanguagePreference || 'ja',
|
||||
jimakuMaxEntryResults: launcherConfig.jimakuMaxEntryResults || 10,
|
||||
jellyfin: false,
|
||||
jellyfinLogin: false,
|
||||
jellyfinLogout: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
doctor: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
mpvIdle: false,
|
||||
mpvSocket: false,
|
||||
mpvStatus: false,
|
||||
appPassthrough: false,
|
||||
appArgs: [],
|
||||
jellyfinServer: '',
|
||||
jellyfinUsername: '',
|
||||
jellyfinPassword: '',
|
||||
youtubePrimarySubLangs: primarySubLangs,
|
||||
youtubeSecondarySubLangs: secondarySubLangs,
|
||||
youtubeAudioLangs,
|
||||
youtubeWhisperSourceLanguage: inferWhisperLanguage(primarySubLangs, 'ja'),
|
||||
useTexthooker: true,
|
||||
autoStartOverlay: false,
|
||||
texthookerOnly: false,
|
||||
useRofi: false,
|
||||
logLevel: 'info',
|
||||
target: '',
|
||||
targetKind: '',
|
||||
};
|
||||
|
||||
if (launcherConfig.jimakuApiKey) parsed.jimakuApiKey = launcherConfig.jimakuApiKey;
|
||||
if (launcherConfig.jimakuApiKeyCommand)
|
||||
parsed.jimakuApiKeyCommand = launcherConfig.jimakuApiKeyCommand;
|
||||
if (launcherConfig.jimakuApiBaseUrl) parsed.jimakuApiBaseUrl = launcherConfig.jimakuApiBaseUrl;
|
||||
if (launcherConfig.jimakuLanguagePreference)
|
||||
parsed.jimakuLanguagePreference = launcherConfig.jimakuLanguagePreference;
|
||||
if (launcherConfig.jimakuMaxEntryResults !== undefined)
|
||||
parsed.jimakuMaxEntryResults = launcherConfig.jimakuMaxEntryResults;
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function applyRootOptionsToArgs(
|
||||
parsed: Args,
|
||||
options: Record<string, unknown>,
|
||||
rootTarget: unknown,
|
||||
): void {
|
||||
if (typeof options.backend === 'string') parsed.backend = parseBackend(options.backend);
|
||||
if (typeof options.directory === 'string') parsed.directory = options.directory;
|
||||
if (options.recursive === true) parsed.recursive = true;
|
||||
if (typeof options.profile === 'string') parsed.profile = options.profile;
|
||||
if (options.start === true) parsed.startOverlay = true;
|
||||
if (typeof options.logLevel === 'string') parsed.logLevel = parseLogLevel(options.logLevel);
|
||||
if (options.rofi === true) parsed.useRofi = true;
|
||||
if (options.startOverlay === true) parsed.autoStartOverlay = true;
|
||||
if (options.texthooker === false) parsed.useTexthooker = false;
|
||||
if (typeof rootTarget === 'string' && rootTarget) ensureTarget(rootTarget, parsed);
|
||||
}
|
||||
|
||||
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||
if (invocations.doctorTriggered) parsed.doctor = true;
|
||||
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
|
||||
|
||||
if (invocations.jellyfinInvocation) {
|
||||
if (invocations.jellyfinInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.jellyfinInvocation.logLevel);
|
||||
}
|
||||
const action = (invocations.jellyfinInvocation.action || '').toLowerCase();
|
||||
if (action && !['setup', 'discovery', 'play', 'login', 'logout'].includes(action)) {
|
||||
fail(`Unknown jellyfin action: ${invocations.jellyfinInvocation.action}`);
|
||||
}
|
||||
parsed.jellyfinServer = invocations.jellyfinInvocation.server || '';
|
||||
parsed.jellyfinUsername = invocations.jellyfinInvocation.username || '';
|
||||
parsed.jellyfinPassword = invocations.jellyfinInvocation.password || '';
|
||||
|
||||
const modeFlags = {
|
||||
setup: invocations.jellyfinInvocation.setup || action === 'setup',
|
||||
discovery: invocations.jellyfinInvocation.discovery || action === 'discovery',
|
||||
play: invocations.jellyfinInvocation.play || action === 'play',
|
||||
login: invocations.jellyfinInvocation.login || action === 'login',
|
||||
logout: invocations.jellyfinInvocation.logout || action === 'logout',
|
||||
};
|
||||
if (
|
||||
!modeFlags.setup &&
|
||||
!modeFlags.discovery &&
|
||||
!modeFlags.play &&
|
||||
!modeFlags.login &&
|
||||
!modeFlags.logout
|
||||
) {
|
||||
modeFlags.setup = true;
|
||||
}
|
||||
|
||||
parsed.jellyfin = Boolean(modeFlags.setup);
|
||||
parsed.jellyfinDiscovery = Boolean(modeFlags.discovery);
|
||||
parsed.jellyfinPlay = Boolean(modeFlags.play);
|
||||
parsed.jellyfinLogin = Boolean(modeFlags.login);
|
||||
parsed.jellyfinLogout = Boolean(modeFlags.logout);
|
||||
}
|
||||
|
||||
if (invocations.ytInvocation) {
|
||||
if (invocations.ytInvocation.logLevel)
|
||||
parsed.logLevel = parseLogLevel(invocations.ytInvocation.logLevel);
|
||||
if (invocations.ytInvocation.mode)
|
||||
parsed.youtubeSubgenMode = parseYoutubeMode(invocations.ytInvocation.mode);
|
||||
if (invocations.ytInvocation.outDir)
|
||||
parsed.youtubeSubgenOutDir = invocations.ytInvocation.outDir;
|
||||
if (invocations.ytInvocation.keepTemp) parsed.youtubeSubgenKeepTemp = true;
|
||||
if (invocations.ytInvocation.whisperBin)
|
||||
parsed.whisperBin = invocations.ytInvocation.whisperBin;
|
||||
if (invocations.ytInvocation.whisperModel)
|
||||
parsed.whisperModel = invocations.ytInvocation.whisperModel;
|
||||
if (invocations.ytInvocation.ytSubgenAudioFormat) {
|
||||
parsed.youtubeSubgenAudioFormat = invocations.ytInvocation.ytSubgenAudioFormat;
|
||||
}
|
||||
if (invocations.ytInvocation.target) ensureTarget(invocations.ytInvocation.target, parsed);
|
||||
}
|
||||
|
||||
if (invocations.doctorLogLevel) parsed.logLevel = parseLogLevel(invocations.doctorLogLevel);
|
||||
if (invocations.texthookerLogLevel)
|
||||
parsed.logLevel = parseLogLevel(invocations.texthookerLogLevel);
|
||||
|
||||
if (invocations.configInvocation) {
|
||||
if (invocations.configInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.configInvocation.logLevel);
|
||||
}
|
||||
const action = (invocations.configInvocation.action || 'path').toLowerCase();
|
||||
if (action === 'path') parsed.configPath = true;
|
||||
else if (action === 'show') parsed.configShow = true;
|
||||
else fail(`Unknown config action: ${invocations.configInvocation.action}`);
|
||||
}
|
||||
|
||||
if (invocations.mpvInvocation) {
|
||||
if (invocations.mpvInvocation.logLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.mpvInvocation.logLevel);
|
||||
}
|
||||
const action = (invocations.mpvInvocation.action || 'status').toLowerCase();
|
||||
if (action === 'status') parsed.mpvStatus = true;
|
||||
else if (action === 'socket') parsed.mpvSocket = true;
|
||||
else if (action === 'idle' || action === 'start') parsed.mpvIdle = true;
|
||||
else fail(`Unknown mpv action: ${invocations.mpvInvocation.action}`);
|
||||
}
|
||||
|
||||
if (invocations.appInvocation) {
|
||||
parsed.appPassthrough = true;
|
||||
parsed.appArgs = invocations.appInvocation.appArgs;
|
||||
}
|
||||
}
|
||||
294
launcher/config/cli-parser-builder.ts
Normal file
294
launcher/config/cli-parser-builder.ts
Normal file
@@ -0,0 +1,294 @@
|
||||
import { Command } from 'commander';
|
||||
|
||||
export interface JellyfinInvocation {
|
||||
action?: string;
|
||||
discovery?: boolean;
|
||||
play?: boolean;
|
||||
login?: boolean;
|
||||
logout?: boolean;
|
||||
setup?: boolean;
|
||||
server?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
logLevel?: string;
|
||||
}
|
||||
|
||||
export interface YtInvocation {
|
||||
target?: string;
|
||||
mode?: string;
|
||||
outDir?: string;
|
||||
keepTemp?: boolean;
|
||||
whisperBin?: string;
|
||||
whisperModel?: string;
|
||||
ytSubgenAudioFormat?: string;
|
||||
logLevel?: string;
|
||||
}
|
||||
|
||||
export interface CommandActionInvocation {
|
||||
action: string;
|
||||
logLevel?: string;
|
||||
}
|
||||
|
||||
export interface CliInvocations {
|
||||
jellyfinInvocation: JellyfinInvocation | null;
|
||||
ytInvocation: YtInvocation | null;
|
||||
configInvocation: CommandActionInvocation | null;
|
||||
mpvInvocation: CommandActionInvocation | null;
|
||||
appInvocation: { appArgs: string[] } | null;
|
||||
doctorTriggered: boolean;
|
||||
doctorLogLevel: string | null;
|
||||
texthookerTriggered: boolean;
|
||||
texthookerLogLevel: string | null;
|
||||
}
|
||||
|
||||
function applyRootOptions(program: Command): void {
|
||||
program
|
||||
.option('-b, --backend <backend>', 'Display backend')
|
||||
.option('-d, --directory <dir>', 'Directory to browse')
|
||||
.option('-r, --recursive', 'Search directories recursively')
|
||||
.option('-p, --profile <profile>', 'MPV profile')
|
||||
.option('--start', 'Explicitly start overlay')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.option('-R, --rofi', 'Use rofi picker')
|
||||
.option('-S, --start-overlay', 'Auto-start overlay')
|
||||
.option('-T, --no-texthooker', 'Disable texthooker-ui server');
|
||||
}
|
||||
|
||||
function buildSubcommandHelpText(program: Command): string {
|
||||
const subcommands = program.commands
|
||||
.filter((command) => command.name() !== 'help')
|
||||
.map((command) => {
|
||||
const aliases = command.aliases();
|
||||
const term = aliases.length > 0 ? `${command.name()}|${aliases[0]}` : command.name();
|
||||
return { term, description: command.description() };
|
||||
});
|
||||
|
||||
if (subcommands.length === 0) return '';
|
||||
const longestTerm = Math.max(...subcommands.map((entry) => entry.term.length));
|
||||
const lines = subcommands.map((entry) =>
|
||||
` ${entry.term.padEnd(longestTerm)} ${entry.description || ''}`.trimEnd(),
|
||||
);
|
||||
return `\nCommands:\n${lines.join('\n')}\n`;
|
||||
}
|
||||
|
||||
function getTopLevelCommand(argv: string[]): { name: string; index: number } | null {
|
||||
const commandNames = new Set([
|
||||
'jellyfin',
|
||||
'jf',
|
||||
'yt',
|
||||
'youtube',
|
||||
'doctor',
|
||||
'config',
|
||||
'mpv',
|
||||
'texthooker',
|
||||
'app',
|
||||
'bin',
|
||||
'help',
|
||||
]);
|
||||
const optionsWithValue = new Set([
|
||||
'-b',
|
||||
'--backend',
|
||||
'-d',
|
||||
'--directory',
|
||||
'-p',
|
||||
'--profile',
|
||||
'--log-level',
|
||||
]);
|
||||
for (let i = 0; i < argv.length; i += 1) {
|
||||
const token = argv[i] || '';
|
||||
if (token === '--') return null;
|
||||
if (token.startsWith('-')) {
|
||||
if (optionsWithValue.has(token)) i += 1;
|
||||
continue;
|
||||
}
|
||||
return commandNames.has(token) ? { name: token, index: i } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasTopLevelCommand(argv: string[]): boolean {
|
||||
return getTopLevelCommand(argv) !== null;
|
||||
}
|
||||
|
||||
export function resolveTopLevelCommand(argv: string[]): { name: string; index: number } | null {
|
||||
return getTopLevelCommand(argv);
|
||||
}
|
||||
|
||||
export function parseCliPrograms(
|
||||
argv: string[],
|
||||
scriptName: string,
|
||||
): {
|
||||
options: Record<string, unknown>;
|
||||
rootTarget: unknown;
|
||||
invocations: CliInvocations;
|
||||
} {
|
||||
let jellyfinInvocation: JellyfinInvocation | null = null;
|
||||
let ytInvocation: YtInvocation | null = null;
|
||||
let configInvocation: CommandActionInvocation | null = null;
|
||||
let mpvInvocation: CommandActionInvocation | null = null;
|
||||
let appInvocation: { appArgs: string[] } | null = null;
|
||||
let doctorLogLevel: string | null = null;
|
||||
let texthookerLogLevel: string | null = null;
|
||||
let doctorTriggered = false;
|
||||
let texthookerTriggered = false;
|
||||
|
||||
const commandProgram = new Command();
|
||||
commandProgram
|
||||
.name(scriptName)
|
||||
.description('Launch MPV with SubMiner sentence mining overlay')
|
||||
.showHelpAfterError(true)
|
||||
.enablePositionalOptions()
|
||||
.allowExcessArguments(false)
|
||||
.allowUnknownOption(false)
|
||||
.exitOverride();
|
||||
applyRootOptions(commandProgram);
|
||||
|
||||
const rootProgram = new Command();
|
||||
rootProgram
|
||||
.name(scriptName)
|
||||
.description('Launch MPV with SubMiner sentence mining overlay')
|
||||
.usage('[options] [command] [target]')
|
||||
.showHelpAfterError(true)
|
||||
.allowExcessArguments(false)
|
||||
.allowUnknownOption(false)
|
||||
.exitOverride()
|
||||
.argument('[target]', 'file, directory, or URL');
|
||||
applyRootOptions(rootProgram);
|
||||
|
||||
commandProgram
|
||||
.command('jellyfin')
|
||||
.alias('jf')
|
||||
.description('Jellyfin workflows')
|
||||
.argument('[action]', 'setup|discovery|play|login|logout')
|
||||
.option('-d, --discovery', 'Cast discovery mode')
|
||||
.option('-p, --play', 'Interactive play picker')
|
||||
.option('-l, --login', 'Login flow')
|
||||
.option('--logout', 'Clear token/session')
|
||||
.option('--setup', 'Open setup window')
|
||||
.option('-s, --server <url>', 'Jellyfin server URL')
|
||||
.option('-u, --username <name>', 'Jellyfin username')
|
||||
.option('-w, --password <pass>', 'Jellyfin password')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string | undefined, options: Record<string, unknown>) => {
|
||||
jellyfinInvocation = {
|
||||
action,
|
||||
discovery: options.discovery === true,
|
||||
play: options.play === true,
|
||||
login: options.login === true,
|
||||
logout: options.logout === true,
|
||||
setup: options.setup === true,
|
||||
server: typeof options.server === 'string' ? options.server : undefined,
|
||||
username: typeof options.username === 'string' ? options.username : undefined,
|
||||
password: typeof options.password === 'string' ? options.password : undefined,
|
||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('yt')
|
||||
.alias('youtube')
|
||||
.description('YouTube workflows')
|
||||
.argument('[target]', 'YouTube URL or ytsearch: query')
|
||||
.option('-m, --mode <mode>', 'Subtitle generation mode')
|
||||
.option('-o, --out-dir <dir>', 'Subtitle output dir')
|
||||
.option('--keep-temp', 'Keep temp files')
|
||||
.option('--whisper-bin <path>', 'whisper.cpp CLI path')
|
||||
.option('--whisper-model <path>', 'whisper model path')
|
||||
.option('--yt-subgen-audio-format <format>', 'Audio extraction format')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((target: string | undefined, options: Record<string, unknown>) => {
|
||||
ytInvocation = {
|
||||
target,
|
||||
mode: typeof options.mode === 'string' ? options.mode : undefined,
|
||||
outDir: typeof options.outDir === 'string' ? options.outDir : undefined,
|
||||
keepTemp: options.keepTemp === true,
|
||||
whisperBin: typeof options.whisperBin === 'string' ? options.whisperBin : undefined,
|
||||
whisperModel: typeof options.whisperModel === 'string' ? options.whisperModel : undefined,
|
||||
ytSubgenAudioFormat:
|
||||
typeof options.ytSubgenAudioFormat === 'string' ? options.ytSubgenAudioFormat : undefined,
|
||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('doctor')
|
||||
.description('Run dependency and environment checks')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((options: Record<string, unknown>) => {
|
||||
doctorTriggered = true;
|
||||
doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('config')
|
||||
.description('Config helpers')
|
||||
.argument('[action]', 'path|show', 'path')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string, options: Record<string, unknown>) => {
|
||||
configInvocation = {
|
||||
action,
|
||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('mpv')
|
||||
.description('MPV helpers')
|
||||
.argument('[action]', 'status|socket|idle', 'status')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((action: string, options: Record<string, unknown>) => {
|
||||
mpvInvocation = {
|
||||
action,
|
||||
logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('texthooker')
|
||||
.description('Launch texthooker-only mode')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((options: Record<string, unknown>) => {
|
||||
texthookerTriggered = true;
|
||||
texthookerLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('app')
|
||||
.alias('bin')
|
||||
.description('Pass arguments directly to SubMiner binary')
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument('[appArgs...]', 'Arguments forwarded to SubMiner app binary')
|
||||
.action((appArgs: string[] | undefined) => {
|
||||
appInvocation = { appArgs: Array.isArray(appArgs) ? appArgs : [] };
|
||||
});
|
||||
|
||||
rootProgram.addHelpText('after', buildSubcommandHelpText(commandProgram));
|
||||
|
||||
const selectedProgram = hasTopLevelCommand(argv) ? commandProgram : rootProgram;
|
||||
try {
|
||||
selectedProgram.parse(['node', scriptName, ...argv]);
|
||||
} catch (error) {
|
||||
const commanderError = error as { code?: string; message?: string };
|
||||
if (commanderError?.code === 'commander.helpDisplayed') {
|
||||
process.exit(0);
|
||||
}
|
||||
throw new Error(commanderError?.message || String(error));
|
||||
}
|
||||
|
||||
return {
|
||||
options: selectedProgram.opts<Record<string, unknown>>(),
|
||||
rootTarget: rootProgram.processedArgs[0],
|
||||
invocations: {
|
||||
jellyfinInvocation,
|
||||
ytInvocation,
|
||||
configInvocation,
|
||||
mpvInvocation,
|
||||
appInvocation,
|
||||
doctorTriggered,
|
||||
doctorLogLevel,
|
||||
texthookerTriggered,
|
||||
texthookerLogLevel,
|
||||
},
|
||||
};
|
||||
}
|
||||
16
launcher/config/jellyfin-config.ts
Normal file
16
launcher/config/jellyfin-config.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { LauncherJellyfinConfig } from '../types.js';
|
||||
|
||||
export function parseLauncherJellyfinConfig(root: Record<string, unknown>): LauncherJellyfinConfig {
|
||||
const jellyfinRaw = root.jellyfin;
|
||||
if (!jellyfinRaw || typeof jellyfinRaw !== 'object') return {};
|
||||
const jellyfin = jellyfinRaw as Record<string, unknown>;
|
||||
return {
|
||||
enabled: typeof jellyfin.enabled === 'boolean' ? jellyfin.enabled : undefined,
|
||||
serverUrl: typeof jellyfin.serverUrl === 'string' ? jellyfin.serverUrl : undefined,
|
||||
username: typeof jellyfin.username === 'string' ? jellyfin.username : undefined,
|
||||
defaultLibraryId:
|
||||
typeof jellyfin.defaultLibraryId === 'string' ? jellyfin.defaultLibraryId : undefined,
|
||||
pullPictures: typeof jellyfin.pullPictures === 'boolean' ? jellyfin.pullPictures : undefined,
|
||||
iconCacheDir: typeof jellyfin.iconCacheDir === 'string' ? jellyfin.iconCacheDir : undefined,
|
||||
};
|
||||
}
|
||||
57
launcher/config/plugin-runtime-config.ts
Normal file
57
launcher/config/plugin-runtime-config.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { log } from '../log.js';
|
||||
import type { LogLevel, PluginRuntimeConfig } from '../types.js';
|
||||
import { DEFAULT_SOCKET_PATH } from '../types.js';
|
||||
|
||||
export function getPluginConfigCandidates(): string[] {
|
||||
const xdgConfigHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
||||
return Array.from(
|
||||
new Set([
|
||||
path.join(xdgConfigHome, 'mpv', 'script-opts', 'subminer.conf'),
|
||||
path.join(os.homedir(), '.config', 'mpv', 'script-opts', 'subminer.conf'),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
export function parsePluginRuntimeConfigContent(content: string): PluginRuntimeConfig {
|
||||
const runtimeConfig: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length === 0 || trimmed.startsWith('#')) continue;
|
||||
const socketMatch = trimmed.match(/^socket_path\s*=\s*(.+)$/i);
|
||||
if (!socketMatch) continue;
|
||||
const value = (socketMatch[1] || '').split('#', 1)[0]?.trim() || '';
|
||||
if (value) runtimeConfig.socketPath = value;
|
||||
}
|
||||
return runtimeConfig;
|
||||
}
|
||||
|
||||
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
|
||||
const candidates = getPluginConfigCandidates();
|
||||
const defaults: PluginRuntimeConfig = { socketPath: DEFAULT_SOCKET_PATH };
|
||||
|
||||
for (const configPath of candidates) {
|
||||
if (!fs.existsSync(configPath)) continue;
|
||||
try {
|
||||
const parsed = parsePluginRuntimeConfigContent(fs.readFileSync(configPath, 'utf8'));
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`Using mpv plugin settings from ${configPath}: socket_path=${parsed.socketPath}`,
|
||||
);
|
||||
return parsed;
|
||||
} catch {
|
||||
log('warn', logLevel, `Failed to read ${configPath}; using launcher defaults`);
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
'debug',
|
||||
logLevel,
|
||||
`No mpv subminer.conf found; using launcher defaults (socket_path=${defaults.socketPath})`,
|
||||
);
|
||||
return defaults;
|
||||
}
|
||||
25
launcher/config/shared-config-reader.ts
Normal file
25
launcher/config/shared-config-reader.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import { parse as parseJsonc } from 'jsonc-parser';
|
||||
import { resolveConfigFilePath } from '../../src/config/path-resolution.js';
|
||||
|
||||
export function resolveLauncherMainConfigPath(): string {
|
||||
return resolveConfigFilePath({
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
existsSync: fs.existsSync,
|
||||
});
|
||||
}
|
||||
|
||||
export function readLauncherMainConfigObject(): Record<string, unknown> | null {
|
||||
const configPath = resolveLauncherMainConfigPath();
|
||||
if (!fs.existsSync(configPath)) return null;
|
||||
try {
|
||||
const data = fs.readFileSync(configPath, 'utf8');
|
||||
const parsed = configPath.endsWith('.jsonc') ? parseJsonc(data) : JSON.parse(data);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
return parsed as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
54
launcher/config/youtube-subgen-config.ts
Normal file
54
launcher/config/youtube-subgen-config.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type { LauncherYoutubeSubgenConfig } from '../types.js';
|
||||
|
||||
function asStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
return value.filter((entry): entry is string => typeof entry === 'string');
|
||||
}
|
||||
|
||||
export function parseLauncherYoutubeSubgenConfig(
|
||||
root: Record<string, unknown>,
|
||||
): LauncherYoutubeSubgenConfig {
|
||||
const youtubeSubgenRaw = root.youtubeSubgen;
|
||||
const youtubeSubgen =
|
||||
youtubeSubgenRaw && typeof youtubeSubgenRaw === 'object'
|
||||
? (youtubeSubgenRaw as Record<string, unknown>)
|
||||
: null;
|
||||
const secondarySubRaw = root.secondarySub;
|
||||
const secondarySub =
|
||||
secondarySubRaw && typeof secondarySubRaw === 'object'
|
||||
? (secondarySubRaw as Record<string, unknown>)
|
||||
: null;
|
||||
const jimakuRaw = root.jimaku;
|
||||
const jimaku =
|
||||
jimakuRaw && typeof jimakuRaw === 'object' ? (jimakuRaw as Record<string, unknown>) : null;
|
||||
|
||||
const mode = youtubeSubgen?.mode;
|
||||
const jimakuLanguagePreference = jimaku?.languagePreference;
|
||||
const jimakuMaxEntryResults = jimaku?.maxEntryResults;
|
||||
|
||||
return {
|
||||
mode: mode === 'automatic' || mode === 'preprocess' || mode === 'off' ? mode : undefined,
|
||||
whisperBin:
|
||||
typeof youtubeSubgen?.whisperBin === 'string' ? youtubeSubgen.whisperBin : undefined,
|
||||
whisperModel:
|
||||
typeof youtubeSubgen?.whisperModel === 'string' ? youtubeSubgen.whisperModel : undefined,
|
||||
primarySubLanguages: asStringArray(youtubeSubgen?.primarySubLanguages),
|
||||
secondarySubLanguages: asStringArray(secondarySub?.secondarySubLanguages),
|
||||
jimakuApiKey: typeof jimaku?.apiKey === 'string' ? jimaku.apiKey : undefined,
|
||||
jimakuApiKeyCommand:
|
||||
typeof jimaku?.apiKeyCommand === 'string' ? jimaku.apiKeyCommand : undefined,
|
||||
jimakuApiBaseUrl: typeof jimaku?.apiBaseUrl === 'string' ? jimaku.apiBaseUrl : undefined,
|
||||
jimakuLanguagePreference:
|
||||
jimakuLanguagePreference === 'ja' ||
|
||||
jimakuLanguagePreference === 'en' ||
|
||||
jimakuLanguagePreference === 'none'
|
||||
? jimakuLanguagePreference
|
||||
: undefined,
|
||||
jimakuMaxEntryResults:
|
||||
typeof jimakuMaxEntryResults === 'number' &&
|
||||
Number.isFinite(jimakuMaxEntryResults) &&
|
||||
jimakuMaxEntryResults > 0
|
||||
? Math.floor(jimakuMaxEntryResults)
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
399
launcher/jellyfin.ts
Normal file
399
launcher/jellyfin.ts
Normal 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
497
launcher/jimaku.ts
Normal 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
59
launcher/log.ts
Normal 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
209
launcher/main.test.ts
Normal 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
101
launcher/main.ts
Normal 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
61
launcher/mpv.test.ts
Normal 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
708
launcher/mpv.ts
Normal 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;
|
||||
}
|
||||
45
launcher/parse-args.test.ts
Normal file
45
launcher/parse-args.test.ts
Normal 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
487
launcher/picker.ts
Normal 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);
|
||||
}
|
||||
21
launcher/process-adapter.ts
Normal file
21
launcher/process-adapter.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface ProcessAdapter {
|
||||
platform(): NodeJS.Platform;
|
||||
onSignal(signal: NodeJS.Signals, handler: () => void): void;
|
||||
writeStdout(text: string): void;
|
||||
exit(code: number): never;
|
||||
setExitCode(code: number): void;
|
||||
}
|
||||
|
||||
export const nodeProcessAdapter: ProcessAdapter = {
|
||||
platform: () => process.platform,
|
||||
onSignal: (signal, handler) => {
|
||||
process.on(signal, handler);
|
||||
},
|
||||
writeStdout: (text) => {
|
||||
process.stdout.write(text);
|
||||
},
|
||||
exit: (code) => process.exit(code),
|
||||
setExitCode: (code) => {
|
||||
process.exitCode = code;
|
||||
},
|
||||
};
|
||||
304
launcher/smoke.e2e.test.ts
Normal file
304
launcher/smoke.e2e.test.ts
Normal 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
196
launcher/types.ts
Normal 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
213
launcher/util.ts
Normal 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
467
launcher/youtube.ts
Normal 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
73
plugin/subminer.conf
Normal 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
1959
plugin/subminer.lua
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user