mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Enhance AniList character dictionary sync and subtitle features (#15)
This commit is contained in:
@@ -166,8 +166,10 @@ test('buildSubminerScriptOpts includes aniskip payload fields', () => {
|
||||
assert.match(opts, /subminer-aniskip_intro_end=62/);
|
||||
assert.match(opts, /subminer-aniskip_lookup_status=ready/);
|
||||
assert.ok(payloadMatch !== null);
|
||||
assert.equal(payloadMatch[1].includes('%'), false);
|
||||
const payloadJson = Buffer.from(payloadMatch[1], 'base64url').toString('utf-8');
|
||||
const encodedPayload = payloadMatch[1];
|
||||
assert.ok(encodedPayload !== undefined);
|
||||
assert.equal(encodedPayload.includes('%'), false);
|
||||
const payloadJson = Buffer.from(encodedPayload, 'base64url').toString('utf-8');
|
||||
const payload = JSON.parse(payloadJson);
|
||||
assert.equal(payload.found, true);
|
||||
const first = payload.results?.[0];
|
||||
|
||||
@@ -53,6 +53,13 @@ interface AniSkipPayloadResponse {
|
||||
results?: unknown;
|
||||
}
|
||||
|
||||
const ROMAN_SEASON_ALIASES: Record<number, readonly string[]> = {
|
||||
2: [' ii ', ' second season ', ' 2nd season '],
|
||||
3: [' iii ', ' third season ', ' 3rd season '],
|
||||
4: [' iv ', ' fourth season ', ' 4th season '],
|
||||
5: [' v ', ' fifth season ', ' 5th season '],
|
||||
};
|
||||
|
||||
const MAL_PREFIX_API = 'https://myanimelist.net/search/prefix.json?type=anime&keyword=';
|
||||
const ANISKIP_PAYLOAD_API = 'https://api.aniskip.com/v1/skip-times/';
|
||||
const MAL_USER_AGENT = 'SubMiner-launcher/ani-skip';
|
||||
@@ -188,14 +195,7 @@ function seasonSignalScore(requestedSeason: number | null, candidateTitle: strin
|
||||
return 40;
|
||||
}
|
||||
|
||||
const romanAliases = {
|
||||
2: [' ii ', ' second season ', ' 2nd season '],
|
||||
3: [' iii ', ' third season ', ' 3rd season '],
|
||||
4: [' iv ', ' fourth season ', ' 4th season '],
|
||||
5: [' v ', ' fifth season ', ' 5th season '],
|
||||
} as const;
|
||||
|
||||
const aliases = romanAliases[season] ?? [];
|
||||
const aliases = ROMAN_SEASON_ALIASES[season] ?? [];
|
||||
return aliases.some((alias) => normalized.includes(alias))
|
||||
? 40
|
||||
: hasAnySequelMarker(candidateTitle)
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 { runDictionaryCommand } from './dictionary-command.js';
|
||||
import { runDoctorCommand } from './doctor-command.js';
|
||||
import { runMpvPreAppCommand } from './mpv-command.js';
|
||||
|
||||
@@ -94,3 +95,36 @@ test('mpv pre-app command exits non-zero when socket is not ready', async () =>
|
||||
(error: unknown) => error instanceof ExitSignal && error.code === 1,
|
||||
);
|
||||
});
|
||||
|
||||
test('dictionary command forwards --dictionary and target path to app binary', () => {
|
||||
const context = createContext();
|
||||
context.args.dictionary = true;
|
||||
context.args.dictionaryTarget = '/tmp/anime';
|
||||
const forwarded: string[][] = [];
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
runDictionaryCommand(context, {
|
||||
runAppCommandWithInherit: (_appPath, appArgs) => {
|
||||
forwarded.push(appArgs);
|
||||
throw new ExitSignal(0);
|
||||
},
|
||||
}),
|
||||
(error: unknown) => error instanceof ExitSignal && error.code === 0,
|
||||
);
|
||||
|
||||
assert.deepEqual(forwarded, [['--dictionary', '--dictionary-target', '/tmp/anime']]);
|
||||
});
|
||||
|
||||
test('dictionary command throws if app handoff unexpectedly returns', () => {
|
||||
const context = createContext();
|
||||
context.args.dictionary = true;
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
runDictionaryCommand(context, {
|
||||
runAppCommandWithInherit: () => undefined as never,
|
||||
}),
|
||||
/unexpectedly returned/,
|
||||
);
|
||||
});
|
||||
|
||||
31
launcher/commands/dictionary-command.ts
Normal file
31
launcher/commands/dictionary-command.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { runAppCommandWithInherit } from '../mpv.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
interface DictionaryCommandDeps {
|
||||
runAppCommandWithInherit: (appPath: string, appArgs: string[]) => never;
|
||||
}
|
||||
|
||||
const defaultDeps: DictionaryCommandDeps = {
|
||||
runAppCommandWithInherit,
|
||||
};
|
||||
|
||||
export function runDictionaryCommand(
|
||||
context: LauncherCommandContext,
|
||||
deps: DictionaryCommandDeps = defaultDeps,
|
||||
): boolean {
|
||||
const { args, appPath } = context;
|
||||
if (!args.dictionary || !appPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const forwarded = ['--dictionary'];
|
||||
if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) {
|
||||
forwarded.push('--dictionary-target', args.dictionaryTarget);
|
||||
}
|
||||
if (args.logLevel !== 'info') {
|
||||
forwarded.push('--log-level', args.logLevel);
|
||||
}
|
||||
|
||||
deps.runAppCommandWithInherit(appPath, forwarded);
|
||||
throw new Error('Dictionary command app handoff unexpectedly returned.');
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { fail, log } from '../log.js';
|
||||
import { commandExists, isYoutubeTarget, realpathMaybe, resolvePathMaybe } from '../util.js';
|
||||
import { collectVideos, showFzfMenu, showRofiMenu } from '../picker.js';
|
||||
@@ -14,6 +15,15 @@ import {
|
||||
import { generateYoutubeSubtitles } from '../youtube.js';
|
||||
import type { Args } from '../types.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { ensureLauncherSetupReady } from '../setup-gate.js';
|
||||
import {
|
||||
getDefaultConfigDir,
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
} from '../../src/shared/setup-state.js';
|
||||
|
||||
const SETUP_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const SETUP_POLL_INTERVAL_MS = 500;
|
||||
|
||||
function checkDependencies(args: Args): void {
|
||||
const missing: string[] = [];
|
||||
@@ -85,12 +95,47 @@ function registerCleanup(context: LauncherCommandContext): void {
|
||||
});
|
||||
}
|
||||
|
||||
async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promise<void> {
|
||||
const { args, appPath } = context;
|
||||
if (!appPath) return;
|
||||
|
||||
const configDir = getDefaultConfigDir({
|
||||
xdgConfigHome: process.env.XDG_CONFIG_HOME,
|
||||
homeDir: os.homedir(),
|
||||
});
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => readSetupState(statePath),
|
||||
launchSetupApp: () => {
|
||||
const setupArgs = ['--background', '--setup'];
|
||||
if (args.logLevel) {
|
||||
setupArgs.push('--log-level', args.logLevel);
|
||||
}
|
||||
const child = spawn(appPath, setupArgs, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
child.unref();
|
||||
},
|
||||
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
now: () => Date.now(),
|
||||
timeoutMs: SETUP_WAIT_TIMEOUT_MS,
|
||||
pollIntervalMs: SETUP_POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
if (!ready) {
|
||||
fail('SubMiner setup is incomplete. Complete setup in the app, then retry playback.');
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPlaybackCommand(context: LauncherCommandContext): Promise<void> {
|
||||
const { args, appPath, scriptPath, mpvSocketPath, pluginRuntimeConfig, processAdapter } = context;
|
||||
if (!appPath) {
|
||||
fail('SubMiner AppImage not found. Install to ~/.local/bin/ or set SUBMINER_APPIMAGE_PATH.');
|
||||
}
|
||||
|
||||
await ensurePlaybackSetupReady(context);
|
||||
|
||||
if (!args.target) {
|
||||
checkPickerDependencies(args);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ test('launcher root help lists subcommands', () => {
|
||||
assert.match(output, /doctor/);
|
||||
assert.match(output, /config/);
|
||||
assert.match(output, /mpv/);
|
||||
assert.match(output, /dictionary\|dict/);
|
||||
assert.match(output, /texthooker/);
|
||||
assert.match(output, /app\|bin/);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fail } from '../log.js';
|
||||
import type {
|
||||
Args,
|
||||
@@ -68,6 +69,27 @@ function parseBackend(value: string): Backend {
|
||||
fail(`Invalid backend: ${value} (must be auto, hyprland, x11, or macos)`);
|
||||
}
|
||||
|
||||
function parseDictionaryTarget(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
fail('Dictionary target path is required.');
|
||||
}
|
||||
if (isUrlTarget(trimmed)) {
|
||||
fail('Dictionary target must be a local file or directory path, not a URL.');
|
||||
}
|
||||
const resolved = path.resolve(resolvePathMaybe(trimmed));
|
||||
let stat: fs.Stats | null = null;
|
||||
try {
|
||||
stat = fs.statSync(resolved);
|
||||
} catch {
|
||||
stat = null;
|
||||
}
|
||||
if (!stat || (!stat.isFile() && !stat.isDirectory())) {
|
||||
fail(`Dictionary target path must be an existing file or directory: ${trimmed}`);
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig): Args {
|
||||
const envMode = (process.env.SUBMINER_YT_SUBGEN_MODE || '').toLowerCase();
|
||||
const defaultMode: YoutubeSubgenMode =
|
||||
@@ -114,6 +136,7 @@ export function createDefaultArgs(launcherConfig: LauncherYoutubeSubgenConfig):
|
||||
jellyfinLogout: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
doctor: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
@@ -170,6 +193,10 @@ export function applyRootOptionsToArgs(
|
||||
}
|
||||
|
||||
export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations): void {
|
||||
if (invocations.dictionaryTriggered) parsed.dictionary = true;
|
||||
if (invocations.dictionaryTarget) {
|
||||
parsed.dictionaryTarget = parseDictionaryTarget(invocations.dictionaryTarget);
|
||||
}
|
||||
if (invocations.doctorTriggered) parsed.doctor = true;
|
||||
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
|
||||
|
||||
@@ -230,6 +257,10 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
|
||||
if (invocations.ytInvocation.target) ensureTarget(invocations.ytInvocation.target, parsed);
|
||||
}
|
||||
|
||||
if (invocations.dictionaryLogLevel) {
|
||||
parsed.logLevel = parseLogLevel(invocations.dictionaryLogLevel);
|
||||
}
|
||||
|
||||
if (invocations.doctorLogLevel) parsed.logLevel = parseLogLevel(invocations.doctorLogLevel);
|
||||
if (invocations.texthookerLogLevel)
|
||||
parsed.logLevel = parseLogLevel(invocations.texthookerLogLevel);
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface CliInvocations {
|
||||
configInvocation: CommandActionInvocation | null;
|
||||
mpvInvocation: CommandActionInvocation | null;
|
||||
appInvocation: { appArgs: string[] } | null;
|
||||
dictionaryTriggered: boolean;
|
||||
dictionaryTarget: string | null;
|
||||
dictionaryLogLevel: string | null;
|
||||
doctorTriggered: boolean;
|
||||
doctorLogLevel: string | null;
|
||||
texthookerTriggered: boolean;
|
||||
@@ -81,6 +84,8 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
|
||||
'doctor',
|
||||
'config',
|
||||
'mpv',
|
||||
'dictionary',
|
||||
'dict',
|
||||
'texthooker',
|
||||
'app',
|
||||
'bin',
|
||||
@@ -128,6 +133,9 @@ export function parseCliPrograms(
|
||||
let configInvocation: CommandActionInvocation | null = null;
|
||||
let mpvInvocation: CommandActionInvocation | null = null;
|
||||
let appInvocation: { appArgs: string[] } | null = null;
|
||||
let dictionaryTriggered = false;
|
||||
let dictionaryTarget: string | null = null;
|
||||
let dictionaryLogLevel: string | null = null;
|
||||
let doctorLogLevel: string | null = null;
|
||||
let texthookerLogLevel: string | null = null;
|
||||
let doctorTriggered = false;
|
||||
@@ -214,6 +222,18 @@ export function parseCliPrograms(
|
||||
};
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('dictionary')
|
||||
.alias('dict')
|
||||
.description('Generate character dictionary ZIP from a file or directory target')
|
||||
.argument('<target>', 'Video file path or anime directory path')
|
||||
.option('--log-level <level>', 'Log level')
|
||||
.action((target: string, options: Record<string, unknown>) => {
|
||||
dictionaryTriggered = true;
|
||||
dictionaryTarget = target;
|
||||
dictionaryLogLevel = typeof options.logLevel === 'string' ? options.logLevel : null;
|
||||
});
|
||||
|
||||
commandProgram
|
||||
.command('doctor')
|
||||
.description('Run dependency and environment checks')
|
||||
@@ -289,6 +309,9 @@ export function parseCliPrograms(
|
||||
configInvocation,
|
||||
mpvInvocation,
|
||||
appInvocation,
|
||||
dictionaryTriggered,
|
||||
dictionaryTarget,
|
||||
dictionaryLogLevel,
|
||||
doctorTriggered,
|
||||
doctorLogLevel,
|
||||
texthookerTriggered,
|
||||
|
||||
@@ -284,8 +284,10 @@ export function parseEpisodePathFromDisplay(
|
||||
const normalized = display.trim().replace(/\s+/g, ' ');
|
||||
const match = normalized.match(/^(.*?)\s+S(\d{1,2})E\d{1,3}\b/i);
|
||||
if (!match) return null;
|
||||
const seriesName = match[1].trim();
|
||||
const seasonNumber = Number.parseInt(match[2], 10);
|
||||
const seriesName = match[1]?.trim();
|
||||
const seasonText = match[2];
|
||||
if (!seriesName || !seasonText) return null;
|
||||
const seasonNumber = Number.parseInt(seasonText, 10);
|
||||
if (!seriesName || !Number.isFinite(seasonNumber) || seasonNumber < 0) return null;
|
||||
return { seriesName, seasonNumber };
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ 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;
|
||||
if (!raw) return undefined;
|
||||
const parsed = Number.parseFloat(raw);
|
||||
if (!Number.isFinite(parsed)) return undefined;
|
||||
return parsed;
|
||||
@@ -72,9 +73,14 @@ export function matchEpisodeFromName(name: string): {
|
||||
} {
|
||||
const seasonEpisode = name.match(/S(\d{1,2})E(\d{1,3})/i);
|
||||
if (seasonEpisode && seasonEpisode.index !== undefined) {
|
||||
const seasonText = seasonEpisode[1];
|
||||
const episodeText = seasonEpisode[2];
|
||||
if (!seasonText || !episodeText) {
|
||||
return { season: null, episode: null, index: null, confidence: 'low' };
|
||||
}
|
||||
return {
|
||||
season: Number.parseInt(seasonEpisode[1], 10),
|
||||
episode: Number.parseInt(seasonEpisode[2], 10),
|
||||
season: Number.parseInt(seasonText, 10),
|
||||
episode: Number.parseInt(episodeText, 10),
|
||||
index: seasonEpisode.index,
|
||||
confidence: 'high',
|
||||
};
|
||||
@@ -82,9 +88,14 @@ export function matchEpisodeFromName(name: string): {
|
||||
|
||||
const alt = name.match(/(\d{1,2})x(\d{1,3})/i);
|
||||
if (alt && alt.index !== undefined) {
|
||||
const seasonText = alt[1];
|
||||
const episodeText = alt[2];
|
||||
if (!seasonText || !episodeText) {
|
||||
return { season: null, episode: null, index: null, confidence: 'low' };
|
||||
}
|
||||
return {
|
||||
season: Number.parseInt(alt[1], 10),
|
||||
episode: Number.parseInt(alt[2], 10),
|
||||
season: Number.parseInt(seasonText, 10),
|
||||
episode: Number.parseInt(episodeText, 10),
|
||||
index: alt.index,
|
||||
confidence: 'high',
|
||||
};
|
||||
@@ -92,9 +103,13 @@ export function matchEpisodeFromName(name: string): {
|
||||
|
||||
const epOnly = name.match(/(?:^|[\s._-])E(?:P)?(\d{1,3})(?:\b|[\s._-])/i);
|
||||
if (epOnly && epOnly.index !== undefined) {
|
||||
const episodeText = epOnly[1];
|
||||
if (!episodeText) {
|
||||
return { season: null, episode: null, index: null, confidence: 'low' };
|
||||
}
|
||||
return {
|
||||
season: null,
|
||||
episode: Number.parseInt(epOnly[1], 10),
|
||||
episode: Number.parseInt(episodeText, 10),
|
||||
index: epOnly.index,
|
||||
confidence: 'medium',
|
||||
};
|
||||
@@ -102,9 +117,13 @@ export function matchEpisodeFromName(name: string): {
|
||||
|
||||
const numeric = name.match(/(?:^|[-–—]\s*)(\d{1,3})\s*[-–—]/);
|
||||
if (numeric && numeric.index !== undefined) {
|
||||
const episodeText = numeric[1];
|
||||
if (!episodeText) {
|
||||
return { season: null, episode: null, index: null, confidence: 'low' };
|
||||
}
|
||||
return {
|
||||
season: null,
|
||||
episode: Number.parseInt(numeric[1], 10),
|
||||
episode: Number.parseInt(episodeText, 10),
|
||||
index: numeric.index,
|
||||
confidence: 'medium',
|
||||
};
|
||||
@@ -117,7 +136,9 @@ 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);
|
||||
const seasonText = match[1];
|
||||
if (!seasonText) return null;
|
||||
const parsed = Number.parseInt(seasonText, 10);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,6 +162,35 @@ test('doctor reports checks and exits non-zero without hard dependencies', () =>
|
||||
});
|
||||
});
|
||||
|
||||
test('dictionary command forwards --dictionary and --dictionary-target to app command path', () => {
|
||||
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 targetPath = path.join(root, 'anime-folder');
|
||||
fs.mkdirSync(targetPath, { recursive: true });
|
||||
const result = runLauncher(['dictionary', targetPath], env);
|
||||
|
||||
assert.equal(result.status, 0);
|
||||
assert.equal(
|
||||
fs.readFileSync(capturePath, 'utf8'),
|
||||
`--dictionary\n--dictionary-target\n${targetPath}\n`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('jellyfin discovery routes to app --background and remote announce with log-level forwarding', () => {
|
||||
withTempDir((root) => {
|
||||
const homeDir = path.join(root, 'home');
|
||||
|
||||
@@ -13,6 +13,7 @@ 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 { runDictionaryCommand } from './commands/dictionary-command.js';
|
||||
import { runJellyfinCommand } from './commands/jellyfin-command.js';
|
||||
import { runPlaybackCommand } from './commands/playback-command.js';
|
||||
|
||||
@@ -90,6 +91,10 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (runDictionaryCommand(appContext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (await runJellyfinCommand(appContext)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
|
||||
jellyfinLogout: false,
|
||||
jellyfinPlay: false,
|
||||
jellyfinDiscovery: false,
|
||||
dictionary: false,
|
||||
doctor: false,
|
||||
configPath: false,
|
||||
configShow: false,
|
||||
|
||||
@@ -427,7 +427,7 @@ export async function startMpv(
|
||||
appPath: string,
|
||||
preloadedSubtitles?: { primaryPath?: string; secondaryPath?: string },
|
||||
options?: { startPaused?: boolean },
|
||||
): void {
|
||||
): Promise<void> {
|
||||
if (targetKind === 'file' && (!fs.existsSync(target) || !fs.statSync(target).isFile())) {
|
||||
fail(`Video file not found: ${target}`);
|
||||
}
|
||||
|
||||
@@ -50,3 +50,11 @@ test('parseArgs maps mpv idle action', () => {
|
||||
assert.equal(parsed.mpvIdle, true);
|
||||
assert.equal(parsed.mpvStatus, false);
|
||||
});
|
||||
|
||||
test('parseArgs maps dictionary command and log-level override', () => {
|
||||
const parsed = parseArgs(['dictionary', '.', '--log-level', 'debug'], 'subminer', {});
|
||||
|
||||
assert.equal(parsed.dictionary, true);
|
||||
assert.equal(parsed.dictionaryTarget, process.cwd());
|
||||
assert.equal(parsed.logLevel, 'debug');
|
||||
});
|
||||
|
||||
@@ -207,7 +207,8 @@ export function pickLibrary(
|
||||
iconPath: ensureIcon(session, lib.id) || undefined,
|
||||
}));
|
||||
const idx = showRofiIconMenu(entries, 'Jellyfin Library', initialQuery, themePath);
|
||||
return idx >= 0 ? visibleLibraries[idx].id : '';
|
||||
const selected = idx >= 0 ? visibleLibraries[idx] : undefined;
|
||||
return selected?.id ?? '';
|
||||
}
|
||||
|
||||
const lines = visibleLibraries.map((lib) => `${lib.id}\t${lib.name} [${lib.kind}]`);
|
||||
@@ -244,7 +245,8 @@ export function pickItem(
|
||||
iconPath: ensureIcon(session, item.id) || undefined,
|
||||
}));
|
||||
const idx = showRofiIconMenu(entries, 'Jellyfin Item', initialQuery, themePath);
|
||||
return idx >= 0 ? visibleItems[idx].id : '';
|
||||
const selected = idx >= 0 ? visibleItems[idx] : undefined;
|
||||
return selected?.id ?? '';
|
||||
}
|
||||
|
||||
const lines = visibleItems.map((item) => `${item.id}\t${item.display}`);
|
||||
@@ -281,7 +283,8 @@ export function pickGroup(
|
||||
iconPath: ensureIcon(session, group.id) || undefined,
|
||||
}));
|
||||
const idx = showRofiIconMenu(entries, 'Jellyfin Anime/Folder', initialQuery, themePath);
|
||||
return idx >= 0 ? visibleGroups[idx].id : '';
|
||||
const selected = idx >= 0 ? visibleGroups[idx] : undefined;
|
||||
return selected?.id ?? '';
|
||||
}
|
||||
|
||||
const lines = visibleGroups.map((group) => `${group.id}\t${group.display}`);
|
||||
|
||||
107
launcher/setup-gate.test.ts
Normal file
107
launcher/setup-gate.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { ensureLauncherSetupReady, waitForSetupCompletion } from './setup-gate';
|
||||
import type { SetupState } from '../src/shared/setup-state';
|
||||
|
||||
test('waitForSetupCompletion resolves completed and cancelled states', async () => {
|
||||
const sequence: Array<SetupState | null> = [
|
||||
null,
|
||||
{
|
||||
version: 1,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
},
|
||||
{
|
||||
version: 1,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'skipped',
|
||||
pluginInstallPathSummary: null,
|
||||
},
|
||||
];
|
||||
|
||||
const result = await waitForSetupCompletion({
|
||||
readSetupState: () => sequence.shift() ?? null,
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(result, 'completed');
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady launches setup app and resumes only after completion', async () => {
|
||||
const calls: string[] = [];
|
||||
let reads = 0;
|
||||
|
||||
const ready = await ensureLauncherSetupReady({
|
||||
readSetupState: () => {
|
||||
reads += 1;
|
||||
if (reads === 1) return null;
|
||||
if (reads === 2) {
|
||||
return {
|
||||
version: 1,
|
||||
status: 'in_progress',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
version: 1,
|
||||
status: 'completed',
|
||||
completedAt: '2026-03-07T00:00:00.000Z',
|
||||
completionSource: 'user',
|
||||
lastSeenYomitanDictionaryCount: 1,
|
||||
pluginInstallStatus: 'installed',
|
||||
pluginInstallPathSummary: '/tmp/mpv',
|
||||
};
|
||||
},
|
||||
launchSetupApp: () => {
|
||||
calls.push('launch');
|
||||
},
|
||||
sleep: async () => undefined,
|
||||
now: (() => {
|
||||
let value = 0;
|
||||
return () => (value += 100);
|
||||
})(),
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(ready, true);
|
||||
assert.deepEqual(calls, ['launch']);
|
||||
});
|
||||
|
||||
test('ensureLauncherSetupReady fails on timeout/cancelled state', async () => {
|
||||
const result = await ensureLauncherSetupReady({
|
||||
readSetupState: () => ({
|
||||
version: 1,
|
||||
status: 'cancelled',
|
||||
completedAt: null,
|
||||
completionSource: null,
|
||||
lastSeenYomitanDictionaryCount: 0,
|
||||
pluginInstallStatus: 'unknown',
|
||||
pluginInstallPathSummary: null,
|
||||
}),
|
||||
launchSetupApp: () => undefined,
|
||||
sleep: async () => undefined,
|
||||
now: () => 0,
|
||||
timeoutMs: 5_000,
|
||||
pollIntervalMs: 100,
|
||||
});
|
||||
|
||||
assert.equal(result, false);
|
||||
});
|
||||
41
launcher/setup-gate.ts
Normal file
41
launcher/setup-gate.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { isSetupCompleted, type SetupState } from '../src/shared/setup-state.js';
|
||||
|
||||
export async function waitForSetupCompletion(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
}): Promise<'completed' | 'cancelled' | 'timeout'> {
|
||||
const deadline = deps.now() + deps.timeoutMs;
|
||||
|
||||
while (deps.now() <= deadline) {
|
||||
const state = deps.readSetupState();
|
||||
if (isSetupCompleted(state)) {
|
||||
return 'completed';
|
||||
}
|
||||
if (state?.status === 'cancelled') {
|
||||
return 'cancelled';
|
||||
}
|
||||
await deps.sleep(deps.pollIntervalMs);
|
||||
}
|
||||
|
||||
return 'timeout';
|
||||
}
|
||||
|
||||
export async function ensureLauncherSetupReady(deps: {
|
||||
readSetupState: () => SetupState | null;
|
||||
launchSetupApp: () => void;
|
||||
sleep: (ms: number) => Promise<void>;
|
||||
now: () => number;
|
||||
timeoutMs: number;
|
||||
pollIntervalMs: number;
|
||||
}): Promise<boolean> {
|
||||
if (isSetupCompleted(deps.readSetupState())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
deps.launchSetupApp();
|
||||
const result = await waitForSetupCompletion(deps);
|
||||
return result === 'completed';
|
||||
}
|
||||
@@ -4,6 +4,13 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import {
|
||||
createDefaultSetupState,
|
||||
getDefaultConfigDir,
|
||||
getSetupStatePath,
|
||||
readSetupState,
|
||||
writeSetupState,
|
||||
} from '../src/shared/setup-state.js';
|
||||
|
||||
type RunResult = {
|
||||
status: number | null;
|
||||
@@ -25,6 +32,9 @@ type SmokeCase = {
|
||||
mpvOverlayLogPath: string;
|
||||
};
|
||||
|
||||
const LAUNCHER_RUN_TIMEOUT_MS = 25000;
|
||||
const LONG_SMOKE_TEST_TIMEOUT_MS = 30000;
|
||||
|
||||
function writeExecutable(filePath: string, body: string): void {
|
||||
fs.writeFileSync(filePath, body);
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
@@ -55,6 +65,13 @@ function createSmokeCase(name: string): SmokeCase {
|
||||
`socket_path=${socketPath}\n`,
|
||||
);
|
||||
|
||||
const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir });
|
||||
const setupState = createDefaultSetupState();
|
||||
setupState.status = 'completed';
|
||||
setupState.completedAt = '2026-03-07T00:00:00.000Z';
|
||||
setupState.completionSource = 'user';
|
||||
writeSetupState(getSetupStatePath(configDir), setupState);
|
||||
|
||||
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');
|
||||
@@ -162,7 +179,7 @@ function runLauncher(
|
||||
{
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 15000,
|
||||
timeout: LAUNCHER_RUN_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -221,6 +238,22 @@ async function waitForJsonLines(
|
||||
}
|
||||
}
|
||||
|
||||
test('launcher smoke fixture seeds completed setup state', () => {
|
||||
const smokeCase = createSmokeCase('setup-state');
|
||||
try {
|
||||
const configDir = getDefaultConfigDir({
|
||||
xdgConfigHome: smokeCase.xdgConfigHome,
|
||||
homeDir: smokeCase.homeDir,
|
||||
});
|
||||
const statePath = getSetupStatePath(configDir);
|
||||
|
||||
assert.equal(readSetupState(statePath)?.status, 'completed');
|
||||
} finally {
|
||||
fs.rmSync(smokeCase.root, { recursive: true, force: true });
|
||||
fs.rmSync(smokeCase.socketDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('launcher mpv status returns ready when socket is connectable', async () => {
|
||||
await withSmokeCase('mpv-status', async (smokeCase) => {
|
||||
const env = makeTestEnv(smokeCase);
|
||||
@@ -263,7 +296,7 @@ test('launcher mpv status returns ready when socket is connectable', async () =>
|
||||
|
||||
test(
|
||||
'launcher start-overlay run forwards socket/backend and stops overlay after mpv exits',
|
||||
{ timeout: 20000 },
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
await withSmokeCase('overlay-start-stop', async (smokeCase) => {
|
||||
const env = makeTestEnv(smokeCase);
|
||||
@@ -322,7 +355,7 @@ test(
|
||||
|
||||
test(
|
||||
'launcher starts mpv paused when plugin auto-start visible overlay gate is enabled',
|
||||
{ timeout: 20000 },
|
||||
{ timeout: LONG_SMOKE_TEST_TIMEOUT_MS },
|
||||
async () => {
|
||||
await withSmokeCase('autoplay-ready-gate', async (smokeCase) => {
|
||||
fs.writeFileSync(
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
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 { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
|
||||
|
||||
export const ROFI_THEME_FILE = 'subminer.rasi';
|
||||
export const DEFAULT_SOCKET_PATH = '/tmp/subminer-socket';
|
||||
@@ -92,6 +80,8 @@ export interface Args {
|
||||
jellyfinLogout: boolean;
|
||||
jellyfinPlay: boolean;
|
||||
jellyfinDiscovery: boolean;
|
||||
dictionary: boolean;
|
||||
dictionaryTarget?: string;
|
||||
doctor: boolean;
|
||||
configPath: boolean;
|
||||
configShow: boolean;
|
||||
|
||||
@@ -58,7 +58,7 @@ function pickBestCandidate(candidates: SubtitleCandidate[]): SubtitleCandidate |
|
||||
if (srtA !== srtB) return srtB - srtA;
|
||||
return b.size - a.size;
|
||||
});
|
||||
return scored[0];
|
||||
return scored[0] ?? null;
|
||||
}
|
||||
|
||||
function scanSubtitleCandidates(
|
||||
@@ -120,7 +120,7 @@ function findAudioFile(tempDir: string, preferredExt: string): string | 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;
|
||||
return audioFiles[0]?.path ?? null;
|
||||
}
|
||||
|
||||
async function runWhisper(
|
||||
|
||||
Reference in New Issue
Block a user