Fix Windows mpv logging and add log export (#88)

This commit is contained in:
2026-05-26 00:31:38 -07:00
committed by GitHub
parent 43ebc7d371
commit 11c196821d
150 changed files with 2748 additions and 582 deletions
+1 -1
View File
@@ -163,7 +163,7 @@ test('buildSubminerScriptOpts includes aniskip payload fields', () => {
const payloadMatch = opts.match(/subminer-aniskip_payload=([^,]+)/);
assert.match(opts, /subminer-binary_path=\/tmp\/SubMiner\.AppImage/);
assert.match(opts, /subminer-socket_path=\/tmp\/subminer\.sock/);
assert.match(opts, /subminer-log_level=debug/);
assert.doesNotMatch(opts, /subminer-log_level=/);
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/);
+1 -4
View File
@@ -564,7 +564,7 @@ export function buildSubminerScriptOpts(
appPath: string,
socketPath: string,
aniSkipMetadata: AniSkipMetadata | null,
logLevel: LogLevel = 'info',
_logLevel: LogLevel = 'info',
extraParts: string[] = [],
): string {
const hasBinaryPath = extraParts.some((part) => part.startsWith('subminer-binary_path='));
@@ -574,9 +574,6 @@ export function buildSubminerScriptOpts(
...(hasSocketPath ? [] : [`subminer-socket_path=${sanitizeScriptOptValue(socketPath)}`]),
...extraParts.map(sanitizeScriptOptValue),
];
if (logLevel !== 'info') {
parts.push(`subminer-log_level=${sanitizeScriptOptValue(logLevel)}`);
}
if (aniSkipMetadata && aniSkipMetadata.title) {
parts.push(`subminer-aniskip_title=${sanitizeScriptOptValue(aniSkipMetadata.title)}`);
}
+30 -2
View File
@@ -6,6 +6,7 @@ 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 { runLogsCommand } from './logs-command.js';
import { runMpvPreAppCommand } from './mpv-command.js';
import { runAppPassthroughCommand } from './app-command.js';
import { runStatsCommand } from './stats-command.js';
@@ -169,6 +170,33 @@ test('doctor command forwards refresh-known-words to app binary', () => {
assert.deepEqual(forwarded, [['--refresh-known-words']]);
});
test('logs command exports logs and writes archive path', () => {
const writes: string[] = [];
const context = createContext();
context.args.logsExport = true;
context.processAdapter = {
...context.processAdapter,
writeStdout: (text) => writes.push(text),
};
const handled = runLogsCommand(context, {
exportLogsArchive: () => ({
zipPath: '/tmp/subminer-logs.zip',
exportedFiles: ['/tmp/app.log'],
mode: 'current-day',
}),
});
assert.equal(handled, true);
assert.deepEqual(writes, ['/tmp/subminer-logs.zip\n']);
});
test('logs command ignores unrelated launcher commands', () => {
const context = createContext();
assert.equal(runLogsCommand(context), false);
});
test('app command starts default macOS background app detached from launcher', () => {
const context = createContext();
context.args.appPassthrough = true;
@@ -185,7 +213,7 @@ test('app command starts default macOS background app detached from launcher', (
});
assert.equal(handled, true);
assert.deepEqual(calls, ['detached:/tmp/subminer.app:info']);
assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
});
test('app command starts default Linux background app detached from launcher', () => {
@@ -204,7 +232,7 @@ test('app command starts default Linux background app detached from launcher', (
});
assert.equal(handled, true);
assert.deepEqual(calls, ['detached:/tmp/subminer.app:info']);
assert.deepEqual(calls, ['detached:/tmp/subminer.app:warn']);
});
test('app command keeps explicit passthrough args attached', () => {
+2 -1
View File
@@ -1,4 +1,5 @@
import { runAppCommandWithInherit } from '../mpv.js';
import { shouldForwardLogLevel } from '../types.js';
import type { LauncherCommandContext } from './context.js';
interface DictionaryCommandDeps {
@@ -35,7 +36,7 @@ export function runDictionaryCommand(
if (typeof args.dictionaryTarget === 'string' && args.dictionaryTarget.trim()) {
forwarded.push('--dictionary-target', args.dictionaryTarget);
}
if (args.logLevel !== 'info') {
if (shouldForwardLogLevel(args.logLevel)) {
forwarded.push('--log-level', args.logLevel);
}
+5 -4
View File
@@ -2,6 +2,7 @@ import { fail } from '../log.js';
import { runAppCommandWithInherit } from '../mpv.js';
import { commandExists } from '../util.js';
import { runJellyfinPlayMenu } from '../jellyfin.js';
import { shouldForwardLogLevel } from '../types.js';
import type { LauncherCommandContext } from './context.js';
export async function runJellyfinCommand(context: LauncherCommandContext): Promise<boolean> {
@@ -18,7 +19,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.jellyfin) {
const forwarded = ['--jellyfin'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded);
return true;
@@ -42,7 +43,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
'--jellyfin-password',
password,
];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded);
return true;
@@ -50,7 +51,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.jellyfinLogout) {
const forwarded = ['--jellyfin-logout'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded);
return true;
@@ -69,7 +70,7 @@ export async function runJellyfinCommand(context: LauncherCommandContext): Promi
if (args.jellyfinDiscovery) {
const forwarded = ['--background', '--jellyfin-remote-announce'];
if (args.logLevel !== 'info') forwarded.push('--log-level', args.logLevel);
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
appendPasswordStore(forwarded);
runAppCommandWithInherit(appPath, forwarded);
return true;
+24
View File
@@ -0,0 +1,24 @@
import { exportLogsArchiveForCurrentUser } from '../../src/main/runtime/log-export.js';
import type { ExportLogsResult } from '../../src/main/runtime/log-export.js';
import type { LauncherCommandContext } from './context.js';
interface LogsCommandDeps {
exportLogsArchive(): ExportLogsResult;
}
const defaultDeps: LogsCommandDeps = {
exportLogsArchive: () => exportLogsArchiveForCurrentUser(),
};
export function runLogsCommand(
context: LauncherCommandContext,
deps: LogsCommandDeps = defaultDeps,
): boolean {
if (!context.args.logsExport) {
return false;
}
const result = deps.exportLogsArchive();
context.processAdapter.writeStdout(`${result.zipPath}\n`);
return true;
}
@@ -36,6 +36,7 @@ function createContext(): LauncherCommandContext {
texthookerOpenBrowser: false,
useRofi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
target: 'https://www.youtube.com/watch?v=65Ovd7t8sNw',
targetKind: 'url',
@@ -55,6 +56,7 @@ function createContext(): LauncherCommandContext {
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
settings: false,
configPath: false,
@@ -321,6 +323,7 @@ test('plugin auto-start playback attaches a warm background app through the laun
test('plugin auto-start attach mode reuses launcher-resolved config dir for app control', async () => {
const context = createContext();
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const originalAppData = process.env.APPDATA;
const xdgConfigHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-test-xdg-'));
const expectedConfigDir = path.join(xdgConfigHome, 'SubMiner');
fs.mkdirSync(expectedConfigDir, { recursive: true });
@@ -347,6 +350,7 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
try {
process.env.XDG_CONFIG_HOME = xdgConfigHome;
process.env.APPDATA = xdgConfigHome;
await runPlaybackCommandWithDeps(context, {
ensurePlaybackSetupReady: async () => {},
@@ -376,6 +380,11 @@ test('plugin auto-start attach mode reuses launcher-resolved config dir for app
} else {
process.env.XDG_CONFIG_HOME = originalXdgConfigHome;
}
if (originalAppData === undefined) {
delete process.env.APPDATA;
} else {
process.env.APPDATA = originalAppData;
}
fs.rmSync(xdgConfigHome, { recursive: true, force: true });
}
});
+2 -1
View File
@@ -4,6 +4,7 @@ import path from 'node:path';
import { runAppCommandAttached } from '../mpv.js';
import { nowMs } from '../time.js';
import { sleep } from '../util.js';
import { shouldForwardLogLevel } from '../types.js';
import type { LauncherCommandContext } from './context.js';
type StatsCommandResponse = {
@@ -156,7 +157,7 @@ export async function runStatsCommand(
if (args.statsCleanupLifetime) {
forwarded.push('--stats-cleanup-lifetime');
}
if (args.logLevel !== 'info') {
if (shouldForwardLogLevel(args.logLevel)) {
forwarded.push('--log-level', args.logLevel);
}
const attachedExitPromise = resolvedDeps.runAppCommandAttached(
+1
View File
@@ -13,6 +13,7 @@ test('launcher root help lists subcommands', () => {
assert.match(output, /doctor/);
assert.match(output, /config/);
assert.match(output, /mpv/);
assert.match(output, /logs/);
assert.match(output, /dictionary\|dict/);
assert.match(output, /texthooker/);
assert.match(output, /app\|bin/);
+50 -1
View File
@@ -1,12 +1,14 @@
import { fail } from './log.js';
import type {
Args,
LauncherLoggingConfig,
LauncherJellyfinConfig,
LauncherMpvConfig,
LauncherYoutubeSubgenConfig,
LogLevel,
PluginRuntimeConfig,
} from './types.js';
import { normalizeLogRotation } from '../src/shared/log-files.js';
import {
applyInvocationsToArgs,
applyRootOptionsToArgs,
@@ -52,6 +54,52 @@ export function loadLauncherMpvConfig(): LauncherMpvConfig {
return parseLauncherMpvConfig(root);
}
function parseLogLevelConfig(value: unknown): LogLevel | undefined {
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (
normalized === 'debug' ||
normalized === 'info' ||
normalized === 'warn' ||
normalized === 'error'
) {
return normalized;
}
return undefined;
}
function parseLogRotationConfig(value: unknown): LauncherLoggingConfig['rotation'] {
return normalizeLogRotation(value);
}
function parseLogFileConfig(value: unknown): boolean | undefined {
return typeof value === 'boolean' ? value : undefined;
}
export function loadLauncherLoggingConfig(): LauncherLoggingConfig {
const root = readLauncherMainConfigObject();
if (!root) return {};
const logging =
root.logging && typeof root.logging === 'object' && !Array.isArray(root.logging)
? (root.logging as Record<string, unknown>)
: null;
const files =
logging?.files && typeof logging.files === 'object' && !Array.isArray(logging.files)
? (logging.files as Record<string, unknown>)
: null;
return {
level: parseLogLevelConfig(logging?.level),
rotation: parseLogRotationConfig(logging?.rotation),
files: files
? {
app: parseLogFileConfig(files.app),
launcher: parseLogFileConfig(files.launcher),
mpv: parseLogFileConfig(files.mpv),
}
: undefined,
};
}
export function hasLauncherExternalYomitanProfileConfig(): boolean {
return readExternalYomitanProfilePath(readLauncherMainConfigObject()) !== null;
}
@@ -65,9 +113,10 @@ export function parseArgs(
scriptName: string,
launcherConfig: LauncherYoutubeSubgenConfig,
launcherMpvConfig: LauncherMpvConfig = {},
launcherLoggingConfig: LauncherLoggingConfig = {},
): Args {
const topLevelCommand = resolveTopLevelCommand(argv);
const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig);
const parsed = createDefaultArgs(launcherConfig, launcherMpvConfig, launcherLoggingConfig);
if (topLevelCommand && (topLevelCommand.name === 'app' || topLevelCommand.name === 'bin')) {
parsed.appPassthrough = true;
+15
View File
@@ -51,6 +51,13 @@ test('createDefaultArgs seeds mpv profile from launcher config', () => {
assert.equal(parsed.profile, 'anime');
});
test('createDefaultArgs seeds log level from launcher logging config', () => {
const parsed = createDefaultArgs({}, {}, { level: 'debug', rotation: 14 });
assert.equal(parsed.logLevel, 'debug');
assert.equal(parsed.logRotation, 14);
});
test('applyRootOptionsToArgs appends CLI mpv profile to configured profile', () => {
const parsed = createDefaultArgs({}, { profile: 'anime' });
@@ -131,6 +138,8 @@ test('applyInvocationsToArgs maps config and jellyfin invocation state', () => {
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
@@ -175,6 +184,8 @@ test('applyInvocationsToArgs maps settings invocation to settings window', () =>
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
@@ -212,6 +223,8 @@ test('applyInvocationsToArgs fails when config invocation has no action', () =>
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: false,
texthookerLogLevel: null,
texthookerOpenBrowser: false,
@@ -247,6 +260,8 @@ test('applyInvocationsToArgs maps texthooker browser-open request', () => {
doctorTriggered: false,
doctorLogLevel: null,
doctorRefreshKnownWords: false,
logsTriggered: false,
logsExport: false,
texthookerTriggered: true,
texthookerLogLevel: null,
texthookerOpenBrowser: true,
+9 -1
View File
@@ -4,6 +4,7 @@ import { fail } from '../log.js';
import type {
Args,
Backend,
LauncherLoggingConfig,
LauncherMpvConfig,
LauncherYoutubeSubgenConfig,
LogLevel,
@@ -106,6 +107,7 @@ function parseDictionaryAnilistId(value: string): number {
export function createDefaultArgs(
launcherConfig: LauncherYoutubeSubgenConfig,
mpvConfig: LauncherMpvConfig = {},
loggingConfig: LauncherLoggingConfig = {},
): Args {
const configuredSecondaryLangs = uniqueNormalizedLangCodes(
launcherConfig.secondarySubLanguages ?? [],
@@ -162,6 +164,7 @@ export function createDefaultArgs(
statsCleanupLifetime: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
update: false,
settings: false,
@@ -195,7 +198,8 @@ export function createDefaultArgs(
texthookerOnly: false,
texthookerOpenBrowser: false,
useRofi: false,
logLevel: 'info',
logLevel: loggingConfig.level ?? 'warn',
logRotation: loggingConfig.rotation ?? 7,
passwordStore: '',
target: '',
targetKind: '',
@@ -260,6 +264,10 @@ export function applyInvocationsToArgs(parsed: Args, invocations: CliInvocations
}
if (invocations.doctorTriggered) parsed.doctor = true;
if (invocations.doctorRefreshKnownWords) parsed.doctorRefreshKnownWords = true;
if (invocations.logsTriggered && !invocations.logsExport) {
fail('Logs command requires -e or --export.');
}
if (invocations.logsExport) parsed.logsExport = true;
if (invocations.texthookerTriggered) parsed.texthookerOnly = true;
if (invocations.texthookerOpenBrowser) parsed.texthookerOpenBrowser = true;
+16
View File
@@ -41,6 +41,8 @@ export interface CliInvocations {
doctorTriggered: boolean;
doctorLogLevel: string | null;
doctorRefreshKnownWords: boolean;
logsTriggered: boolean;
logsExport: boolean;
texthookerTriggered: boolean;
texthookerLogLevel: string | null;
texthookerOpenBrowser: boolean;
@@ -91,6 +93,7 @@ function getTopLevelCommand(argv: string[]): { name: string; index: number } | n
'config',
'settings',
'mpv',
'logs',
'dictionary',
'dict',
'stats',
@@ -158,6 +161,8 @@ export function parseCliPrograms(
let statsLogLevel: string | null = null;
let doctorLogLevel: string | null = null;
let doctorRefreshKnownWords = false;
let logsTriggered = false;
let logsExport = false;
let texthookerLogLevel: string | null = null;
let texthookerOpenBrowser = false;
let doctorTriggered = false;
@@ -294,6 +299,15 @@ export function parseCliPrograms(
doctorRefreshKnownWords = options.refreshKnownWords === true;
});
commandProgram
.command('logs')
.description('Log file helpers')
.option('-e, --export', 'Export sanitized log archive')
.action((options: Record<string, unknown>) => {
logsTriggered = true;
logsExport = options.export === true;
});
commandProgram
.command('config')
.description('Config file helpers (path|show)')
@@ -388,6 +402,8 @@ export function parseCliPrograms(
doctorTriggered,
doctorLogLevel,
doctorRefreshKnownWords,
logsTriggered,
logsExport,
texthookerTriggered,
texthookerLogLevel,
texthookerOpenBrowser,
+3 -1
View File
@@ -40,6 +40,7 @@ function validBackendOrDefault(value: unknown, fallback: Backend): Backend {
export function parsePluginRuntimeConfigFromMainConfig(
root: Record<string, unknown> | null,
logLevel: LogLevel = 'info',
): PluginRuntimeConfig {
const mpvConfig = root ? parseLauncherMpvConfig(root) : {};
const texthooker = rootObject(root, 'texthooker');
@@ -48,6 +49,7 @@ export function parsePluginRuntimeConfigFromMainConfig(
socketPath: mpvConfig.socketPath ?? DEFAULT_SOCKET_PATH,
binaryPath: mpvConfig.subminerBinaryPath ?? '',
backend: validBackendOrDefault(mpvConfig.backend, 'auto'),
logLevel,
autoStart: booleanOrDefault(mpvConfig.autoStartSubMiner, true),
autoStartVisibleOverlay: booleanOrDefault(root?.auto_start_overlay, false),
autoStartPauseUntilReady: booleanOrDefault(mpvConfig.pauseUntilOverlayReady, true),
@@ -65,7 +67,7 @@ export function buildPluginRuntimeScriptOptParts(
}
export function readPluginRuntimeConfig(logLevel: LogLevel): PluginRuntimeConfig {
const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject());
const parsed = parsePluginRuntimeConfigFromMainConfig(readLauncherMainConfigObject(), logLevel);
log(
'debug',
+2 -1
View File
@@ -9,6 +9,7 @@ import type {
JellyfinItemEntry,
JellyfinGroupEntry,
} from './types.js';
import { shouldForwardLogLevel } from './types.js';
import { log, fail, getMpvLogPath } from './log.js';
import { nowMs } from './time.js';
import { commandExists, resolvePathMaybe, sleep } from './util.js';
@@ -1036,7 +1037,7 @@ export async function runJellyfinPlayMenu(
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);
if (shouldForwardLogLevel(args.logLevel)) forwarded.push('--log-level', args.logLevel);
if (args.passwordStore) forwarded.push('--password-store', args.passwordStore);
runAppCommandWithInheritLogged(appPath, forwarded, args.logLevel, 'jellyfin-play');
}
+24 -5
View File
@@ -1,6 +1,14 @@
import type { LogLevel } from './types.js';
import { DEFAULT_MPV_LOG_FILE, getDefaultLauncherLogFile } from './types.js';
import { appendLogLine, resolveDefaultLogFilePath } from '../src/shared/log-files.js';
import { getDefaultLauncherLogFile } from './types.js';
import {
appendLogLine,
DEFAULT_LOG_ROTATION,
isLogFileEnabled,
normalizeLogRotation,
pruneLogDirectoryForPath,
resolveDefaultLogFilePath,
type LogRotation,
} from '../src/shared/log-files.js';
export const COLORS = {
red: '\x1b[0;31m',
@@ -22,25 +30,36 @@ export function shouldLog(level: LogLevel, configured: LogLevel): boolean {
}
export function getMpvLogPath(): string {
if (!isLogFileEnabled('mpv')) return '';
const envPath = process.env.SUBMINER_MPV_LOG?.trim();
if (envPath) return envPath;
return DEFAULT_MPV_LOG_FILE;
const logPath = envPath || resolveDefaultLogFilePath('mpv');
pruneLogDirectoryForPath(logPath, getLogRotation());
return logPath;
}
export function getLauncherLogPath(): string {
if (!isLogFileEnabled('launcher')) return '';
const envPath = process.env.SUBMINER_LAUNCHER_LOG?.trim();
if (envPath) return envPath;
return getDefaultLauncherLogFile();
}
export function getAppLogPath(): string {
if (!isLogFileEnabled('app')) return '';
const envPath = process.env.SUBMINER_APP_LOG?.trim();
if (envPath) return envPath;
return resolveDefaultLogFilePath('app');
}
function getLogRotation(): LogRotation {
return normalizeLogRotation(process.env.SUBMINER_LOG_ROTATION) ?? DEFAULT_LOG_ROTATION;
}
function appendTimestampedLog(logPath: string, message: string): void {
appendLogLine(logPath, `[${new Date().toISOString()}] ${message}`);
if (!logPath.trim()) return;
appendLogLine(logPath, `[${new Date().toISOString()}] ${message}`, {
rotation: getLogRotation(),
});
}
export function appendToMpvLog(message: string): void {
+32 -2
View File
@@ -124,6 +124,29 @@ test('short version flag prints installed app version without requiring app bina
});
});
test('logs export writes sanitized archive without requiring app binary', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const logsDir =
process.platform === 'win32'
? path.join(xdgConfigHome, 'SubMiner', 'logs')
: path.join(homeDir, '.config', 'SubMiner', 'logs');
fs.mkdirSync(logsDir, { recursive: true });
fs.writeFileSync(path.join(logsDir, 'app-2026-W21.log'), `/home/kyle/video.mkv\n`, 'utf8');
const result = runLauncher(['logs', '-e'], makeTestEnv(homeDir, xdgConfigHome));
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
const zipPath = result.stdout.trim();
assert.match(zipPath, /subminer-logs-.+\.zip$/);
assert.equal(fs.existsSync(zipPath), true);
const archive = fs.readFileSync(zipPath);
assert.equal(archive.includes(Buffer.from('/home/kyle')), false);
assert.equal(archive.includes(Buffer.from('/home/<user>')), true);
});
});
test('config path prefers jsonc over json for same directory', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
@@ -395,7 +418,7 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
});
});
test('launcher forwards non-info log level into mpv plugin script opts', { timeout: 15000 }, () => {
test('launcher forwards non-info log level into mpv logging args', { timeout: 15000 }, () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
@@ -430,6 +453,11 @@ test('launcher forwards non-info log level into mpv plugin script opts', { timeo
autoStartSubMiner: true,
pauseUntilOverlayReady: true,
},
logging: {
files: {
mpv: true,
},
},
}),
);
fs.writeFileSync(appPath, '#!/bin/sh\nexit 0\n');
@@ -468,7 +496,9 @@ ${bunBinary} -e "const net=require('node:net'); const fs=require('node:fs'); con
const result = runLauncher(['--log-level', 'debug', videoPath], env);
assert.equal(result.status, 0, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`);
assert.match(fs.readFileSync(mpvArgsPath, 'utf8'), /--script-opts=.*subminer-log_level=debug/);
const mpvArgs = fs.readFileSync(mpvArgsPath, 'utf8');
assert.match(mpvArgs, /--msg-level=all=warn,subminer=debug/);
assert.doesNotMatch(mpvArgs, /--script-opts=.*subminer-log_level=debug/);
});
});
+20 -1
View File
@@ -1,7 +1,9 @@
import path from 'node:path';
import packageJson from '../package.json';
import { applyLogFileTogglesToEnv } from '../src/shared/log-files.js';
import {
loadLauncherJellyfinConfig,
loadLauncherLoggingConfig,
loadLauncherMpvConfig,
loadLauncherYoutubeSubgenConfig,
parseArgs,
@@ -16,6 +18,7 @@ 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 { runLogsCommand } from './commands/logs-command.js';
import { runStatsCommand } from './commands/stats-command.js';
import { runJellyfinCommand } from './commands/jellyfin-command.js';
import { runPlaybackCommand } from './commands/playback-command.js';
@@ -61,7 +64,19 @@ async function main(): Promise<void> {
const scriptName = path.basename(scriptPath);
const launcherConfig = loadLauncherYoutubeSubgenConfig();
const launcherMpvConfig = loadLauncherMpvConfig();
const args = parseArgs(process.argv.slice(2), scriptName, launcherConfig, launcherMpvConfig);
const launcherLoggingConfig = loadLauncherLoggingConfig();
applyLogFileTogglesToEnv(launcherLoggingConfig.files);
process.env.SUBMINER_LOG_ROTATION =
launcherLoggingConfig.rotation !== undefined
? String(launcherLoggingConfig.rotation)
: (process.env.SUBMINER_LOG_ROTATION ?? '7');
const args = parseArgs(
process.argv.slice(2),
scriptName,
launcherConfig,
launcherMpvConfig,
launcherLoggingConfig,
);
if (args.version) {
console.log(`SubMiner ${APP_VERSION}`);
@@ -87,6 +102,10 @@ async function main(): Promise<void> {
return;
}
if (runLogsCommand(context)) {
return;
}
const resolvedAppPath = ensureAppPath(context);
state.appPath = resolvedAppPath;
log('debug', args.logLevel, `Using SubMiner app binary: ${resolvedAppPath}`);
+2
View File
@@ -566,6 +566,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
texthookerOpenBrowser: false,
useRofi: false,
logLevel: 'error',
logRotation: 7,
passwordStore: '',
target: '',
targetKind: '',
@@ -585,6 +586,7 @@ function makeArgs(overrides: Partial<Args> = {}): Args {
stats: false,
doctor: false,
doctorRefreshKnownWords: false,
logsExport: false,
version: false,
settings: false,
configPath: false,
+36 -10
View File
@@ -4,6 +4,7 @@ import os from 'node:os';
import net from 'node:net';
import { spawn, spawnSync } from 'node:child_process';
import { buildMpvLaunchModeArgs } from '../src/shared/mpv-launch-mode.js';
import { buildMpvLoggingArgs } from '../src/shared/mpv-logging-args.js';
import {
isAppControlServerAvailable as checkAppControlServerAvailable,
sendAppControlCommand,
@@ -14,7 +15,11 @@ import {
type InstalledMpvPluginDetection,
} from '../src/main/runtime/first-run-setup-plugin.js';
import type { LogLevel, Backend, Args, MpvTrack, PluginRuntimeConfig } from './types.js';
import { DEFAULT_MPV_SUBMINER_ARGS, DEFAULT_YOUTUBE_YTDL_FORMAT } from './types.js';
import {
DEFAULT_MPV_SUBMINER_ARGS,
DEFAULT_YOUTUBE_YTDL_FORMAT,
shouldForwardLogLevel,
} from './types.js';
import { appendToAppLog, getAppLogPath, log, fail, getMpvLogPath } from './log.js';
import { buildSubminerScriptOpts, resolveAniSkipMetadataForFile } from './aniskip-metadata.js';
import { buildPluginRuntimeScriptOptParts } from './config/plugin-runtime-config.js';
@@ -951,7 +956,7 @@ export async function startMpv(
);
}
mpvArgs.push(`--script-opts=${scriptOpts}`);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
try {
fs.rmSync(socketPath, { force: true });
@@ -1031,7 +1036,7 @@ export async function startOverlay(
socketPath,
...extraAppArgs,
];
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
if (shouldForwardLogLevel(args.logLevel)) overlayArgs.push('--log-level', args.logLevel);
if (args.useTexthooker) overlayArgs.push('--texthooker');
const controlResult = await sendAppControlCommand(overlayArgs, {
@@ -1176,7 +1181,7 @@ export function launchTexthookerOnly(
): never {
const overlayArgs = ['--texthooker'];
if (args.texthookerOpenBrowser) overlayArgs.push('--open-browser');
if (args.logLevel !== 'info') overlayArgs.push('--log-level', args.logLevel);
if (shouldForwardLogLevel(args.logLevel)) overlayArgs.push('--log-level', args.logLevel);
log('info', args.logLevel, 'Launching texthooker mode...');
const result = runSyncAppCommand(appPath, overlayArgs, true);
@@ -1254,7 +1259,7 @@ function stopManagedOverlayApp(args: Args): void {
log('info', args.logLevel, 'Stopping SubMiner overlay...');
const stopArgs = ['--stop'];
if (args.logLevel !== 'info') stopArgs.push('--log-level', args.logLevel);
if (shouldForwardLogLevel(args.logLevel)) stopArgs.push('--log-level', args.logLevel);
const target = resolveAppSpawnTarget(state.appPath, stopArgs);
const result = spawnSync(target.command, target.args, {
@@ -1306,6 +1311,8 @@ function buildAppEnv(
...baseEnv,
SUBMINER_APP_LOG: getAppLogPath(),
SUBMINER_MPV_LOG: getMpvLogPath(),
SUBMINER_LOG_LEVEL: extraEnv.SUBMINER_LOG_LEVEL ?? baseEnv.SUBMINER_LOG_LEVEL,
SUBMINER_LOG_ROTATION: extraEnv.SUBMINER_LOG_ROTATION ?? baseEnv.SUBMINER_LOG_ROTATION,
};
delete env.ELECTRON_RUN_AS_NODE;
clearTransportedAppArgs(env);
@@ -1326,10 +1333,13 @@ function buildAppEnv(
}
export function buildMpvEnv(
args: Pick<Args, 'backend'>,
args: Pick<Args, 'backend' | 'logLevel' | 'logRotation'>,
baseEnv: NodeJS.ProcessEnv = process.env,
): NodeJS.ProcessEnv {
const env = buildAppEnv(baseEnv);
const env = buildAppEnv(baseEnv, {
SUBMINER_LOG_LEVEL: args.logLevel,
SUBMINER_LOG_ROTATION: String(args.logRotation),
});
if (!shouldForceX11MpvBackend(args, env)) {
return env;
}
@@ -1586,13 +1596,13 @@ export function runAppCommandWithInheritLogged(
export function launchAppStartDetached(appPath: string, logLevel: LogLevel): void {
const startArgs = ['--start'];
if (logLevel !== 'info') startArgs.push('--log-level', logLevel);
if (shouldForwardLogLevel(logLevel)) startArgs.push('--log-level', logLevel);
launchAppCommandDetached(appPath, startArgs, logLevel, 'start');
}
export function launchAppBackgroundDetached(appPath: string, logLevel: LogLevel): void {
const startArgs = ['--start', '--background'];
if (logLevel !== 'info') startArgs.push('--log-level', logLevel);
if (shouldForwardLogLevel(logLevel)) startArgs.push('--log-level', logLevel);
launchAppCommandDetached(appPath, startArgs, logLevel, 'app', {
[BACKGROUND_CHILD_ENV]: '1',
});
@@ -1615,6 +1625,22 @@ export function launchAppCommandDetached(
`${label}: launching detached app with args: ${[target.command, ...target.args].join(' ')}`,
);
const appLogPath = getAppLogPath();
if (!appLogPath) {
try {
const proc = spawn(target.command, target.args, {
stdio: 'ignore',
detached: true,
env: buildAppEnv(process.env, { ...target.env, ...extraEnv }),
});
proc.once('error', (error) => {
log('warn', logLevel, `${label}: failed to launch detached app: ${error.message}`);
});
proc.unref();
} catch (error) {
log('warn', logLevel, `${label}: failed to launch detached app: ${(error as Error).message}`);
}
return;
}
fs.mkdirSync(path.dirname(appLogPath), { recursive: true });
const stdoutFd = fs.openSync(appLogPath, 'a');
const stderrFd = fs.openSync(appLogPath, 'a');
@@ -1673,7 +1699,7 @@ export function launchMpvIdleDetached(
runtimeScriptOpts,
)}`,
);
mpvArgs.push(`--log-file=${getMpvLogPath()}`);
mpvArgs.push(...buildMpvLoggingArgs(args.logLevel, getMpvLogPath(), mpvArgs));
mpvArgs.push(`--input-ipc-server=${socketPath}`);
const mpvTarget = resolveCommandInvocation('mpv', mpvArgs, {
normalizeWindowsShellArgs: false,
+15
View File
@@ -244,3 +244,18 @@ test('parseArgs maps doctor refresh-known-words flag', () => {
assert.equal(parsed.doctor, true);
assert.equal(parsed.doctorRefreshKnownWords, true);
});
test('parseArgs maps logs export flag', () => {
const parsed = parseArgs(['logs', '-e'], 'subminer', {});
assert.equal(parsed.logsExport, true);
});
test('parseArgs requires an explicit logs action', () => {
const exit = withProcessExitIntercept(() => {
parseArgs(['logs'], 'subminer', {});
});
assert.equal(exit.code, 1);
assert.match(exit.stderr, /Logs command requires -e or --export/);
});
+10 -2
View File
@@ -23,6 +23,8 @@ type SmokeCase = {
artifactsDir: string;
binDir: string;
xdgConfigHome: string;
appDataDir: string;
localAppDataDir: string;
homeDir: string;
socketDir: string;
socketPath: string;
@@ -61,6 +63,8 @@ function createSmokeCase(name: string): SmokeCase {
const artifactsDir = path.join(root, 'artifacts');
const binDir = path.join(root, 'bin');
const xdgConfigHome = path.join(root, 'xdg');
const appDataDir = path.join(root, 'AppData', 'Roaming');
const localAppDataDir = path.join(root, 'AppData', 'Local');
const homeDir = path.join(root, 'home');
const socketDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-smoke-sock-'));
const socketPath = path.join(socketDir, 'subminer.sock');
@@ -73,7 +77,7 @@ function createSmokeCase(name: string): SmokeCase {
fs.mkdirSync(binDir, { recursive: true });
fs.writeFileSync(videoPath, 'fake video fixture');
const configDir = getDefaultConfigDir({ xdgConfigHome, homeDir });
const configDir = getDefaultConfigDir({ xdgConfigHome, appDataDir, homeDir });
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(path.join(configDir, 'config.jsonc'), JSON.stringify({ mpv: { socketPath } }));
const setupState = createDefaultSetupState();
@@ -159,6 +163,8 @@ process.exit(0);
artifactsDir,
binDir,
xdgConfigHome,
appDataDir,
localAppDataDir,
homeDir,
socketDir,
socketPath,
@@ -174,6 +180,8 @@ function makeTestEnv(smokeCase: SmokeCase): NodeJS.ProcessEnv {
...process.env,
HOME: smokeCase.homeDir,
XDG_CONFIG_HOME: smokeCase.xdgConfigHome,
APPDATA: smokeCase.appDataDir,
LOCALAPPDATA: smokeCase.localAppDataDir,
SUBMINER_APPIMAGE_PATH: smokeCase.fakeAppPath,
SUBMINER_MPV_LOG: smokeCase.mpvOverlayLogPath,
};
@@ -410,7 +418,7 @@ test(
const env = makeTestEnv(smokeCase);
const result = runLauncher(
smokeCase,
['--backend', 'x11', '--start-overlay', smokeCase.videoPath],
['--backend', 'x11', '--log-level', 'info', '--start-overlay', smokeCase.videoPath],
env,
'overlay-start-stop',
);
+17 -1
View File
@@ -1,7 +1,11 @@
import path from 'node:path';
import os from 'node:os';
import type { MpvBackend, MpvLaunchMode } from '../src/types/config.js';
import { resolveDefaultLogFilePath } from '../src/shared/log-files.js';
import {
resolveDefaultLogFilePath,
type LogFileToggles,
type LogRotation,
} from '../src/shared/log-files.js';
export { VIDEO_EXTENSIONS } from '../src/shared/video-extensions.js';
export const ROFI_THEME_FILE = 'subminer.rasi';
@@ -67,6 +71,9 @@ export const DEFAULT_MPV_SUBMINER_ARGS = [
] as const;
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
export function shouldForwardLogLevel(level: LogLevel): boolean {
return level === 'debug' || level === 'error';
}
export type Backend = 'auto' | 'hyprland' | 'sway' | 'x11' | 'macos' | 'windows';
export type JimakuLanguagePreference = 'ja' | 'en' | 'none';
@@ -106,6 +113,7 @@ export interface Args {
texthookerOpenBrowser: boolean;
useRofi: boolean;
logLevel: LogLevel;
logRotation: LogRotation;
passwordStore: string;
target: string;
targetKind: '' | 'file' | 'url';
@@ -132,6 +140,7 @@ export interface Args {
dictionaryTarget?: string;
doctor: boolean;
doctorRefreshKnownWords: boolean;
logsExport: boolean;
version: boolean;
update?: boolean;
settings: boolean;
@@ -186,10 +195,17 @@ export interface LauncherMpvConfig {
aniskipButtonKey?: string;
}
export interface LauncherLoggingConfig {
level?: LogLevel;
rotation?: LogRotation;
files?: Partial<LogFileToggles>;
}
export interface PluginRuntimeConfig {
socketPath: string;
binaryPath: string;
backend: Backend;
logLevel?: LogLevel;
autoStart: boolean;
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;