From 4ebabbe6395de8e5146663a86034b4fcb99e814b Mon Sep 17 00:00:00 2001 From: sudacode Date: Sun, 22 Feb 2026 21:43:43 -0800 Subject: [PATCH] chore: add project management metadata and remaining repository files --- backlog/config.yml | 14 + launcher/aniskip-metadata.test.ts | 75 + launcher/aniskip-metadata.ts | 196 +++ launcher/commands/app-command.ts | 20 + launcher/commands/command-modules.test.ts | 90 + launcher/commands/config-command.ts | 43 + launcher/commands/context.ts | 12 + launcher/commands/doctor-command.ts | 85 + launcher/commands/jellyfin-command.ts | 71 + launcher/commands/mpv-command.ts | 62 + launcher/commands/playback-command.ts | 208 +++ launcher/config-domain-parsers.test.ts | 60 + launcher/config-path.ts | 11 + launcher/config.test.ts | 21 + launcher/config.ts | 61 + launcher/config/args-normalizer.ts | 257 +++ launcher/config/cli-parser-builder.ts | 294 ++++ launcher/config/jellyfin-config.ts | 16 + launcher/config/plugin-runtime-config.ts | 57 + launcher/config/shared-config-reader.ts | 25 + launcher/config/youtube-subgen-config.ts | 54 + launcher/jellyfin.ts | 399 +++++ launcher/jimaku.ts | 497 ++++++ launcher/log.ts | 59 + launcher/main.test.ts | 209 +++ launcher/main.ts | 101 ++ launcher/mpv.test.ts | 61 + launcher/mpv.ts | 708 ++++++++ launcher/parse-args.test.ts | 45 + launcher/picker.ts | 487 +++++ launcher/process-adapter.ts | 21 + launcher/smoke.e2e.test.ts | 304 ++++ launcher/types.ts | 196 +++ launcher/util.ts | 213 +++ launcher/youtube.ts | 467 +++++ plugin/subminer.conf | 73 + plugin/subminer.lua | 1959 +++++++++++++++++++++ 37 files changed, 7531 insertions(+) create mode 100644 backlog/config.yml create mode 100644 launcher/aniskip-metadata.test.ts create mode 100644 launcher/aniskip-metadata.ts create mode 100644 launcher/commands/app-command.ts create mode 100644 launcher/commands/command-modules.test.ts create mode 100644 launcher/commands/config-command.ts create mode 100644 launcher/commands/context.ts create mode 100644 launcher/commands/doctor-command.ts create mode 100644 launcher/commands/jellyfin-command.ts create mode 100644 launcher/commands/mpv-command.ts create mode 100644 launcher/commands/playback-command.ts create mode 100644 launcher/config-domain-parsers.test.ts create mode 100644 launcher/config-path.ts create mode 100644 launcher/config.test.ts create mode 100644 launcher/config.ts create mode 100644 launcher/config/args-normalizer.ts create mode 100644 launcher/config/cli-parser-builder.ts create mode 100644 launcher/config/jellyfin-config.ts create mode 100644 launcher/config/plugin-runtime-config.ts create mode 100644 launcher/config/shared-config-reader.ts create mode 100644 launcher/config/youtube-subgen-config.ts create mode 100644 launcher/jellyfin.ts create mode 100644 launcher/jimaku.ts create mode 100644 launcher/log.ts create mode 100644 launcher/main.test.ts create mode 100644 launcher/main.ts create mode 100644 launcher/mpv.test.ts create mode 100644 launcher/mpv.ts create mode 100644 launcher/parse-args.test.ts create mode 100644 launcher/picker.ts create mode 100644 launcher/process-adapter.ts create mode 100644 launcher/smoke.e2e.test.ts create mode 100644 launcher/types.ts create mode 100644 launcher/util.ts create mode 100644 launcher/youtube.ts create mode 100644 plugin/subminer.conf create mode 100644 plugin/subminer.lua diff --git a/backlog/config.yml b/backlog/config.yml new file mode 100644 index 0000000..d07eca0 --- /dev/null +++ b/backlog/config.yml @@ -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" diff --git a/launcher/aniskip-metadata.test.ts b/launcher/aniskip-metadata.test.ts new file mode 100644 index 0000000..0404947 --- /dev/null +++ b/launcher/aniskip-metadata.test.ts @@ -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/); +}); diff --git a/launcher/aniskip-metadata.ts b/launcher/aniskip-metadata.ts new file mode 100644 index 0000000..ce1a8cc --- /dev/null +++ b/launcher/aniskip-metadata.ts @@ -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(','); +} diff --git a/launcher/commands/app-command.ts b/launcher/commands/app-command.ts new file mode 100644 index 0000000..62a49ac --- /dev/null +++ b/launcher/commands/app-command.ts @@ -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; +} diff --git a/launcher/commands/command-modules.test.ts b/launcher/commands/command-modules.test.ts new file mode 100644 index 0000000..f629d2d --- /dev/null +++ b/launcher/commands/command-modules.test.ts @@ -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 { + 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, + ); +}); diff --git a/launcher/commands/config-command.ts b/launcher/commands/config-command.ts new file mode 100644 index 0000000..b4faabe --- /dev/null +++ b/launcher/commands/config-command.ts @@ -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; +} diff --git a/launcher/commands/context.ts b/launcher/commands/context.ts new file mode 100644 index 0000000..44db33d --- /dev/null +++ b/launcher/commands/context.ts @@ -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; +} diff --git a/launcher/commands/doctor-command.ts b/launcher/commands/doctor-command.ts new file mode 100644 index 0000000..b070ab9 --- /dev/null +++ b/launcher/commands/doctor-command.ts @@ -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; +} diff --git a/launcher/commands/jellyfin-command.ts b/launcher/commands/jellyfin-command.ts new file mode 100644 index 0000000..d05d22a --- /dev/null +++ b/launcher/commands/jellyfin-command.ts @@ -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 { + 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, + ); +} diff --git a/launcher/commands/mpv-command.ts b/launcher/commands/mpv-command.ts new file mode 100644 index 0000000..96445a9 --- /dev/null +++ b/launcher/commands/mpv-command.ts @@ -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; + launchMpvIdleDetached( + socketPath: string, + appPath: string, + args: LauncherCommandContext['args'], + ): Promise; +} + +const defaultDeps: MpvCommandDeps = { + waitForUnixSocketReady, + launchMpvIdleDetached, +}; + +export async function runMpvPreAppCommand( + context: LauncherCommandContext, + deps: MpvCommandDeps = defaultDeps, +): Promise { + 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 { + 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; +} diff --git a/launcher/commands/playback-command.ts b/launcher/commands/playback-command.ts new file mode 100644 index 0000000..e1c28eb --- /dev/null +++ b/launcher/commands/playback-command.ts @@ -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 { + 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((resolve) => { + if (!state.mpvProc) { + stopOverlay(args); + resolve(); + return; + } + state.mpvProc.on('exit', (code) => { + stopOverlay(args); + processAdapter.setExitCode(code ?? 0); + resolve(); + }); + }); +} diff --git a/launcher/config-domain-parsers.test.ts b/launcher/config-domain-parsers.test.ts new file mode 100644 index 0000000..fecb9f0 --- /dev/null +++ b/launcher/config-domain-parsers.test.ts @@ -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'); +}); diff --git a/launcher/config-path.ts b/launcher/config-path.ts new file mode 100644 index 0000000..33786af --- /dev/null +++ b/launcher/config-path.ts @@ -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, + }); +} diff --git a/launcher/config.test.ts b/launcher/config.test.ts new file mode 100644 index 0000000..a559df2 --- /dev/null +++ b/launcher/config.test.ts @@ -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/); +}); diff --git a/launcher/config.ts b/launcher/config.ts new file mode 100644 index 0000000..5cbc196 --- /dev/null +++ b/launcher/config.ts @@ -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; + 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; +} diff --git a/launcher/config/args-normalizer.ts b/launcher/config/args-normalizer.ts new file mode 100644 index 0000000..3408b81 --- /dev/null +++ b/launcher/config/args-normalizer.ts @@ -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, + 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; + } +} diff --git a/launcher/config/cli-parser-builder.ts b/launcher/config/cli-parser-builder.ts new file mode 100644 index 0000000..f486f7a --- /dev/null +++ b/launcher/config/cli-parser-builder.ts @@ -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 ', 'Display backend') + .option('-d, --directory ', 'Directory to browse') + .option('-r, --recursive', 'Search directories recursively') + .option('-p, --profile ', 'MPV profile') + .option('--start', 'Explicitly start overlay') + .option('--log-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; + 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 ', 'Jellyfin server URL') + .option('-u, --username ', 'Jellyfin username') + .option('-w, --password ', 'Jellyfin password') + .option('--log-level ', 'Log level') + .action((action: string | undefined, options: Record) => { + 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 ', 'Subtitle generation mode') + .option('-o, --out-dir ', 'Subtitle output dir') + .option('--keep-temp', 'Keep temp files') + .option('--whisper-bin ', 'whisper.cpp CLI path') + .option('--whisper-model ', 'whisper model path') + .option('--yt-subgen-audio-format ', 'Audio extraction format') + .option('--log-level ', 'Log level') + .action((target: string | undefined, options: Record) => { + 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 ', 'Log level') + .action((options: Record) => { + doctorTriggered = true; + doctorLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null; + }); + + commandProgram + .command('config') + .description('Config helpers') + .argument('[action]', 'path|show', 'path') + .option('--log-level ', 'Log level') + .action((action: string, options: Record) => { + 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 ', 'Log level') + .action((action: string, options: Record) => { + mpvInvocation = { + action, + logLevel: typeof options.logLevel === 'string' ? options.logLevel : undefined, + }; + }); + + commandProgram + .command('texthooker') + .description('Launch texthooker-only mode') + .option('--log-level ', 'Log level') + .action((options: Record) => { + 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>(), + rootTarget: rootProgram.processedArgs[0], + invocations: { + jellyfinInvocation, + ytInvocation, + configInvocation, + mpvInvocation, + appInvocation, + doctorTriggered, + doctorLogLevel, + texthookerTriggered, + texthookerLogLevel, + }, + }; +} diff --git a/launcher/config/jellyfin-config.ts b/launcher/config/jellyfin-config.ts new file mode 100644 index 0000000..19d75c9 --- /dev/null +++ b/launcher/config/jellyfin-config.ts @@ -0,0 +1,16 @@ +import type { LauncherJellyfinConfig } from '../types.js'; + +export function parseLauncherJellyfinConfig(root: Record): LauncherJellyfinConfig { + const jellyfinRaw = root.jellyfin; + if (!jellyfinRaw || typeof jellyfinRaw !== 'object') return {}; + const jellyfin = jellyfinRaw as Record; + 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, + }; +} diff --git a/launcher/config/plugin-runtime-config.ts b/launcher/config/plugin-runtime-config.ts new file mode 100644 index 0000000..13baabe --- /dev/null +++ b/launcher/config/plugin-runtime-config.ts @@ -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; +} diff --git a/launcher/config/shared-config-reader.ts b/launcher/config/shared-config-reader.ts new file mode 100644 index 0000000..3d9228b --- /dev/null +++ b/launcher/config/shared-config-reader.ts @@ -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 | 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; + } catch { + return null; + } +} diff --git a/launcher/config/youtube-subgen-config.ts b/launcher/config/youtube-subgen-config.ts new file mode 100644 index 0000000..bfe7c34 --- /dev/null +++ b/launcher/config/youtube-subgen-config.ts @@ -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, +): LauncherYoutubeSubgenConfig { + const youtubeSubgenRaw = root.youtubeSubgen; + const youtubeSubgen = + youtubeSubgenRaw && typeof youtubeSubgenRaw === 'object' + ? (youtubeSubgenRaw as Record) + : null; + const secondarySubRaw = root.secondarySub; + const secondarySub = + secondarySubRaw && typeof secondarySubRaw === 'object' + ? (secondarySubRaw as Record) + : null; + const jimakuRaw = root.jimaku; + const jimaku = + jimakuRaw && typeof jimakuRaw === 'object' ? (jimakuRaw as Record) : 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, + }; +} diff --git a/launcher/jellyfin.ts b/launcher/jellyfin.ts new file mode 100644 index 0000000..61b1711 --- /dev/null +++ b/launcher/jellyfin.ts @@ -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( + session: JellyfinSessionConfig, + requestPath: string, +): Promise { + 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 { + 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 { + 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> }>( + 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> = []; + let startIndex = 0; + while (true) { + const payload = await jellyfinApiRequest<{ + Items?: Array>; + 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>; + 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> = []; + 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 { + 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'); +} diff --git a/launcher/jimaku.ts b/launcher/jimaku.ts new file mode 100644 index 0000000..a4f9419 --- /dev/null +++ b/launcher/jimaku.ts @@ -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 = { 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; + }; + 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 { + 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( + endpoint: string, + query: Record, + options: { baseUrl: string; apiKey: string }, +): Promise> { + 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, + redirectCount = 0, +): Promise { + 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, + }; +} diff --git a/launcher/log.ts b/launcher/log.ts new file mode 100644 index 0000000..10aca67 --- /dev/null +++ b/launcher/log.ts @@ -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 = { + 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); +} diff --git a/launcher/main.test.ts b/launcher/main.test.ts new file mode 100644 index 0000000..121c6bb --- /dev/null +++ b/launcher/main.test.ts @@ -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(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', + ); + }); +}); diff --git a/launcher/main.ts b/launcher/main.ts new file mode 100644 index 0000000..197c32f --- /dev/null +++ b/launcher/main.ts @@ -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, + 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 { + 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); +}); diff --git a/launcher/mpv.test.ts b/launcher/mpv.test.ts new file mode 100644 index 0000000..425a3ff --- /dev/null +++ b/launcher/mpv.test.ts @@ -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 }); + } +}); diff --git a/launcher/mpv.ts b/launcher/mpv.ts new file mode 100644 index 0000000..5e06471 --- /dev/null +++ b/launcher/mpv.ts @@ -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 | null, + mpvProc: null as ReturnType | null, + youtubeSubgenChildren: new Set>(), + 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 { + 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 { + 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 { + 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 { + 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 { + 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; + return candidate.type === 'sub'; + }) + .map((track) => { + const candidate = track as Record; + 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 { + 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 { + 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 { + 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 = { + ...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 { + 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 { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function canConnectUnixSocket(socketPath: string): Promise { + return await new Promise((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 { + 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; +} diff --git a/launcher/parse-args.test.ts b/launcher/parse-args.test.ts new file mode 100644 index 0000000..1b921db --- /dev/null +++ b/launcher/parse-args.test.ts @@ -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); +}); diff --git a/launcher/picker.ts b/launcher/picker.ts new file mode 100644 index 0000000..7c00914 --- /dev/null +++ b/launcher/picker.ts @@ -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 { + 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((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); +} diff --git a/launcher/process-adapter.ts b/launcher/process-adapter.ts new file mode 100644 index 0000000..58b3ff6 --- /dev/null +++ b/launcher/process-adapter.ts @@ -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; + }, +}; diff --git a/launcher/smoke.e2e.test.ts b/launcher/smoke.e2e.test.ts new file mode 100644 index 0000000..4135e7d --- /dev/null +++ b/launcher/smoke.e2e.test.ts @@ -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, +): Promise { + 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> { + 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); +} + +async function waitForJsonLines( + filePath: string, + minCount: number, + timeoutMs = 1500, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (readJsonLines(filePath).length >= minCount) { + return; + } + await new Promise((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((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((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); + }); + }, +); diff --git a/launcher/types.ts b/launcher/types.ts new file mode 100644 index 0000000..c0d217a --- /dev/null +++ b/launcher/types.ts @@ -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; +} diff --git a/launcher/util.ts b/launcher/util.ts new file mode 100644 index 0000000..ed30272 --- /dev/null +++ b/launcher/util.ts @@ -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 { + 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(); + 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>, +): Promise { + 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 }); + }); + }); +} diff --git a/launcher/youtube.ts b/launcher/youtube.ts new file mode 100644 index 0000000..f917a4c --- /dev/null +++ b/launcher/youtube.ts @@ -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, + 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 { + 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 { + 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 { + 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, +): Promise { + 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(); + let keepTemp = args.youtubeSubgenKeepTemp; + + const publishTrack = async ( + lang: 'primary' | 'secondary', + source: SubtitleCandidate['source'], + selectedPath: string, + basename: string, + ): Promise => { + 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 + } + } + } +} diff --git a/plugin/subminer.conf b/plugin/subminer.conf new file mode 100644 index 0000000..ec489d8 --- /dev/null +++ b/plugin/subminer.conf @@ -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 diff --git a/plugin/subminer.lua b/plugin/subminer.lua new file mode 100644 index 0000000..d1a8649 --- /dev/null +++ b/plugin/subminer.lua @@ -0,0 +1,1959 @@ +local input = require("mp.input") +local mp = require("mp") +local msg = require("mp.msg") +local options = require("mp.options") +local utils = require("mp.utils") + +local function is_windows() + return package.config:sub(1, 1) == "\\" +end + +local function is_macos() + local platform = mp.get_property("platform") or "" + if platform == "macos" or platform == "darwin" then + return true + end + local ostype = os.getenv("OSTYPE") or "" + return ostype:find("darwin") ~= nil +end + +local function default_socket_path() + if is_windows() then + return "\\\\.\\pipe\\subminer-socket" + end + return "/tmp/subminer-socket" +end + +local function is_linux() + return not is_windows() and not is_macos() +end + +local function is_subminer_process_running() + local command = is_windows() and { "tasklist", "/FO", "CSV", "/NH" } or { "ps", "-A", "-o", "args=" } + local result = mp.command_native({ + name = "subprocess", + args = command, + playback_only = false, + capture_stdout = true, + capture_stderr = false, + }) + if not result or type(result.stdout) ~= "string" or result.status ~= 0 then + return false + end + + local process_list = result.stdout:lower() + for line in process_list:gmatch("[^\\n]+") do + if is_windows() then + local image = line:match('^"([^"]+)","') + if not image then + image = line:match("^\"([^\"]+)\"") + end + if not image then + goto continue + end + if image == "subminer" or image == "subminer.exe" or image == "subminer.appimage" or image == "subminer.app" then + return true + end + if image:find("subminer", 1, true) and not image:find(".lua", 1, true) then + return true + end + else + local argv0 = line:match('^"([^"]+)"') or line:match("^%s*([^%s]+)") + if not argv0 then + goto continue + end + if argv0:find("subminer.lua", 1, true) or argv0:find("subminer.conf", 1, true) then + goto continue + end + local exe = argv0:match("([^/\\]+)$") or argv0 + if exe == "SubMiner" or exe == "SubMiner.AppImage" or exe == "SubMiner.exe" or exe == "subminer" or exe == "subminer.appimage" or exe == "subminer.exe" then + return true + end + if exe:find("subminer", 1, true) and exe:find("%.lua", 1, true) == nil and exe:find("%.app", 1, true) == nil then + return true + end + end + + ::continue:: + end + return false +end + +local function is_subminer_app_running() + if is_subminer_process_running() then + return true + end + return false +end + +local function is_subminer_ipc_ready() + if not is_subminer_process_running() then + return false, "SubMiner process not running" + end + + if is_windows() then + return true, nil + end + + if opts.socket_path ~= default_socket_path() then + return false, "SubMiner socket path mismatch" + end + + if not file_exists(default_socket_path()) then + return false, "SubMiner IPC socket missing at /tmp/subminer-socket" + end + + return true, nil +end + +local function normalize_binary_path_candidate(candidate) + if type(candidate) ~= "string" then + return nil + end + local trimmed = candidate:match("^%s*(.-)%s*$") or "" + if trimmed == "" then + return nil + end + if #trimmed >= 2 then + local first = trimmed:sub(1, 1) + local last = trimmed:sub(-1) + if (first == '"' and last == '"') or (first == "'" and last == "'") then + trimmed = trimmed:sub(2, -2) + end + end + return trimmed ~= "" and trimmed or nil +end + +local function binary_candidates_from_app_path(app_path) + return { + utils.join_path(app_path, "Contents", "MacOS", "SubMiner"), + utils.join_path(app_path, "Contents", "MacOS", "subminer"), + } +end + +local opts = { + binary_path = "", + socket_path = default_socket_path(), + texthooker_enabled = true, + texthooker_port = 5174, + backend = "auto", + auto_start = true, + auto_start_overlay = false, -- legacy alias, maps to auto_start_visible_overlay + auto_start_visible_overlay = false, + auto_start_invisible_overlay = "platform-default", -- platform-default | visible | hidden + osd_messages = true, + log_level = "info", + aniskip_enabled = true, + aniskip_title = "", + aniskip_season = "", + aniskip_mal_id = "", + aniskip_episode = "", + aniskip_show_button = true, + aniskip_button_text = "You can skip by pressing %s", + aniskip_button_key = "y-k", + aniskip_button_duration = 3, +} + +options.read_options(opts, "subminer") + +local state = { + overlay_running = false, + texthooker_running = false, + overlay_process = nil, + binary_available = false, + binary_path = nil, + detected_backend = nil, + invisible_overlay_visible = false, + hover_highlight = { + revision = -1, + payload = nil, + saved_sub_visibility = nil, + saved_secondary_sub_visibility = nil, + overlay_active = false, + cached_ass = nil, + clear_timer = nil, + last_hover_update_ts = 0, + }, + aniskip = { + mal_id = nil, + title = nil, + episode = nil, + intro_start = nil, + intro_end = nil, + found = false, + prompt_shown = false, + }, +} + +local HOVER_MESSAGE_NAME = "subminer-hover-token" +local HOVER_MESSAGE_NAME_LEGACY = "yomipv-hover-token" +local DEFAULT_HOVER_BASE_COLOR = "FFFFFF" +local DEFAULT_HOVER_COLOR = "C6A0F6" + +local LOG_LEVEL_PRIORITY = { + debug = 10, + info = 20, + warn = 30, + error = 40, +} + +local function normalize_log_level(level) + local normalized = (level or "info"):lower() + if LOG_LEVEL_PRIORITY[normalized] then + return normalized + end + return "info" +end + +local function should_log(level) + local current = normalize_log_level(opts.log_level) + local target = normalize_log_level(level) + return LOG_LEVEL_PRIORITY[target] >= LOG_LEVEL_PRIORITY[current] +end + +local function subminer_log(level, scope, message) + if not should_log(level) then + return + end + local timestamp = os.date("%Y-%m-%d %H:%M:%S") + local line = string.format("[subminer] - %s - %s - [%s] %s", timestamp, string.upper(level), scope, message) + if level == "error" then + msg.error(line) + elseif level == "warn" then + msg.warn(line) + elseif level == "debug" then + msg.debug(line) + else + msg.info(line) + end +end + +local function show_osd(message) + if opts.osd_messages then + mp.osd_message("SubMiner: " .. message, 3) + end +end + +local function url_encode(text) + if type(text) ~= "string" then + return "" + end + local encoded = text:gsub("\n", " ") + encoded = encoded:gsub("([^%w%-_%.~ ])", function(char) + return string.format("%%%02X", string.byte(char)) + end) + return encoded:gsub(" ", "%%20") +end + +local function run_json_curl(url) + local result = mp.command_native({ + name = "subprocess", + args = { "curl", "-sL", "--connect-timeout", "5", "-A", "SubMiner-mpv/ani-skip", url }, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + if not result or result.status ~= 0 or type(result.stdout) ~= "string" or result.stdout == "" then + return nil, result and result.stderr or "curl failed" + end + local parsed, parse_error = utils.parse_json(result.stdout) + if type(parsed) ~= "table" then + return nil, parse_error or "invalid json" + end + return parsed, nil +end + +local function parse_episode_hint(text) + if type(text) ~= "string" or text == "" then + return nil + end + local patterns = { + "[Ss]%d+[Ee](%d+)", + "[Ee][Pp]?[%s%._%-]*(%d+)", + "[%s%._%-]+(%d+)[%s%._%-]+", + } + for _, pattern in ipairs(patterns) do + local token = text:match(pattern) + if token then + local episode = tonumber(token) + if episode and episode > 0 and episode < 10000 then + return episode + end + end + end + return nil +end + +local function cleanup_title(raw) + if type(raw) ~= "string" then + return nil + end + local cleaned = raw + cleaned = cleaned:gsub("%b[]", " ") + cleaned = cleaned:gsub("%b()", " ") + cleaned = cleaned:gsub("[Ss]%d+[Ee]%d+", " ") + cleaned = cleaned:gsub("[Ee][Pp]?[%s%._%-]*%d+", " ") + cleaned = cleaned:gsub("[%._%-]+", " ") + cleaned = cleaned:gsub("%s+", " ") + cleaned = cleaned:match("^%s*(.-)%s*$") or "" + if cleaned == "" then + return nil + end + return cleaned +end + +local function extract_show_title_from_path(media_path) + if type(media_path) ~= "string" or media_path == "" then + return nil + end + local normalized = media_path:gsub("\\", "/") + local segments = {} + for segment in normalized:gmatch("[^/]+") do + segments[#segments + 1] = segment + end + for index = 1, #segments do + local segment = segments[index] or "" + if segment:match("^[Ss]eason[%s%._%-]*%d+$") or segment:match("^[Ss][%s%._%-]*%d+$") then + local prior = segments[index - 1] + local cleaned = cleanup_title(prior or "") + if cleaned and cleaned ~= "" then + return cleaned + end + end + end + return nil +end + +local function normalize_for_match(value) + if type(value) ~= "string" then + return "" + end + return value:lower():gsub("[^%w]+", " "):gsub("%s+", " "):match("^%s*(.-)%s*$") or "" +end + +local MATCH_STOPWORDS = { + the = true, + this = true, + that = true, + world = true, + animated = true, + series = true, + season = true, + no = true, + on = true, + ["and"] = true, +} + +local function tokenize_match_words(value) + local normalized = normalize_for_match(value) + local tokens = {} + for token in normalized:gmatch("%S+") do + if #token >= 3 and not MATCH_STOPWORDS[token] then + tokens[#tokens + 1] = token + end + end + return tokens +end + +local function token_set(tokens) + local set = {} + for _, token in ipairs(tokens) do + set[token] = true + end + return set +end + +local function title_overlap_score(expected_title, candidate_title) + local expected = normalize_for_match(expected_title) + local candidate = normalize_for_match(candidate_title) + if expected == "" or candidate == "" then + return 0 + end + if candidate:find(expected, 1, true) then + return 120 + end + local expected_tokens = tokenize_match_words(expected_title) + local candidate_tokens = token_set(tokenize_match_words(candidate_title)) + if #expected_tokens == 0 then + return 0 + end + local score = 0 + local matched = 0 + for _, token in ipairs(expected_tokens) do + if candidate_tokens[token] then + score = score + 30 + matched = matched + 1 + else + score = score - 20 + end + end + if matched == 0 then + score = score - 80 + end + local coverage = matched / #expected_tokens + if #expected_tokens >= 2 then + -- Require strong multi-token agreement to avoid false positives like "Shadow Skill". + if coverage >= 0.8 then + score = score + 30 + elseif coverage >= 0.6 then + score = score + 10 + else + score = score - 50 + end + else + if coverage >= 1 then + score = score + 10 + end + end + return score +end + +local function has_any_sequel_marker(candidate_title) + local normalized = normalize_for_match(candidate_title) + if normalized == "" then + return false + end + local markers = { + "season 2", + "season 3", + "season 4", + "2nd season", + "3rd season", + "4th season", + "second season", + "third season", + "fourth season", + " ii ", + " iii ", + " iv ", + } + local padded = " " .. normalized .. " " + for _, marker in ipairs(markers) do + if padded:find(marker, 1, true) then + return true + end + end + return false +end + +local function season_signal_score(requested_season, candidate_title) + local season = tonumber(requested_season) + if not season or season < 1 then + return 0 + end + local normalized = " " .. normalize_for_match(candidate_title) .. " " + if normalized == " " then + return 0 + end + + if season == 1 then + return has_any_sequel_marker(candidate_title) and -60 or 20 + end + + local numeric_marker = string.format(" season %d ", season) + local ordinal_marker = string.format(" %dth season ", season) + local roman_markers = { + [2] = { " ii ", " second season ", " 2nd season " }, + [3] = { " iii ", " third season ", " 3rd season " }, + [4] = { " iv ", " fourth season ", " 4th season " }, + [5] = { " v ", " fifth season ", " 5th season " }, + } + + if normalized:find(numeric_marker, 1, true) or normalized:find(ordinal_marker, 1, true) then + return 40 + end + local aliases = roman_markers[season] or {} + for _, marker in ipairs(aliases) do + if normalized:find(marker, 1, true) then + return 40 + end + end + if has_any_sequel_marker(candidate_title) then + return -20 + end + return 5 +end + +local function resolve_title_and_episode() + local forced_title = type(opts.aniskip_title) == "string" and (opts.aniskip_title:match("^%s*(.-)%s*$") or "") or "" + local forced_season = tonumber(opts.aniskip_season) + local forced_episode = tonumber(opts.aniskip_episode) + local media_title = mp.get_property("media-title") + local filename = mp.get_property("filename/no-ext") or mp.get_property("filename") or "" + local path = mp.get_property("path") or "" + local path_show_title = extract_show_title_from_path(path) + local candidate_title = nil + if path_show_title and path_show_title ~= "" then + candidate_title = path_show_title + elseif forced_title ~= "" then + candidate_title = forced_title + else + candidate_title = cleanup_title(media_title) or cleanup_title(filename) or cleanup_title(path) + end + local episode = forced_episode + or parse_episode_hint(media_title) + or parse_episode_hint(filename) + or parse_episode_hint(path) + or 1 + return candidate_title, episode, forced_season +end + +local function resolve_mal_id(title, season) + local forced_mal_id = tonumber(opts.aniskip_mal_id) + if forced_mal_id and forced_mal_id > 0 then + return forced_mal_id, "(forced-mal-id)" + end + if type(title) == "string" and title:match("^%d+$") then + local numeric = tonumber(title) + if numeric and numeric > 0 then + return numeric, title + end + end + if type(title) ~= "string" or title == "" then + return nil, nil + end + + local lookup = title + if season and season > 1 then + lookup = string.format("%s Season %d", lookup, season) + end + local mal_url = "https://myanimelist.net/search/prefix.json?type=anime&keyword=" .. url_encode(lookup) + local mal_json, mal_error = run_json_curl(mal_url) + if not mal_json then + subminer_log("warn", "aniskip", "MAL lookup failed: " .. tostring(mal_error)) + return nil, lookup + end + local categories = mal_json.categories + if type(categories) ~= "table" then + return nil, lookup + end + for _, category in ipairs(categories) do + if type(category) == "table" and type(category.items) == "table" then + for _, item in ipairs(category.items) do + if type(item) == "table" and tonumber(item.id) then + subminer_log( + "info", + "aniskip", + string.format( + 'MAL candidate selected (first result): id=%s name="%s" season_hint=%s', + tostring(item.id), + tostring(item.name or ""), + tostring(season or "-") + ) + ) + return tonumber(item.id), lookup + end + end + end + end + return nil, lookup +end + +local function set_intro_chapters(intro_start, intro_end) + if type(intro_start) ~= "number" or type(intro_end) ~= "number" then + return + end + local current = mp.get_property_native("chapter-list") + local chapters = {} + if type(current) == "table" then + for _, chapter in ipairs(current) do + local title = type(chapter) == "table" and chapter.title or nil + if type(title) ~= "string" or not title:match("^AniSkip ") then + chapters[#chapters + 1] = chapter + end + end + end + chapters[#chapters + 1] = { time = intro_start, title = "AniSkip Intro Start" } + chapters[#chapters + 1] = { time = intro_end, title = "AniSkip Intro End" } + table.sort(chapters, function(a, b) + local a_time = type(a) == "table" and tonumber(a.time) or 0 + local b_time = type(b) == "table" and tonumber(b.time) or 0 + return a_time < b_time + end) + mp.set_property_native("chapter-list", chapters) +end + +local function remove_aniskip_chapters() + local current = mp.get_property_native("chapter-list") + if type(current) ~= "table" then + return + end + local chapters = {} + local changed = false + for _, chapter in ipairs(current) do + local title = type(chapter) == "table" and chapter.title or nil + if type(title) == "string" and title:match("^AniSkip ") then + changed = true + else + chapters[#chapters + 1] = chapter + end + end + if changed then + mp.set_property_native("chapter-list", chapters) + end +end + +local function clear_aniskip_state() + state.aniskip.prompt_shown = false + state.aniskip.found = false + state.aniskip.mal_id = nil + state.aniskip.title = nil + state.aniskip.episode = nil + state.aniskip.intro_start = nil + state.aniskip.intro_end = nil + remove_aniskip_chapters() +end + +local function skip_intro_now() + if not state.aniskip.found then + show_osd("Intro skip unavailable") + return + end + local intro_start = state.aniskip.intro_start + local intro_end = state.aniskip.intro_end + if type(intro_start) ~= "number" or type(intro_end) ~= "number" then + show_osd("Intro markers missing") + return + end + local now = mp.get_property_number("time-pos") + if type(now) ~= "number" then + show_osd("Skip unavailable") + return + end + local epsilon = 0.35 + if now < (intro_start - epsilon) or now > (intro_end + epsilon) then + show_osd("Skip intro only during intro") + return + end + mp.set_property_number("time-pos", intro_end) + show_osd("Skipped intro") +end + +local function update_intro_button_visibility() + if not opts.aniskip_enabled or not opts.aniskip_show_button or not state.aniskip.found then + return + end + local now = mp.get_property_number("time-pos") + if type(now) ~= "number" then + return + end + local in_intro = now >= (state.aniskip.intro_start or -1) and now < (state.aniskip.intro_end or -1) + local intro_start = state.aniskip.intro_start or -1 + local hint_window_end = intro_start + 3 + if in_intro and not state.aniskip.prompt_shown and now >= intro_start and now < hint_window_end then + local key = opts.aniskip_button_key ~= "" and opts.aniskip_button_key or "y-k" + local message = string.format(opts.aniskip_button_text, key) + mp.osd_message(message, tonumber(opts.aniskip_button_duration) or 3) + state.aniskip.prompt_shown = true + end +end + +local function apply_aniskip_payload(mal_id, title, episode, payload) + local results = payload and payload.results + if type(results) ~= "table" then + return false + end + for _, item in ipairs(results) do + if type(item) == "table" and item.skip_type == "op" and type(item.interval) == "table" then + local intro_start = tonumber(item.interval.start_time) + local intro_end = tonumber(item.interval.end_time) + if intro_start and intro_end and intro_end > intro_start then + state.aniskip.found = true + state.aniskip.mal_id = mal_id + state.aniskip.title = title + state.aniskip.episode = episode + state.aniskip.intro_start = intro_start + state.aniskip.intro_end = intro_end + state.aniskip.prompt_shown = false + set_intro_chapters(intro_start, intro_end) + subminer_log( + "info", + "aniskip", + string.format("Intro window %.3f -> %.3f (MAL %d, ep %d)", intro_start, intro_end, mal_id, episode) + ) + return true + end + end + end + return false +end + +local function fetch_aniskip_for_current_media() + if not is_subminer_app_running() then + subminer_log("debug", "lifecycle", "Skipping aniskip lookup: SubMiner app not running") + return + end + + clear_aniskip_state() + if not opts.aniskip_enabled then + return + end + local title, episode, season = resolve_title_and_episode() + local media_title_fallback = cleanup_title(mp.get_property("media-title")) + local filename_fallback = cleanup_title(mp.get_property("filename/no-ext") or mp.get_property("filename") or "") + local path_fallback = cleanup_title(mp.get_property("path") or "") + local lookup_titles = {} + local seen_titles = {} + local function push_lookup_title(candidate) + if type(candidate) ~= "string" then + return + end + local trimmed = candidate:match("^%s*(.-)%s*$") or "" + if trimmed == "" then + return + end + local key = trimmed:lower() + if seen_titles[key] then + return + end + seen_titles[key] = true + lookup_titles[#lookup_titles + 1] = trimmed + end + push_lookup_title(title) + push_lookup_title(media_title_fallback) + push_lookup_title(filename_fallback) + push_lookup_title(path_fallback) + + subminer_log( + "info", + "aniskip", + string.format( + 'Query context: title="%s" season=%s episode=%s (opts: title="%s" season=%s episode=%s mal_id=%s; fallback_titles=%d)', + tostring(title or ""), + tostring(season or "-"), + tostring(episode or "-"), + tostring(opts.aniskip_title or ""), + tostring(opts.aniskip_season or "-"), + tostring(opts.aniskip_episode or "-"), + tostring(opts.aniskip_mal_id or "-"), + #lookup_titles + ) + ) + local mal_id, mal_lookup = nil, nil + for index, lookup_title in ipairs(lookup_titles) do + subminer_log( + "info", + "aniskip", + string.format('MAL lookup attempt %d/%d using title="%s"', index, #lookup_titles, lookup_title) + ) + local attempt_mal_id, attempt_lookup = resolve_mal_id(lookup_title, season) + if attempt_mal_id then + mal_id = attempt_mal_id + mal_lookup = attempt_lookup + break + end + mal_lookup = attempt_lookup or mal_lookup + end + if not mal_id then + subminer_log( + "info", + "aniskip", + string.format('Skipped: MAL id unavailable for query="%s"', tostring(mal_lookup or "")) + ) + return + end + local url = string.format("https://api.aniskip.com/v1/skip-times/%d/%d?types=op&types=ed", mal_id, episode) + subminer_log( + "info", + "aniskip", + string.format('Resolved MAL id=%d using query="%s"; AniSkip URL=%s', mal_id, tostring(mal_lookup or ""), url) + ) + local payload, fetch_error = run_json_curl(url) + if not payload then + subminer_log("warn", "aniskip", "AniSkip fetch failed: " .. tostring(fetch_error)) + return + end + if payload.found ~= true then + subminer_log("info", "aniskip", "AniSkip: no skip windows found") + return + end + if not apply_aniskip_payload(mal_id, title, episode, payload) then + subminer_log("info", "aniskip", "AniSkip payload did not include OP interval") + end +end + +local function to_hex_color(input) + if type(input) ~= "string" then + return nil + end + + local hex = input:gsub("[%#%']", ""):gsub("^0x", "") + if #hex ~= 6 and #hex ~= 3 then + return nil + end + if #hex == 3 then + return hex:sub(1, 1) .. hex:sub(1, 1) .. hex:sub(2, 2) .. hex:sub(2, 2) .. hex:sub(3, 3) .. hex:sub(3, 3) + end + return hex +end + +local function fix_ass_color(input, fallback) + local hex = to_hex_color(input) + if not hex then + return fallback or DEFAULT_HOVER_BASE_COLOR + end + local r, g, b = hex:sub(1, 2), hex:sub(3, 4), hex:sub(5, 6) + return b .. g .. r +end + +local function escape_ass_text(text) + return (text or "") + :gsub("\\", "\\\\") + :gsub("{", "\\{") + :gsub("}", "\\}") + :gsub("\n", "\\N") +end + +local function resolve_osd_dimensions() + local width = mp.get_property_number("osd-width", 0) or 0 + local height = mp.get_property_number("osd-height", 0) or 0 + + if width <= 0 or height <= 0 then + local osd_dims = mp.get_property_native("osd-dimensions") + if type(osd_dims) == "table" and type(osd_dims.w) == "number" and osd_dims.w > 0 then + width = osd_dims.w + end + if type(osd_dims) == "table" and type(osd_dims.h) == "number" and osd_dims.h > 0 then + height = osd_dims.h + end + end + + if width <= 0 then + width = 1280 + end + if height <= 0 then + height = 720 + end + + return width, height +end + +local function resolve_metrics() + local sub_font_size = mp.get_property_number("sub-font-size", 36) or 36 + local sub_scale = mp.get_property_number("sub-scale", 1) or 1 + local sub_scale_by_window = mp.get_property_bool("sub-scale-by-window", true) == true + local sub_pos = mp.get_property_number("sub-pos", 100) or 100 + local sub_margin_y = mp.get_property_number("sub-margin-y", 0) or 0 + local sub_font = mp.get_property("sub-font", "sans-serif") or "sans-serif" + local sub_spacing = mp.get_property_number("sub-spacing", 0) or 0 + local sub_bold = mp.get_property_bool("sub-bold", false) == true + local sub_italic = mp.get_property_bool("sub-italic", false) == true + local sub_border_size = mp.get_property_number("sub-border-size", 2) or 2 + local sub_shadow_offset = mp.get_property_number("sub-shadow-offset", 0) or 0 + local osd_w, osd_h = resolve_osd_dimensions() + local window_scale = 1 + if sub_scale_by_window and osd_h > 0 then + window_scale = osd_h / 720 + end + local effective_margin_y = sub_margin_y * window_scale + + return { + font_size = sub_font_size * (sub_scale > 0 and sub_scale or 1) * window_scale, + pos = sub_pos, + margin_y = effective_margin_y, + font = sub_font, + spacing = sub_spacing, + bold = sub_bold, + italic = sub_italic, + border = sub_border_size * window_scale, + shadow = sub_shadow_offset * window_scale, + base_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_BASE_COLOR), + hover_color = fix_ass_color(mp.get_property("sub-color"), DEFAULT_HOVER_COLOR), + } +end + +local function get_subtitle_ass_property() + local ass_text = mp.get_property("sub-text/ass") + if type(ass_text) == "string" and ass_text ~= "" then + return ass_text + end + + ass_text = mp.get_property("sub-text-ass") + if type(ass_text) == "string" and ass_text ~= "" then + return ass_text + end + + return nil +end + +local function plain_text_and_ass_map(text) + local plain = {} + local map = {} + local plain_len = 0 + local i = 1 + local text_len = #text + + while i <= text_len do + local ch = text:sub(i, i) + if ch == "{" then + local close = text:find("}", i + 1, true) + if not close then + break + end + i = close + 1 + elseif ch == "\\" then + local esc = text:sub(i + 1, i + 1) + if esc == "N" or esc == "n" then + plain_len = plain_len + 1 + plain[plain_len] = "\n" + map[plain_len] = i + i = i + 2 + elseif esc == "h" then + plain_len = plain_len + 1 + plain[plain_len] = " " + map[plain_len] = i + i = i + 2 + elseif esc == "{" then + plain_len = plain_len + 1 + plain[plain_len] = "{" + map[plain_len] = i + i = i + 2 + elseif esc == "}" then + plain_len = plain_len + 1 + plain[plain_len] = "}" + map[plain_len] = i + i = i + 2 + elseif esc == "\\" then + plain_len = plain_len + 1 + plain[plain_len] = "\\" + map[plain_len] = i + i = i + 2 + else + local seq_end = i + 1 + while seq_end <= text_len and text:sub(seq_end, seq_end):match("[%a]") do + seq_end = seq_end + 1 + end + if text:sub(seq_end, seq_end) == "(" then + local close = text:find(")", seq_end, true) + if close then + i = close + 1 + else + i = seq_end + 1 + end + else + i = seq_end + 1 + end + end + else + plain_len = plain_len + 1 + plain[plain_len] = ch + map[plain_len] = i + i = i + 1 + end + end + + return table.concat(plain), map +end + +local function find_hover_span(payload, plain) + local source_len = #plain + local cursor = 1 + for _, token in ipairs(payload.tokens or {}) do + if type(token) ~= "table" or type(token.text) ~= "string" or token.text == "" then + goto continue + end + + local token_text = token.text + local start_pos = nil + local end_pos = nil + + if type(token.startPos) == "number" and type(token.endPos) == "number" then + if token.startPos >= 0 and token.endPos >= token.startPos then + local candidate_start = token.startPos + 1 + local candidate_stop = token.endPos + if + candidate_start >= 1 + and candidate_stop <= source_len + and candidate_stop >= candidate_start + and plain:sub(candidate_start, candidate_stop) == token_text + then + start_pos = candidate_start + end_pos = candidate_stop + end + end + end + + if not start_pos or not end_pos then + local fallback_start, fallback_stop = plain:find(token_text, cursor, true) + if not fallback_start then + fallback_start, fallback_stop = plain:find(token_text, 1, true) + end + start_pos, end_pos = fallback_start, fallback_stop + end + + if start_pos and end_pos then + if token.index == payload.hoveredTokenIndex then + return start_pos, end_pos + end + cursor = end_pos + 1 + end + + ::continue:: + end + + return nil +end + +local function inject_hover_color_to_ass(raw_ass, plain_map, hover_start, hover_end, hover_color, base_color) + if hover_start == nil or hover_end == nil then + return raw_ass + end + + local raw_open_idx = plain_map[hover_start] or 1 + local raw_close_idx = plain_map[hover_end + 1] or (#raw_ass + 1) + if raw_open_idx < 1 then + raw_open_idx = 1 + end + if raw_close_idx < 1 then + raw_close_idx = 1 + end + if raw_open_idx > #raw_ass + 1 then + raw_open_idx = #raw_ass + 1 + end + if raw_close_idx > #raw_ass + 1 then + raw_close_idx = #raw_ass + 1 + end + + local open_tag = string.format("{\\1c&H%s&}", hover_color) + local close_tag = string.format("{\\1c&H%s&}", base_color) + local changes = { + { idx = raw_open_idx, tag = open_tag }, + { idx = raw_close_idx, tag = close_tag }, + } + table.sort(changes, function(a, b) + return a.idx < b.idx + end) + + local output = {} + local cursor = 1 + for _, change in ipairs(changes) do + if change.idx > #raw_ass + 1 then + change.idx = #raw_ass + 1 + end + if change.idx < 1 then + change.idx = 1 + end + if change.idx > cursor then + output[#output + 1] = raw_ass:sub(cursor, change.idx - 1) + end + output[#output + 1] = change.tag + cursor = change.idx + end + if cursor <= #raw_ass then + output[#output + 1] = raw_ass:sub(cursor) + end + + return table.concat(output) +end + +local function build_hover_subtitle_content(payload) + local source_ass = get_subtitle_ass_property() + if type(source_ass) == "string" and source_ass ~= "" then + state.hover_highlight.cached_ass = source_ass + else + source_ass = state.hover_highlight.cached_ass + end + if type(source_ass) ~= "string" or source_ass == "" then + return nil + end + + local plain_source, plain_map = plain_text_and_ass_map(source_ass) + if type(plain_source) ~= "string" or plain_source == "" then + return nil + end + + local hover_start, hover_end = find_hover_span(payload, plain_source) + if not hover_start or not hover_end then + return nil + end + + local metrics = resolve_metrics() + local hover_color = fix_ass_color(payload.colors and payload.colors.hover or nil, metrics.hover_color) + local base_color = fix_ass_color(payload.colors and payload.colors.base or nil, metrics.base_color) + return inject_hover_color_to_ass(source_ass, plain_map, hover_start, hover_end, hover_color, base_color) +end + +local function clear_hover_overlay() + if state.hover_highlight.clear_timer then + state.hover_highlight.clear_timer:kill() + state.hover_highlight.clear_timer = nil + end + if state.hover_highlight.overlay_active then + if type(state.hover_highlight.saved_sub_visibility) == "string" then + mp.set_property("sub-visibility", state.hover_highlight.saved_sub_visibility) + else + mp.set_property("sub-visibility", "yes") + end + if type(state.hover_highlight.saved_secondary_sub_visibility) == "string" then + mp.set_property("secondary-sub-visibility", state.hover_highlight.saved_secondary_sub_visibility) + end + state.hover_highlight.saved_sub_visibility = nil + state.hover_highlight.saved_secondary_sub_visibility = nil + state.hover_highlight.overlay_active = false + end + mp.set_osd_ass(0, 0, "") + state.hover_highlight.payload = nil + state.hover_highlight.revision = -1 + state.hover_highlight.cached_ass = nil + state.hover_highlight.last_hover_update_ts = 0 +end + +local function schedule_hover_clear(delay_seconds) + if state.hover_highlight.clear_timer then + state.hover_highlight.clear_timer:kill() + state.hover_highlight.clear_timer = nil + end + state.hover_highlight.clear_timer = mp.add_timeout(delay_seconds or 0.08, function() + state.hover_highlight.clear_timer = nil + clear_hover_overlay() + end) +end + +local function render_hover_overlay(payload) + if not payload or payload.hoveredTokenIndex == nil or payload.subtitle == nil then + clear_hover_overlay() + return + end + + local ass = build_hover_subtitle_content(payload) + if not ass then + -- Transient parse/mapping miss; keep previous frame to avoid flicker. + return + end + + local osd_w, osd_h = resolve_osd_dimensions() + local metrics = resolve_metrics() + local osd_dims = mp.get_property_native("osd-dimensions") + local ml = (type(osd_dims) == "table" and type(osd_dims.ml) == "number") and osd_dims.ml or 0 + local mr = (type(osd_dims) == "table" and type(osd_dims.mr) == "number") and osd_dims.mr or 0 + local mt = (type(osd_dims) == "table" and type(osd_dims.mt) == "number") and osd_dims.mt or 0 + local mb = (type(osd_dims) == "table" and type(osd_dims.mb) == "number") and osd_dims.mb or 0 + local usable_w = math.max(1, osd_w - ml - mr) + local usable_h = math.max(1, osd_h - mt - mb) + local anchor_x = math.floor(ml + usable_w / 2) + local baseline_adjust = (metrics.border + metrics.shadow) * 5 + local anchor_y = math.floor(mt + (usable_h * metrics.pos / 100) - metrics.margin_y + baseline_adjust) + local font_size = math.max(8, metrics.font_size) + local anchor_tag = string.format( + "{\\an2\\q2\\pos(%d,%d)\\fn%s\\fs%g\\b%d\\i%d\\fsp%g\\bord%g\\shad%g\\1c&H%s&}", + anchor_x, + anchor_y, + escape_ass_text(metrics.font), + font_size, + metrics.bold and 1 or 0, + metrics.italic and 1 or 0, + metrics.spacing, + metrics.border, + metrics.shadow, + metrics.base_color + ) + if not state.hover_highlight.overlay_active then + state.hover_highlight.saved_sub_visibility = mp.get_property("sub-visibility") + state.hover_highlight.saved_secondary_sub_visibility = mp.get_property("secondary-sub-visibility") + mp.set_property("sub-visibility", "no") + mp.set_property("secondary-sub-visibility", "no") + state.hover_highlight.overlay_active = true + end + mp.set_osd_ass(osd_w, osd_h, anchor_tag .. ass) +end + +local function handle_hover_message(payload_json) + local parsed, parse_error = utils.parse_json(payload_json) + if not parsed then + msg.warn("Invalid hover-highlight payload: " .. tostring(parse_error)) + clear_hover_overlay() + return + end + + if type(parsed.revision) ~= "number" then + clear_hover_overlay() + return + end + + if parsed.revision < state.hover_highlight.revision then + return + end + + if type(parsed.hoveredTokenIndex) == "number" and type(parsed.tokens) == "table" then + if state.hover_highlight.clear_timer then + state.hover_highlight.clear_timer:kill() + state.hover_highlight.clear_timer = nil + end + state.hover_highlight.revision = parsed.revision + state.hover_highlight.payload = parsed + state.hover_highlight.last_hover_update_ts = mp.get_time() or 0 + render_hover_overlay(state.hover_highlight.payload) + return + end + + local now = mp.get_time() or 0 + local elapsed_since_hover = now - (state.hover_highlight.last_hover_update_ts or 0) + state.hover_highlight.revision = parsed.revision + state.hover_highlight.payload = nil + if state.hover_highlight.overlay_active then + if elapsed_since_hover > 0.35 then + -- Ignore stale null-hover updates while pointer is stationary. + return + end + schedule_hover_clear(0.08) + else + clear_hover_overlay() + end +end + +local function detect_backend() + if state.detected_backend then + return state.detected_backend + end + + local backend = nil + + if is_macos() then + backend = "macos" + elseif is_windows() then + backend = nil + elseif os.getenv("HYPRLAND_INSTANCE_SIGNATURE") then + backend = "hyprland" + elseif os.getenv("SWAYSOCK") then + backend = "sway" + elseif os.getenv("XDG_SESSION_TYPE") == "x11" or os.getenv("DISPLAY") then + backend = "x11" + else + subminer_log("warn", "backend", "Could not detect window manager, falling back to x11") + backend = "x11" + end + + state.detected_backend = backend + if backend then + subminer_log("info", "backend", "Detected backend: " .. backend) + else + subminer_log("info", "backend", "No backend detected") + end + return backend +end + +local function file_exists(path) + local info = utils.file_info(path) + if not info then return false end + if info.is_dir ~= nil then + return not info.is_dir + end + return true +end + +local function resolve_binary_candidate(candidate) + local normalized = normalize_binary_path_candidate(candidate) + if not normalized then + return nil + end + + if file_exists(normalized) then + return normalized + end + + if not normalized:lower():find("%.app") then + return nil + end + + local app_root = normalized + if not app_root:lower():match("%.app$") then + app_root = normalized:match("(.+%.app)") + end + if not app_root then + return nil + end + + for _, path in ipairs(binary_candidates_from_app_path(app_root)) do + if file_exists(path) then + return path + end + end + + return nil +end + +local function find_binary_override() + local candidates = { + resolve_binary_candidate(os.getenv("SUBMINER_APPIMAGE_PATH")), + resolve_binary_candidate(os.getenv("SUBMINER_BINARY_PATH")), + } + + for _, path in ipairs(candidates) do + if path and path ~= "" then + return path + end + end + + return nil +end + +local function find_binary() + local override = find_binary_override() + if override then + return override + end + + local configured = resolve_binary_candidate(opts.binary_path) + if configured then + return configured + end + + local search_paths = { + "/Applications/SubMiner.app/Contents/MacOS/SubMiner", + utils.join_path(os.getenv("HOME") or "", "Applications/SubMiner.app/Contents/MacOS/SubMiner"), + "C:\\Program Files\\SubMiner\\SubMiner.exe", + "C:\\Program Files (x86)\\SubMiner\\SubMiner.exe", + "C:\\SubMiner\\SubMiner.exe", + utils.join_path(os.getenv("HOME") or "", ".local/bin/SubMiner.AppImage"), + "/opt/SubMiner/SubMiner.AppImage", + "/usr/local/bin/SubMiner", + "/usr/bin/SubMiner", + } + + for _, path in ipairs(search_paths) do + if file_exists(path) then + subminer_log("info", "binary", "Found binary at: " .. path) + return path + end + end + + return nil +end + +local function ensure_binary_available() + if state.binary_available and state.binary_path and file_exists(state.binary_path) then + return true + end + + local discovered = find_binary() + if discovered then + state.binary_path = discovered + state.binary_available = true + return true + end + + state.binary_path = nil + state.binary_available = false + return false +end + +local function resolve_backend(override_backend) + local selected = override_backend + if selected == nil or selected == "" then + selected = opts.backend + end + if selected == "auto" then + return detect_backend() + end + return selected +end + +local function build_command_args(action, overrides) + overrides = overrides or {} + local args = { state.binary_path } + + table.insert(args, "--" .. action) + local log_level = normalize_log_level(overrides.log_level or opts.log_level) + if log_level ~= "info" then + table.insert(args, "--log-level") + table.insert(args, log_level) + end + + local needs_start_context = action == "start" + + if needs_start_context then + local backend = resolve_backend(overrides.backend) + if backend and backend ~= "" then + table.insert(args, "--backend") + table.insert(args, backend) + end + + local socket_path = overrides.socket_path or opts.socket_path + table.insert(args, "--socket") + table.insert(args, socket_path) + end + + return args +end + +local function run_control_command(action) + local args = build_command_args(action) + subminer_log("debug", "process", "Control command: " .. table.concat(args, " ")) + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + return result and result.status == 0 +end + +local function coerce_bool(value, fallback) + if type(value) == "boolean" then + return value + end + if type(value) == "string" then + local normalized = value:lower() + if normalized == "yes" or normalized == "true" or normalized == "1" or normalized == "on" then + return true + end + if normalized == "no" or normalized == "false" or normalized == "0" or normalized == "off" then + return false + end + end + return fallback +end + +local function parse_start_script_message_overrides(...) + local overrides = {} + for i = 1, select("#", ...) do + local token = select(i, ...) + if type(token) == "string" and token ~= "" then + local key, value = token:match("^([%w_%-]+)=(.+)$") + if key and value then + local normalized_key = key:lower() + if normalized_key == "backend" then + local backend = value:lower() + if backend == "auto" or backend == "hyprland" or backend == "sway" or backend == "x11" or backend == "macos" then + overrides.backend = backend + end + elseif normalized_key == "socket" or normalized_key == "socket_path" then + overrides.socket_path = value + elseif normalized_key == "texthooker" or normalized_key == "texthooker_enabled" then + local parsed = coerce_bool(value, nil) + if parsed ~= nil then + overrides.texthooker_enabled = parsed + end + elseif normalized_key == "log-level" or normalized_key == "log_level" then + overrides.log_level = normalize_log_level(value) + end + end + end + end + return overrides +end + +local function resolve_visible_overlay_startup() + local visible = coerce_bool(opts.auto_start_visible_overlay, false) + -- Backward compatibility for old config key. + if coerce_bool(opts.auto_start_overlay, false) then + visible = true + end + return visible +end + +local function resolve_invisible_overlay_startup() + local raw = opts.auto_start_invisible_overlay + if type(raw) == "boolean" then + return raw + end + + local mode = type(raw) == "string" and raw:lower() or "platform-default" + if mode == "visible" or mode == "show" or mode == "yes" or mode == "true" or mode == "on" then + return true + end + if mode == "hidden" or mode == "hide" or mode == "no" or mode == "false" or mode == "off" then + return false + end + + -- platform-default + return not is_linux() +end + +local function apply_startup_overlay_preferences() + local should_show_visible = resolve_visible_overlay_startup() + local should_show_invisible = resolve_invisible_overlay_startup() + + local visible_action = should_show_visible and "show-visible-overlay" or "hide-visible-overlay" + if not run_control_command(visible_action) then + subminer_log("warn", "process", "Failed to apply visible startup action: " .. visible_action) + end + + local invisible_action = should_show_invisible and "show-invisible-overlay" or "hide-invisible-overlay" + if not run_control_command(invisible_action) then + subminer_log("warn", "process", "Failed to apply invisible startup action: " .. invisible_action) + end + + state.invisible_overlay_visible = should_show_invisible +end + +local function build_texthooker_args() + local args = { state.binary_path, "--texthooker", "--port", tostring(opts.texthooker_port) } + local log_level = normalize_log_level(opts.log_level) + if log_level ~= "info" then + table.insert(args, "--log-level") + table.insert(args, log_level) + end + return args +end + +local function ensure_texthooker_running(callback) + if not opts.texthooker_enabled then + callback() + return + end + + if state.texthooker_running then + callback() + return + end + + local args = build_texthooker_args() + subminer_log("info", "texthooker", "Starting texthooker process: " .. table.concat(args, " ")) + state.texthooker_running = true + + mp.command_native_async({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + if not success or (result and result.status ~= 0) then + state.texthooker_running = false + subminer_log( + "warn", + "texthooker", + "Texthooker process exited unexpectedly: " .. (error or (result and result.stderr) or "unknown error") + ) + end + end) + + -- Give the process a moment to acquire the app lock before sending --start. + mp.add_timeout(0.35, callback) +end + +local function start_overlay(overrides) + local socket_ready, reason = is_subminer_ipc_ready() + local process_not_running = reason == "SubMiner process not running" + if not socket_ready and not process_not_running then + subminer_log("warn", "process", "Refusing to start overlay: " .. tostring(reason)) + show_osd("SubMiner IPC not set up. Launch mpv with --input-ipc-server=/tmp/subminer-socket") + return + end + + if not ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + if state.overlay_running then + subminer_log("info", "process", "Overlay already running") + show_osd("Already running") + return + end + + overrides = overrides or {} + local texthooker_enabled = overrides.texthooker_enabled + if texthooker_enabled == nil then + texthooker_enabled = opts.texthooker_enabled + end + + local function launch_overlay() + local args = build_command_args("start", overrides) + subminer_log("info", "process", "Starting overlay: " .. table.concat(args, " ")) + + show_osd("Starting...") + state.overlay_running = true + + mp.command_native_async({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + if not success or (result and result.status ~= 0) then + state.overlay_running = false + subminer_log( + "error", + "process", + "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error") + ) + show_osd("Overlay start failed") + end + end) + + -- Apply explicit startup visibility for each overlay layer. + mp.add_timeout(0.6, function() + apply_startup_overlay_preferences() + end) + end + + if texthooker_enabled then + ensure_texthooker_running(launch_overlay) + else + launch_overlay() + end +end + +local function start_overlay_from_script_message(...) + local overrides = parse_start_script_message_overrides(...) + start_overlay(overrides) +end + +local function stop_overlay() + if not ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + local args = build_command_args("stop") + subminer_log("info", "process", "Stopping overlay: " .. table.concat(args, " ")) + + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + + state.overlay_running = false + state.texthooker_running = false + if result.status == 0 then + subminer_log("info", "process", "Overlay stopped") + else + subminer_log("warn", "process", "Stop command returned non-zero status: " .. tostring(result.status)) + end + show_osd("Stopped") +end + +local function toggle_overlay() + if not ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + local args = build_command_args("toggle") + subminer_log("info", "process", "Toggling overlay: " .. table.concat(args, " ")) + + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + + if result and result.status ~= 0 then + subminer_log("warn", "process", "Toggle command failed") + show_osd("Toggle failed") + end +end + +local function toggle_invisible_overlay() + if not ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + local args = build_command_args("toggle-invisible-overlay") + subminer_log("info", "process", "Toggling invisible overlay: " .. table.concat(args, " ")) + + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + + if result and result.status ~= 0 then + subminer_log("warn", "process", "Invisible toggle command failed") + show_osd("Invisible toggle failed") + return + end + + state.invisible_overlay_visible = not state.invisible_overlay_visible + show_osd("Invisible overlay: " .. (state.invisible_overlay_visible and "visible" or "hidden")) +end + +local function show_invisible_overlay() + if not ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + local args = build_command_args("show-invisible-overlay") + subminer_log("info", "process", "Showing invisible overlay: " .. table.concat(args, " ")) + + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + + if result and result.status ~= 0 then + subminer_log("warn", "process", "Show invisible command failed") + show_osd("Show invisible failed") + return + end + + state.invisible_overlay_visible = true + show_osd("Invisible overlay: visible") +end + +local function hide_invisible_overlay() + if not ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + local args = build_command_args("hide-invisible-overlay") + subminer_log("info", "process", "Hiding invisible overlay: " .. table.concat(args, " ")) + + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + + if result and result.status ~= 0 then + subminer_log("warn", "process", "Hide invisible command failed") + show_osd("Hide invisible failed") + return + end + + state.invisible_overlay_visible = false + show_osd("Invisible overlay: hidden") +end + +local function open_options() + if not state.binary_available then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + local args = build_command_args("settings") + subminer_log("info", "process", "Opening options: " .. table.concat(args, " ")) + local result = mp.command_native({ + name = "subprocess", + args = args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + if result.status == 0 then + subminer_log("info", "process", "Options window opened") + show_osd("Options opened") + else + subminer_log("warn", "process", "Failed to open options") + show_osd("Failed to open options") + end +end + +local restart_overlay +local check_status + +local function show_menu() + if not state.binary_available then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + local items = { + "Start overlay", + "Stop overlay", + "Toggle overlay", + "Toggle invisible overlay", + "Open options", + "Restart overlay", + "Check status", + } + + local actions = { + start_overlay, + stop_overlay, + toggle_overlay, + toggle_invisible_overlay, + open_options, + restart_overlay, + check_status, + } + + input.select({ + prompt = "SubMiner: ", + items = items, + submit = function(index) + if index and actions[index] then + actions[index]() + end + end, + }) +end + +restart_overlay = function() + if not ensure_binary_available() then + subminer_log("error", "binary", "SubMiner binary not found") + show_osd("Error: binary not found") + return + end + + subminer_log("info", "process", "Restarting overlay...") + show_osd("Restarting...") + + local stop_args = build_command_args("stop") + mp.command_native({ + name = "subprocess", + args = stop_args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }) + + state.overlay_running = false + state.texthooker_running = false + + ensure_texthooker_running(function() + local start_args = build_command_args("start") + subminer_log("info", "process", "Starting overlay: " .. table.concat(start_args, " ")) + + state.overlay_running = true + mp.command_native_async({ + name = "subprocess", + args = start_args, + playback_only = false, + capture_stdout = true, + capture_stderr = true, + }, function(success, result, error) + if not success or (result and result.status ~= 0) then + state.overlay_running = false + subminer_log( + "error", + "process", + "Overlay start failed: " .. (error or (result and result.stderr) or "unknown error") + ) + show_osd("Restart failed") + else + show_osd("Restarted successfully") + end + end) + end) +end + +check_status = function() + if not state.binary_available then + show_osd("Status: binary not found") + return + end + + local status = state.overlay_running and "running" or "stopped" + show_osd("Status: overlay is " .. status) + subminer_log("info", "process", "Status check: overlay is " .. status) +end + +local function on_file_loaded() + if not is_subminer_app_running() then + clear_aniskip_state() + subminer_log("debug", "lifecycle", "Skipping file load hooks: SubMiner app not running") + return true + end + + clear_aniskip_state() + fetch_aniskip_for_current_media() + state.binary_path = find_binary() + if state.binary_path then + state.binary_available = true + subminer_log("info", "lifecycle", "SubMiner ready (binary: " .. state.binary_path .. ")") + local should_auto_start = coerce_bool(opts.auto_start, false) + if should_auto_start then + start_overlay() + end + else + state.binary_available = false + subminer_log("warn", "binary", "SubMiner binary not found - overlay features disabled") + if opts.binary_path ~= "" then + subminer_log("warn", "binary", "Configured path '" .. opts.binary_path .. "' does not exist") + end + end +end + +local function on_shutdown() + clear_aniskip_state() + clear_hover_overlay() + if (state.overlay_running or state.texthooker_running) and state.binary_available then + subminer_log("info", "lifecycle", "mpv shutting down, stopping SubMiner process") + show_osd("Shutting down...") + stop_overlay() + end +end + +local function register_keybindings() + mp.add_key_binding("y-s", "subminer-start", start_overlay) + mp.add_key_binding("y-S", "subminer-stop", stop_overlay) + mp.add_key_binding("y-t", "subminer-toggle", toggle_overlay) + mp.add_key_binding("y-i", "subminer-toggle-invisible", toggle_invisible_overlay) + mp.add_key_binding("y-I", "subminer-show-invisible", show_invisible_overlay) + mp.add_key_binding("y-u", "subminer-hide-invisible", hide_invisible_overlay) + mp.add_key_binding("y-y", "subminer-menu", show_menu) + mp.add_key_binding("y-o", "subminer-options", open_options) + mp.add_key_binding("y-r", "subminer-restart", restart_overlay) + mp.add_key_binding("y-c", "subminer-status", check_status) + if type(opts.aniskip_button_key) == "string" and opts.aniskip_button_key ~= "" then + mp.add_key_binding(opts.aniskip_button_key, "subminer-skip-intro", skip_intro_now) + end + if opts.aniskip_button_key ~= "y-k" then + mp.add_key_binding("y-k", "subminer-skip-intro-fallback", skip_intro_now) + end +end + +local function register_script_messages() + mp.register_script_message("subminer-start", start_overlay_from_script_message) + mp.register_script_message("subminer-stop", stop_overlay) + mp.register_script_message("subminer-toggle", toggle_overlay) + mp.register_script_message("subminer-toggle-invisible", toggle_invisible_overlay) + mp.register_script_message("subminer-show-invisible", show_invisible_overlay) + mp.register_script_message("subminer-hide-invisible", hide_invisible_overlay) + mp.register_script_message("subminer-menu", show_menu) + mp.register_script_message("subminer-options", open_options) + mp.register_script_message("subminer-restart", restart_overlay) + mp.register_script_message("subminer-status", check_status) + mp.register_script_message("subminer-aniskip-refresh", fetch_aniskip_for_current_media) + mp.register_script_message("subminer-skip-intro", skip_intro_now) + mp.register_script_message(HOVER_MESSAGE_NAME, function(payload_json) + handle_hover_message(payload_json) + end) + mp.register_script_message(HOVER_MESSAGE_NAME_LEGACY, function(payload_json) + handle_hover_message(payload_json) + end) +end + +local function init() + register_keybindings() + register_script_messages() + + mp.register_event("file-loaded", on_file_loaded) + mp.register_event("shutdown", on_shutdown) + mp.register_event("file-loaded", clear_hover_overlay) + mp.register_event("end-file", clear_hover_overlay) + mp.register_event("shutdown", clear_hover_overlay) + mp.register_event("end-file", clear_aniskip_state) + mp.register_event("shutdown", clear_aniskip_state) + mp.add_hook("on_unload", 10, function() + clear_hover_overlay() + clear_aniskip_state() + end) + mp.observe_property("sub-start", "native", function() + clear_hover_overlay() + end) + mp.observe_property("time-pos", "number", function() + update_intro_button_visibility() + end) + + subminer_log("info", "lifecycle", "SubMiner plugin loaded") +end + +init()