mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-27 06:12:05 -07:00
Add backlog tasks and launcher time helper tests
- Track follow-up cleanup work in Backlog.md - Replace Date.now usage with shared nowMs helper - Add launcher args/parser and core regression tests
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
||||
waitForUnixSocketReady,
|
||||
} from '../mpv.js';
|
||||
import type { Args } from '../types.js';
|
||||
import { nowMs } from '../time.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
import { ensureLauncherSetupReady } from '../setup-gate.js';
|
||||
import {
|
||||
@@ -116,7 +117,7 @@ async function ensurePlaybackSetupReady(context: LauncherCommandContext): Promis
|
||||
child.unref();
|
||||
},
|
||||
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
||||
now: () => Date.now(),
|
||||
now: () => nowMs(),
|
||||
timeoutMs: SETUP_WAIT_TIMEOUT_MS,
|
||||
pollIntervalMs: SETUP_POLL_INTERVAL_MS,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { runAppCommandAttached } from '../mpv.js';
|
||||
import { nowMs } from '../time.js';
|
||||
import { sleep } from '../util.js';
|
||||
import type { LauncherCommandContext } from './context.js';
|
||||
|
||||
@@ -45,8 +46,8 @@ const defaultDeps: StatsCommandDeps = {
|
||||
runAppCommandAttached: (appPath, appArgs, logLevel, label) =>
|
||||
runAppCommandAttached(appPath, appArgs, logLevel, label),
|
||||
waitForStatsResponse: async (responsePath, signal) => {
|
||||
const deadline = Date.now() + STATS_STARTUP_RESPONSE_TIMEOUT_MS;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = nowMs() + STATS_STARTUP_RESPONSE_TIMEOUT_MS;
|
||||
while (nowMs() < deadline) {
|
||||
if (signal?.aborted) {
|
||||
return {
|
||||
ok: false,
|
||||
|
||||
155
launcher/config/args-normalizer.test.ts
Normal file
155
launcher/config/args-normalizer.test.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
applyInvocationsToArgs,
|
||||
applyRootOptionsToArgs,
|
||||
createDefaultArgs,
|
||||
} from './args-normalizer.js';
|
||||
|
||||
class ExitSignal extends Error {
|
||||
code: number;
|
||||
|
||||
constructor(code: number) {
|
||||
super(`exit:${code}`);
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function withProcessExitIntercept(callback: () => void): ExitSignal {
|
||||
const originalExit = process.exit;
|
||||
try {
|
||||
process.exit = ((code?: number) => {
|
||||
throw new ExitSignal(code ?? 0);
|
||||
}) as typeof process.exit;
|
||||
callback();
|
||||
} catch (error) {
|
||||
if (error instanceof ExitSignal) {
|
||||
return error;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
process.exit = originalExit;
|
||||
}
|
||||
|
||||
throw new Error('expected process.exit');
|
||||
}
|
||||
|
||||
function withTempDir<T>(fn: (dir: string) => T): T {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-launcher-args-'));
|
||||
try {
|
||||
return fn(dir);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('createDefaultArgs normalizes configured language codes and env thread override', () => {
|
||||
const originalThreads = process.env.SUBMINER_WHISPER_THREADS;
|
||||
process.env.SUBMINER_WHISPER_THREADS = '7';
|
||||
|
||||
try {
|
||||
const parsed = createDefaultArgs({
|
||||
primarySubLanguages: [' JA ', 'jpn', 'ja'],
|
||||
secondarySubLanguages: ['en', 'ENG', ''],
|
||||
whisperThreads: 2,
|
||||
});
|
||||
|
||||
assert.deepEqual(parsed.youtubePrimarySubLangs, ['ja', 'jpn']);
|
||||
assert.deepEqual(parsed.youtubeSecondarySubLangs, ['en', 'eng']);
|
||||
assert.deepEqual(parsed.youtubeAudioLangs, ['ja', 'jpn', 'en', 'eng']);
|
||||
assert.equal(parsed.whisperThreads, 7);
|
||||
assert.equal(parsed.youtubeWhisperSourceLanguage, 'ja');
|
||||
} finally {
|
||||
if (originalThreads === undefined) {
|
||||
delete process.env.SUBMINER_WHISPER_THREADS;
|
||||
} else {
|
||||
process.env.SUBMINER_WHISPER_THREADS = originalThreads;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('applyRootOptionsToArgs maps file, directory, and url targets', () => {
|
||||
withTempDir((dir) => {
|
||||
const filePath = path.join(dir, 'movie.mkv');
|
||||
const folderPath = path.join(dir, 'anime');
|
||||
fs.writeFileSync(filePath, 'x');
|
||||
fs.mkdirSync(folderPath);
|
||||
|
||||
const fileParsed = createDefaultArgs({});
|
||||
applyRootOptionsToArgs(fileParsed, {}, filePath);
|
||||
assert.equal(fileParsed.targetKind, 'file');
|
||||
assert.equal(fileParsed.target, filePath);
|
||||
|
||||
const dirParsed = createDefaultArgs({});
|
||||
applyRootOptionsToArgs(dirParsed, {}, folderPath);
|
||||
assert.equal(dirParsed.directory, folderPath);
|
||||
assert.equal(dirParsed.target, '');
|
||||
assert.equal(dirParsed.targetKind, '');
|
||||
|
||||
const urlParsed = createDefaultArgs({});
|
||||
applyRootOptionsToArgs(urlParsed, {}, 'https://example.test/video');
|
||||
assert.equal(urlParsed.targetKind, 'url');
|
||||
assert.equal(urlParsed.target, 'https://example.test/video');
|
||||
});
|
||||
});
|
||||
|
||||
test('applyRootOptionsToArgs rejects unsupported targets', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
const error = withProcessExitIntercept(() => {
|
||||
applyRootOptionsToArgs(parsed, {}, '/definitely/missing/subminer-target');
|
||||
});
|
||||
|
||||
assert.equal(error.code, 1);
|
||||
assert.match(error.message, /exit:1/);
|
||||
});
|
||||
|
||||
test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
|
||||
const parsed = createDefaultArgs({});
|
||||
|
||||
applyInvocationsToArgs(parsed, {
|
||||
jellyfinInvocation: {
|
||||
action: 'play',
|
||||
play: true,
|
||||
server: 'https://jf.example',
|
||||
username: 'alice',
|
||||
password: 'secret',
|
||||
logLevel: 'debug',
|
||||
},
|
||||
configInvocation: {
|
||||
action: 'show',
|
||||
logLevel: 'warn',
|
||||
},
|
||||
mpvInvocation: null,
|
||||
appInvocation: null,
|
||||
dictionaryTriggered: false,
|
||||
dictionaryTarget: null,
|
||||
dictionaryLogLevel: null,
|
||||
statsTriggered: false,
|
||||
statsBackground: false,
|
||||
statsStop: false,
|
||||
statsCleanup: false,
|
||||
statsCleanupVocab: false,
|
||||
statsCleanupLifetime: false,
|
||||
statsLogLevel: null,
|
||||
doctorTriggered: false,
|
||||
doctorLogLevel: null,
|
||||
doctorRefreshKnownWords: false,
|
||||
texthookerTriggered: false,
|
||||
texthookerLogLevel: null,
|
||||
});
|
||||
|
||||
assert.equal(parsed.jellyfin, false);
|
||||
assert.equal(parsed.jellyfinPlay, true);
|
||||
assert.equal(parsed.jellyfinDiscovery, false);
|
||||
assert.equal(parsed.jellyfinLogin, false);
|
||||
assert.equal(parsed.jellyfinLogout, false);
|
||||
assert.equal(parsed.jellyfinServer, 'https://jf.example');
|
||||
assert.equal(parsed.jellyfinUsername, 'alice');
|
||||
assert.equal(parsed.jellyfinPassword, 'secret');
|
||||
assert.equal(parsed.configShow, true);
|
||||
assert.equal(parsed.logLevel, 'warn');
|
||||
});
|
||||
37
launcher/config/cli-parser-builder.test.ts
Normal file
37
launcher/config/cli-parser-builder.test.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { parseCliPrograms, resolveTopLevelCommand } from './cli-parser-builder.js';
|
||||
|
||||
test('resolveTopLevelCommand skips root options and finds the first command', () => {
|
||||
assert.deepEqual(resolveTopLevelCommand(['--backend', 'macos', 'config', 'show']), {
|
||||
name: 'config',
|
||||
index: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveTopLevelCommand respects the app alias after root options', () => {
|
||||
assert.deepEqual(resolveTopLevelCommand(['--log-level', 'debug', 'bin', '--foo']), {
|
||||
name: 'bin',
|
||||
index: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('parseCliPrograms keeps root options and target when no command is present', () => {
|
||||
const result = parseCliPrograms(['--backend', 'x11', '/tmp/movie.mkv'], 'subminer');
|
||||
|
||||
assert.equal(result.options.backend, 'x11');
|
||||
assert.equal(result.rootTarget, '/tmp/movie.mkv');
|
||||
assert.equal(result.invocations.appInvocation, null);
|
||||
});
|
||||
|
||||
test('parseCliPrograms routes app alias arguments through passthrough mode', () => {
|
||||
const result = parseCliPrograms(
|
||||
['--backend', 'macos', 'bin', '--anilist', '--log-level', 'debug'],
|
||||
'subminer',
|
||||
);
|
||||
|
||||
assert.equal(result.options.backend, 'macos');
|
||||
assert.deepEqual(result.invocations.appInvocation, {
|
||||
appArgs: ['--anilist', '--log-level', 'debug'],
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
JellyfinGroupEntry,
|
||||
} from './types.js';
|
||||
import { log, fail, getMpvLogPath } from './log.js';
|
||||
import { nowMs } from './time.js';
|
||||
import { commandExists, resolvePathMaybe, sleep } from './util.js';
|
||||
import {
|
||||
pickLibrary,
|
||||
@@ -453,9 +454,9 @@ async function runAppJellyfinCommand(
|
||||
}
|
||||
return retriedAfterStart ? 12000 : 4000;
|
||||
})();
|
||||
const settleDeadline = Date.now() + settleWindowMs;
|
||||
const settleDeadline = nowMs() + settleWindowMs;
|
||||
const settleOffset = attempt.logOffset;
|
||||
while (Date.now() < settleDeadline) {
|
||||
while (nowMs() < settleDeadline) {
|
||||
await sleep(100);
|
||||
const settledOutput = readLogAppendedSince(settleOffset);
|
||||
if (!settledOutput.trim()) {
|
||||
@@ -489,8 +490,8 @@ async function requestJellyfinPreviewAuthFromApp(
|
||||
return null;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 4000;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = nowMs() + 4000;
|
||||
while (nowMs() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(responsePath)) {
|
||||
const raw = fs.readFileSync(responsePath, 'utf8');
|
||||
|
||||
@@ -43,6 +43,7 @@ function runLauncher(argv: string[], env: NodeJS.ProcessEnv): RunResult {
|
||||
{
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { LogLevel, Backend, Args, MpvTrack } from './types.js';
|
||||
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
|
||||
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
|
||||
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
|
||||
import { nowMs } from './time.js';
|
||||
import {
|
||||
commandExists,
|
||||
getPathEnv,
|
||||
@@ -200,8 +201,8 @@ async function terminateTrackedDetachedMpv(logLevel: LogLevel): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 1500;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = nowMs() + 1500;
|
||||
while (nowMs() < deadline) {
|
||||
if (!isProcessAlive(pid)) {
|
||||
clearTrackedDetachedMpvPid();
|
||||
return;
|
||||
@@ -344,7 +345,7 @@ export function sendMpvCommandWithResponse(
|
||||
timeoutMs = 5000,
|
||||
): Promise<unknown> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const requestId = Date.now() + Math.floor(Math.random() * 1000);
|
||||
const requestId = nowMs() + Math.floor(Math.random() * 1000);
|
||||
const socket = net.createConnection(socketPath);
|
||||
let buffer = '';
|
||||
|
||||
@@ -1117,8 +1118,8 @@ export async function waitForUnixSocketReady(
|
||||
socketPath: string,
|
||||
timeoutMs: number,
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const deadline = nowMs() + timeoutMs;
|
||||
while (nowMs() < deadline) {
|
||||
try {
|
||||
if (fs.existsSync(socketPath)) {
|
||||
const ready = await canConnectUnixSocket(socketPath);
|
||||
|
||||
8
launcher/time.ts
Normal file
8
launcher/time.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export function nowMs(): number {
|
||||
const perf = globalThis.performance;
|
||||
if (perf) {
|
||||
return Math.floor(perf.timeOrigin + perf.now());
|
||||
}
|
||||
|
||||
return Number(process.hrtime.bigint() / 1000000n);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import os from 'node:os';
|
||||
import { spawn } from 'node:child_process';
|
||||
import type { LogLevel, CommandExecOptions, CommandExecResult } from './types.js';
|
||||
import { log } from './log.js';
|
||||
import { nowMs } from './time.js';
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -198,7 +199,7 @@ export function normalizeBasename(value: string, fallback: string): string {
|
||||
if (safe) return safe;
|
||||
const fallbackSafe = sanitizeToken(fallback);
|
||||
if (fallbackSafe) return fallbackSafe;
|
||||
return `${Date.now()}`;
|
||||
return `${nowMs()}`;
|
||||
}
|
||||
|
||||
export function normalizeLangCode(value: string): string {
|
||||
|
||||
Reference in New Issue
Block a user