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
+3
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { AnkiConnectClient } from './anki-connect';
import { setLogLevel } from './logger';
test('AnkiConnectClient disables keep-alive agents to avoid stale socket retries', () => {
const client = new AnkiConnectClient('http://127.0.0.1:8765') as unknown as {
@@ -36,6 +37,7 @@ test('AnkiConnectClient includes action name in retry logs', async () => {
const originalInfo = console.info;
const messages: string[] = [];
setLogLevel('info');
try {
console.info = (...args: unknown[]) => {
messages.push(args.map((value) => String(value)).join(' '));
@@ -46,6 +48,7 @@ test('AnkiConnectClient includes action name in retry logs', async () => {
assert.match(messages.join('\n'), /AnkiConnect notesInfo retry 1\/3 after 200ms delay/);
} finally {
console.info = originalInfo;
setLogLevel(undefined);
}
});
+15
View File
@@ -105,6 +105,8 @@ test('parseArgs captures session action forwarding flags', () => {
'--shift-sub-delay-next-line',
'--cycle-runtime-option',
'anki.autoUpdateNewCards:prev',
'--session-action',
'{"actionId":"openCharacterDictionaryManager"}',
'--copy-subtitle-count',
'3',
'--mine-sentence-count=2',
@@ -122,6 +124,7 @@ test('parseArgs captures session action forwarding flags', () => {
assert.equal(args.shiftSubDelayNextLine, true);
assert.equal(args.cycleRuntimeOptionId, 'anki.autoUpdateNewCards');
assert.equal(args.cycleRuntimeOptionDirection, -1);
assert.deepEqual(args.sessionAction, { actionId: 'openCharacterDictionaryManager' });
assert.equal(args.copySubtitleCount, 3);
assert.equal(args.mineSentenceCount, 2);
assert.equal(hasExplicitCommand(args), true);
@@ -282,6 +285,18 @@ test('hasExplicitCommand and shouldStartApp preserve command intent', () => {
assert.equal(shouldStartApp(cycleRuntimeOption), true);
assert.equal(commandNeedsOverlayRuntime(cycleRuntimeOption), true);
const sessionAction = parseArgs([
'--session-action',
'{"actionId":"cycleRuntimeOption","payload":{"runtimeOptionId":"anki.autoUpdateNewCards","direction":-1}}',
]);
assert.deepEqual(sessionAction.sessionAction, {
actionId: 'cycleRuntimeOption',
payload: { runtimeOptionId: 'anki.autoUpdateNewCards', direction: -1 },
});
assert.equal(hasExplicitCommand(sessionAction), true);
assert.equal(shouldStartApp(sessionAction), true);
assert.equal(commandNeedsOverlayRuntime(sessionAction), true);
const toggleStatsOverlayRuntime = parseArgs(['--toggle-stats-overlay']);
assert.equal(commandNeedsOverlayRuntime(toggleStatsOverlayRuntime), true);
+39 -8
View File
@@ -1,3 +1,5 @@
import type { SessionActionDispatchRequest } from '../types/runtime';
export interface CliArgs {
background: boolean;
managedPlayback: boolean;
@@ -32,7 +34,6 @@ export interface CliArgs {
toggleSubtitleSidebar: boolean;
openRuntimeOptions: boolean;
openSessionHelp: boolean;
openCharacterDictionary: boolean;
openControllerSelect: boolean;
openControllerDebug: boolean;
openJimaku: boolean;
@@ -44,6 +45,7 @@ export interface CliArgs {
shiftSubDelayNextLine: boolean;
cycleRuntimeOptionId?: string;
cycleRuntimeOptionDirection?: 1 | -1;
sessionAction?: SessionActionDispatchRequest;
copySubtitleCount?: number;
mineSentenceCount?: number;
anilistStatus: boolean;
@@ -139,7 +141,6 @@ export function parseArgs(argv: string[]): CliArgs {
toggleSubtitleSidebar: false,
openRuntimeOptions: false,
openSessionHelp: false,
openCharacterDictionary: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
@@ -212,6 +213,31 @@ export function parseArgs(argv: string[]): CliArgs {
return null;
};
const parseSessionAction = (value: string | undefined): SessionActionDispatchRequest | null => {
if (!value) return null;
try {
const parsed = JSON.parse(value) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
const actionId = (parsed as { actionId?: unknown }).actionId;
if (typeof actionId !== 'string' || actionId.length === 0) return null;
const payload = (parsed as { payload?: unknown }).payload;
if (
payload !== undefined &&
(!payload || typeof payload !== 'object' || Array.isArray(payload))
) {
return null;
}
return payload === undefined
? { actionId: actionId as SessionActionDispatchRequest['actionId'] }
: {
actionId: actionId as SessionActionDispatchRequest['actionId'],
payload: payload as SessionActionDispatchRequest['payload'],
};
} catch {
return null;
}
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (!arg || !arg.startsWith('--')) continue;
@@ -261,7 +287,6 @@ export function parseArgs(argv: string[]): CliArgs {
else if (arg === '--toggle-subtitle-sidebar') args.toggleSubtitleSidebar = true;
else if (arg === '--open-runtime-options') args.openRuntimeOptions = true;
else if (arg === '--open-session-help') args.openSessionHelp = true;
else if (arg === '--open-character-dictionary') args.openCharacterDictionary = true;
else if (arg === '--open-controller-select') args.openControllerSelect = true;
else if (arg === '--open-controller-debug') args.openControllerDebug = true;
else if (arg === '--open-jimaku') args.openJimaku = true;
@@ -283,6 +308,12 @@ export function parseArgs(argv: string[]): CliArgs {
args.cycleRuntimeOptionId = parsed.id;
args.cycleRuntimeOptionDirection = parsed.direction;
}
} else if (arg.startsWith('--session-action=')) {
const parsed = parseSessionAction(arg.slice('--session-action='.length));
if (parsed) args.sessionAction = parsed;
} else if (arg === '--session-action') {
const parsed = parseSessionAction(readValue(argv[i + 1]));
if (parsed) args.sessionAction = parsed;
} else if (arg.startsWith('--copy-subtitle-count=')) {
const value = Number(arg.split('=', 2)[1]);
if (Number.isInteger(value) && value > 0) args.copySubtitleCount = value;
@@ -516,7 +547,6 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
@@ -527,6 +557,7 @@ export function hasExplicitCommand(args: CliArgs): boolean {
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.anilistStatus ||
@@ -591,7 +622,6 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
!args.openCharacterDictionary &&
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
@@ -602,6 +632,7 @@ export function isStandaloneTexthookerCommand(args: CliArgs): boolean {
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined &&
args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined &&
!args.anilistStatus &&
@@ -657,7 +688,6 @@ export function shouldStartApp(args: CliArgs): boolean {
args.toggleSubtitleSidebar ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
@@ -668,6 +698,7 @@ export function shouldStartApp(args: CliArgs): boolean {
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined ||
args.dictionary ||
@@ -717,7 +748,6 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.toggleSubtitleSidebar &&
!args.openRuntimeOptions &&
!args.openSessionHelp &&
!args.openCharacterDictionary &&
!args.openControllerSelect &&
!args.openControllerDebug &&
!args.openJimaku &&
@@ -728,6 +758,7 @@ export function shouldRunYomitanOnlyStartup(args: CliArgs): boolean {
!args.shiftSubDelayPrevLine &&
!args.shiftSubDelayNextLine &&
args.cycleRuntimeOptionId === undefined &&
args.sessionAction === undefined &&
args.copySubtitleCount === undefined &&
args.mineSentenceCount === undefined &&
!args.anilistStatus &&
@@ -782,7 +813,6 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.markAudioCard ||
args.openRuntimeOptions ||
args.openSessionHelp ||
args.openCharacterDictionary ||
args.openControllerSelect ||
args.openControllerDebug ||
args.openJimaku ||
@@ -793,6 +823,7 @@ export function commandNeedsOverlayRuntime(args: CliArgs): boolean {
args.shiftSubDelayPrevLine ||
args.shiftSubDelayNextLine ||
args.cycleRuntimeOptionId !== undefined ||
args.sessionAction !== undefined ||
args.copySubtitleCount !== undefined ||
args.mineSentenceCount !== undefined
);
-1
View File
@@ -43,7 +43,6 @@ ${B}Mining${R}
--toggle-subtitle-sidebar Toggle subtitle sidebar panel
--open-runtime-options Open runtime options palette
--open-session-help Open session help modal
--open-character-dictionary Open character dictionary management modal
--open-controller-select Open controller select modal
--open-controller-debug Open controller debug modal
+108 -4
View File
@@ -63,7 +63,6 @@ test('loads defaults when config is missing', () => {
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.equal(config.ankiConnect.media.audioPadding, 0);
assert.equal(config.anilist.enabled, false);
assert.equal(config.anilist.characterDictionary.enabled, false);
assert.equal(config.subtitleStyle.nameMatchImagesEnabled, false);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
@@ -96,7 +95,6 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal('openCharacterDictionary' in config.shortcuts, false);
assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
assert.equal(config.discordPresence.enabled, true);
@@ -825,7 +823,6 @@ test('parses anilist.characterDictionary config with clamping and enum validatio
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.anilist.characterDictionary.enabled, true);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1);
assert.equal(config.anilist.characterDictionary.maxLoaded, 20);
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
@@ -1462,6 +1459,50 @@ test('accepts valid logging.level', () => {
assert.equal(config.logging.level, 'warn');
});
test('accepts valid logging.rotation', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"rotation": 14
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.logging.rotation, 14);
});
test('accepts valid logging file toggles', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"files": {
"app": false,
"launcher": true,
"mpv": true
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.logging.files, {
app: false,
launcher: true,
mpv: true,
});
});
test('falls back for invalid logging.level and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -1482,6 +1523,68 @@ test('falls back for invalid logging.level and reports warning', () => {
assert.ok(warnings.some((warning) => warning.path === 'logging.level'));
});
test('falls back for invalid logging.rotation and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"rotation": 0
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.logging.rotation, DEFAULT_CONFIG.logging.rotation);
assert.ok(warnings.some((warning) => warning.path === 'logging.rotation'));
});
test('falls back for invalid logging file toggles and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"files": {
"mpv": "yes"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.logging.files.mpv, DEFAULT_CONFIG.logging.files.mpv);
assert.ok(warnings.some((warning) => warning.path === 'logging.files.mpv'));
});
test('falls back for invalid logging files object and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"files": false
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(config.logging.files, DEFAULT_CONFIG.logging.files);
assert.ok(warnings.some((warning) => warning.path === 'logging.files'));
});
test('warns and ignores unknown top-level config keys', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -2518,6 +2621,7 @@ test('template generator includes known keys', () => {
assert.doesNotMatch(output, /"clientVersion":/);
assert.doesNotMatch(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/);
assert.doesNotMatch(output, /"characterDictionary":\s*\{\s*"enabled":/);
assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"knownWords"\s*:\s*\{/);
assert.match(output, /"knownWordColor": "#a6da95"/);
@@ -2527,7 +2631,7 @@ test('template generator includes known keys', () => {
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
assert.match(
output,
/"level": "info",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/,
/"level": "warn",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/,
);
assert.match(
output,
+7 -1
View File
@@ -28,7 +28,13 @@ export const CORE_DEFAULT_CONFIG: Pick<
port: 6678,
},
logging: {
level: 'info',
level: 'warn',
rotation: 7,
files: {
app: true,
launcher: true,
mpv: false,
},
},
texthooker: {
launchAtStartup: false,
@@ -110,7 +110,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
enabled: false,
accessToken: '',
characterDictionary: {
enabled: false,
refreshTtlHours: 168,
maxLoaded: 3,
evictionPolicy: 'delete',
@@ -92,6 +92,7 @@ test('config option registry includes critical paths and has unique entries', ()
for (const requiredPath of [
'logging.level',
'logging.files.mpv',
'annotationWebsocket.enabled',
'controller.enabled',
'controller.scrollPixelsPerSecond',
@@ -101,7 +102,7 @@ test('config option registry includes critical paths and has unique entries', ()
'subtitleStyle.enableJlpt',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
'ankiConnect.enabled',
'anilist.characterDictionary.enabled',
'subtitleStyle.nameMatchEnabled',
'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath',
'mpv.launchMode',
+24
View File
@@ -83,6 +83,30 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.logging.level,
description: 'Minimum log level for runtime logging.',
},
{
path: 'logging.rotation',
kind: 'number',
defaultValue: defaultConfig.logging.rotation,
description: 'Number of days of app, launcher, and mpv logs to retain.',
},
{
path: 'logging.files.app',
kind: 'boolean',
defaultValue: defaultConfig.logging.files.app,
description: 'Write SubMiner app runtime logs.',
},
{
path: 'logging.files.launcher',
kind: 'boolean',
defaultValue: defaultConfig.logging.files.launcher,
description: 'Write launcher command logs.',
},
{
path: 'logging.files.mpv',
kind: 'boolean',
defaultValue: defaultConfig.logging.files.mpv,
description: 'Write mpv player logs. Enable temporarily when debugging mpv/plugin startup.',
},
{
path: 'youtube.primarySubLanguages',
kind: 'string',
@@ -392,13 +392,6 @@ export function buildIntegrationConfigOptionRegistry(
description:
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
},
{
path: 'anilist.characterDictionary.enabled',
kind: 'boolean',
defaultValue: defaultConfig.anilist.characterDictionary.enabled,
description:
'Enable automatic Yomitan character dictionary sync for currently watched AniList media.',
},
{
path: 'anilist.characterDictionary.refreshTtlHours',
kind: 'number',
@@ -426,7 +419,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'enum',
enumValues: ['all', 'active'],
defaultValue: defaultConfig.anilist.characterDictionary.profileScope,
description: 'Yomitan profile scope for dictionary enable/disable updates.',
description: 'Yomitan profile scope for character dictionary settings updates.',
},
{
path: 'anilist.characterDictionary.collapsibleSections.description',
+1 -1
View File
@@ -74,7 +74,7 @@ export function buildSubtitleConfigOptionRegistry(
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.nameMatchEnabled,
description:
'Enable subtitle token coloring for matches from the SubMiner character dictionary.',
'Enable character dictionary sync and subtitle token coloring for character-name matches.',
},
{
path: 'subtitleStyle.nameMatchImagesEnabled',
+1 -1
View File
@@ -33,7 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Logging',
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
notes: ['Hot-reload: logging.level applies live while SubMiner is running.'],
notes: ['Hot-reload: logging.level and logging.files apply live while SubMiner is running.'],
key: 'logging',
},
{
+30
View File
@@ -100,6 +100,36 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'Expected debug, info, warn, or error.',
);
}
const logRotation = src.logging.rotation;
if (typeof logRotation === 'number' && Number.isInteger(logRotation) && logRotation > 0) {
resolved.logging.rotation = logRotation;
} else if (src.logging.rotation !== undefined) {
warn(
'logging.rotation',
src.logging.rotation,
resolved.logging.rotation,
'Expected a positive whole number of days.',
);
}
if (isObject(src.logging.files)) {
for (const key of ['app', 'launcher', 'mpv'] as const) {
const enabled = asBoolean(src.logging.files[key]);
if (enabled !== undefined) {
resolved.logging.files[key] = enabled;
} else if (src.logging.files[key] !== undefined) {
warn(
`logging.files.${key}`,
src.logging.files[key],
resolved.logging.files[key],
'Expected boolean.',
);
}
}
} else if (src.logging.files !== undefined) {
warn('logging.files', src.logging.files, resolved.logging.files, 'Expected object.');
}
}
applyControllerConfig(context);
-12
View File
@@ -81,18 +81,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
if (isObject(src.anilist.characterDictionary)) {
const characterDictionary = src.anilist.characterDictionary;
const dictionaryEnabled = asBoolean(characterDictionary.enabled);
if (dictionaryEnabled !== undefined) {
resolved.anilist.characterDictionary.enabled = dictionaryEnabled;
} else if (characterDictionary.enabled !== undefined) {
warn(
'anilist.characterDictionary.enabled',
characterDictionary.enabled,
resolved.anilist.characterDictionary.enabled,
'Expected boolean.',
);
}
const refreshTtlHours = asNumber(characterDictionary.refreshTtlHours);
if (refreshTtlHours !== undefined) {
const normalized = Math.min(24 * 365, Math.max(1, Math.floor(refreshTtlHours)));
-2
View File
@@ -97,7 +97,6 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
const { context, warnings } = createResolveContext({
anilist: {
characterDictionary: {
enabled: true,
refreshTtlHours: 0,
maxLoaded: 99,
evictionPolicy: 'purge' as never,
@@ -113,7 +112,6 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
applyIntegrationConfig(context);
assert.equal(context.resolved.anilist.characterDictionary.enabled, true);
assert.equal(context.resolved.anilist.characterDictionary.refreshTtlHours, 1);
assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20);
assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete');
+5 -6
View File
@@ -55,10 +55,6 @@ test('settings registry moves AniSkip button key into input shortcuts and hot re
});
test('settings registry exposes character dictionary panel shortcuts dynamically', () => {
assert.equal(
fields.some((candidate) => candidate.configPath === 'shortcuts.openCharacterDictionary'),
false,
);
assert.equal(
field('shortcuts.openCharacterDictionaryManager').label,
'Open Character Dictionary Manager',
@@ -69,7 +65,6 @@ test('settings registry exposes character dictionary panel shortcuts dynamically
test('settings registry hides removed modal-only fields', () => {
for (const path of [
'shortcuts.multiCopyTimeoutMs',
'shortcuts.openCharacterDictionary',
'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers',
]) {
@@ -265,7 +260,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
]) {
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
}
assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary');
assert.equal(paths.has('anilist.characterDictionary.enabled'), false);
});
test('settings registry marks safe live config paths as hot-reloadable', () => {
@@ -274,6 +269,10 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'logging.rotation',
'logging.files.app',
'logging.files.launcher',
'logging.files.mpv',
'youtube.primarySubLanguages',
'jimaku.apiBaseUrl',
'jimaku.languagePreference',
+8
View File
@@ -63,6 +63,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.enabled',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.profileScope',
@@ -184,6 +185,11 @@ const PATH_ORDER = new Map<string, number>(
'mpv.launchMode',
'mpv.executablePath',
'mpv.aniskipButtonKey',
'logging.level',
'logging.rotation',
'logging.files.app',
'logging.files.launcher',
'logging.files.mpv',
].map((path, index) => [path, index]),
);
@@ -667,6 +673,8 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
path === 'logging.rotation' ||
pathStartsWith(path, 'logging.files') ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync')
-1
View File
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
+24 -17
View File
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
refreshKnownWords: false,
openRuntimeOptions: false,
openSessionHelp: false,
openCharacterDictionary: false,
openControllerSelect: false,
openControllerDebug: false,
openJimaku: false,
@@ -785,6 +784,30 @@ test('handleCliCommand dispatches cycle-runtime-option session action', async ()
});
});
test('handleCliCommand dispatches generic session action payloads', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(
makeArgs({
sessionAction: {
actionId: 'openCharacterDictionaryManager',
},
}),
'initial',
deps,
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'openCharacterDictionaryManager',
});
});
test('handleCliCommand dispatches mark-watched session action', async () => {
let request: unknown = null;
const { deps } = createDeps({
@@ -801,22 +824,6 @@ test('handleCliCommand dispatches mark-watched session action', async () => {
});
});
test('handleCliCommand opens character dictionary manager from CLI flag', async () => {
let request: unknown = null;
const { deps } = createDeps({
dispatchSessionAction: async (nextRequest) => {
request = nextRequest;
},
});
handleCliCommand(makeArgs({ openCharacterDictionary: true }), 'initial', deps);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(request, {
actionId: 'openCharacterDictionaryManager',
});
});
test('handleCliCommand logs AniList status details', () => {
const { deps, calls } = createDeps();
handleCliCommand(makeArgs({ anilistStatus: true }), 'initial', deps);
+7 -7
View File
@@ -384,7 +384,13 @@ export function handleCliCommand(
deps.log(`Starting MPV IPC connection on socket: ${socketPath}`);
}
if (args.toggle || args.toggleVisibleOverlay) {
if (args.sessionAction) {
dispatchCliSessionAction(
args.sessionAction,
`sessionAction:${args.sessionAction.actionId}`,
'Session action failed',
);
} else if (args.toggle || args.toggleVisibleOverlay) {
deps.toggleVisibleOverlay();
} else if (args.togglePrimarySubtitleBar) {
deps.togglePrimarySubtitleBar();
@@ -490,12 +496,6 @@ export function handleCliCommand(
'openSessionHelp',
'Open session help failed',
);
} else if (args.openCharacterDictionary) {
dispatchCliSessionAction(
{ actionId: 'openCharacterDictionaryManager' },
'openCharacterDictionaryManager',
'Open character dictionary failed',
);
} else if (args.openControllerSelect) {
dispatchCliSessionAction(
{ actionId: 'openControllerSelect' },
@@ -25,6 +25,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
next.stats.toggleKey = 'F8';
next.stats.markWatchedKey = 'F9';
next.logging.level = 'debug';
next.logging.rotation = 14;
next.logging.files.mpv = true;
next.youtube.primarySubLanguages = ['ja', 'en'];
next.jimaku.maxEntryResults = prev.jimaku.maxEntryResults + 1;
next.subsync.replace = !prev.subsync.replace;
@@ -56,6 +58,8 @@ test('classifyConfigHotReloadDiff treats safe nested config paths as hot-reloada
'mpv.aniskipButtonKey',
'stats.markWatchedKey',
'logging.level',
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'jimaku.maxEntryResults',
'subsync.replace',
+2
View File
@@ -61,6 +61,8 @@ const HOT_RELOAD_EXACT_OR_PREFIX_PATHS = [
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'logging.rotation',
'logging.files',
'youtube.primarySubLanguages',
'jimaku',
'subsync',
+76
View File
@@ -23,6 +23,37 @@ function makeDeps(overrides: Partial<MpvIpcClientProtocolDeps> = {}): MpvIpcClie
};
}
function captureWarnLogs(run: () => void): string[] {
const originalWarn = console.warn;
const originalLogLevel = process.env.SUBMINER_LOG_LEVEL;
const originalAppLog = process.env.SUBMINER_APP_LOG;
const messages: string[] = [];
console.warn = (...args: unknown[]) => {
messages.push(args.map(String).join(' '));
};
process.env.SUBMINER_LOG_LEVEL = 'warn';
process.env.SUBMINER_APP_LOG = process.platform === 'win32' ? 'NUL' : '/dev/null';
try {
run();
} finally {
console.warn = originalWarn;
if (originalLogLevel === undefined) {
delete process.env.SUBMINER_LOG_LEVEL;
} else {
process.env.SUBMINER_LOG_LEVEL = originalLogLevel;
}
if (originalAppLog === undefined) {
delete process.env.SUBMINER_APP_LOG;
} else {
process.env.SUBMINER_APP_LOG = originalAppLog;
}
}
return messages;
}
function invokeHandleMessage(client: MpvIpcClient, msg: unknown): Promise<void> {
return (client as unknown as { handleMessage: (msg: unknown) => Promise<void> }).handleMessage(
msg,
@@ -401,6 +432,51 @@ test('MpvIpcClient onClose requests app quit for managed playback', () => {
assert.equal(quitRequests, 1);
});
test('MpvIpcClient only warns once for repeated post-disconnect socket failures', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = () => true;
(client as any).scheduleReconnect = () => {};
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
const messages = captureWarnLogs(() => {
callbacks.onClose();
for (let index = 0; index < 3; index += 1) {
const error = Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), {
code: 'ENOENT',
});
callbacks.onError(error);
callbacks.onClose();
}
});
assert.equal(messages.filter((message) => message.includes('MPV IPC socket closed')).length, 1);
assert.equal(messages.filter((message) => message.includes('MPV IPC socket error')).length, 1);
});
test('MpvIpcClient warns again after MPV reconnects and disconnects later', () => {
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
(client as any).send = () => true;
(client as any).scheduleReconnect = () => {};
const callbacks = (client as any).transport.callbacks;
callbacks.onConnect();
const messages = captureWarnLogs(() => {
callbacks.onClose();
callbacks.onError(Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), { code: 'ENOENT' }));
callbacks.onClose();
callbacks.onConnect();
callbacks.onClose();
callbacks.onError(Object.assign(new Error('connect ENOENT /tmp/mpv.sock'), { code: 'ENOENT' }));
callbacks.onClose();
});
assert.equal(messages.filter((message) => message.includes('MPV IPC socket closed')).length, 2);
assert.equal(messages.filter((message) => message.includes('MPV IPC socket error')).length, 2);
});
test('MpvIpcClient reconnect replays property subscriptions and initial state requests', () => {
const commands: unknown[] = [];
const client = new MpvIpcClient('/tmp/mpv.sock', makeDeps());
+69 -6
View File
@@ -136,6 +136,7 @@ type MpvIpcClientEventName = keyof MpvIpcClientEventMap;
export class MpvIpcClient implements MpvClient {
private deps: MpvIpcClientProtocolDeps;
private transport: MpvSocketTransport;
private socketPath: string;
public socket: ReturnType<MpvSocketTransport['getSocket']> = null;
private eventBus = new EventEmitter();
private buffer = '';
@@ -144,6 +145,7 @@ export class MpvIpcClient implements MpvClient {
private reconnectAttempt = 0;
private firstConnection = true;
private hasConnectedOnce = false;
private socketErrorWarnedForDisconnect = false;
public currentVideoPath = '';
public currentMediaTitle: string | null = null;
public currentTimePos = 0;
@@ -180,23 +182,30 @@ export class MpvIpcClient implements MpvClient {
constructor(socketPath: string, deps: MpvIpcClientDeps) {
this.deps = deps;
this.socketPath = socketPath;
this.transport = new MpvSocketTransport({
socketPath,
onConnect: () => {
logger.debug('Connected to MPV socket');
this.connected = true;
this.connecting = false;
this.socket = this.transport.getSocket();
this.reconnectAttempt = 0;
this.hasConnectedOnce = true;
this.socketErrorWarnedForDisconnect = false;
const resolvedConfig = this.deps.getResolvedConfig();
logger.info('MPV IPC socket connected', {
socketPath: this.socketPath,
autoStartOverlay: this.deps.autoStartOverlay,
configAutoStartOverlay: resolvedConfig.auto_start_overlay === true,
});
this.setSecondarySubVisibility(false);
subscribeToMpvProperties(this.send.bind(this));
requestMpvInitialState(this.send.bind(this));
this.emit('connection-change', { connected: true });
const shouldAutoStart =
this.deps.autoStartOverlay || this.deps.getResolvedConfig().auto_start_overlay === true;
this.deps.autoStartOverlay || resolvedConfig.auto_start_overlay === true;
if (this.firstConnection && shouldAutoStart) {
logger.debug('Auto-starting overlay, hiding mpv subtitles');
setTimeout(() => {
@@ -211,18 +220,30 @@ export class MpvIpcClient implements MpvClient {
this.processBuffer();
},
onError: (err: Error) => {
logger.debug('MPV socket error:', err.message);
this.logSocketError(err);
this.failPendingRequests();
},
onClose: () => {
logger.debug('MPV socket closed');
const wasConnected = this.connected;
const shouldQuitOnMpvShutdown = this.deps.shouldQuitOnMpvShutdown?.() === true;
if (wasConnected) {
logger.warn('MPV IPC socket closed', {
socketPath: this.socketPath,
shouldQuitOnMpvShutdown,
});
} else {
logger.debug('MPV IPC socket closed before first connection', {
socketPath: this.socketPath,
reconnectAttempt: this.reconnectAttempt,
});
}
this.connected = false;
this.connecting = false;
this.socket = null;
this.playbackPaused = null;
this.emit('connection-change', { connected: false });
this.failPendingRequests();
if (this.deps.shouldQuitOnMpvShutdown?.() === true) {
if (shouldQuitOnMpvShutdown) {
this.deps.requestAppQuit?.();
return;
}
@@ -261,6 +282,13 @@ export class MpvIpcClient implements MpvClient {
}
setSocketPath(socketPath: string): void {
if (socketPath !== this.socketPath) {
logger.info('MPV IPC socket path updated', {
previousSocketPath: this.socketPath,
socketPath,
});
}
this.socketPath = socketPath;
this.transport.setSocketPath(socketPath);
}
@@ -299,7 +327,9 @@ export class MpvIpcClient implements MpvClient {
getReconnectTimer: () => this.deps.getReconnectTimer(),
setReconnectTimer: (timer) => this.deps.setReconnectTimer(timer),
onReconnectAttempt: (attempt, delay) => {
logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`);
logger.debug(`Attempting to reconnect to MPV (attempt ${attempt}, delay ${delay}ms)...`, {
socketPath: this.socketPath,
});
},
connect: () => {
this.connect();
@@ -307,6 +337,39 @@ export class MpvIpcClient implements MpvClient {
});
}
private shouldLogPreConnectionFailure(): boolean {
const nextAttempt = this.reconnectAttempt + 1;
return nextAttempt <= 3 || nextAttempt % 10 === 0;
}
private logSocketError(err: Error): void {
const errorWithCode = err as Error & { code?: unknown };
const details = {
socketPath: this.socketPath,
reconnectAttempt: this.reconnectAttempt,
hasConnectedOnce: this.hasConnectedOnce,
message: err.message,
code: typeof errorWithCode.code === 'string' ? errorWithCode.code : undefined,
};
if (!this.hasConnectedOnce) {
if (this.shouldLogPreConnectionFailure()) {
logger.warn('MPV IPC socket error', details);
return;
}
logger.debug('MPV IPC socket error', details);
return;
}
if (!this.socketErrorWarnedForDisconnect) {
this.socketErrorWarnedForDisconnect = true;
logger.warn('MPV IPC socket error', details);
return;
}
logger.debug('MPV IPC socket error', details);
}
private processBuffer(): void {
const parsed = splitMpvMessagesFromBuffer(
this.buffer,
@@ -46,9 +46,6 @@ function createDeps(overrides: Partial<OverlayShortcutRuntimeDeps> = {}) {
openRuntimeOptions: () => {
calls.push('openRuntimeOptions');
},
openCharacterDictionary: () => {
calls.push('openCharacterDictionary');
},
openCharacterDictionaryManager: () => {
calls.push('openCharacterDictionaryManager');
},
@@ -163,7 +160,6 @@ test('runOverlayShortcutLocalFallback dispatches matching single-step actions',
},
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
@@ -197,7 +193,6 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
(_input, accelerator) => accelerator === 'Ctrl+M',
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
@@ -218,7 +213,6 @@ test('runOverlayShortcutLocalFallback leaves multi-step numeric shortcuts for re
(_input, accelerator) => accelerator === 'Ctrl+N',
{
openRuntimeOptions: () => handled.push('openRuntimeOptions'),
openCharacterDictionary: () => handled.push('openCharacterDictionary'),
openCharacterDictionaryManager: () => handled.push('openCharacterDictionaryManager'),
openJimaku: () => handled.push('openJimaku'),
markAudioCard: () => handled.push('markAudioCard'),
@@ -256,7 +250,6 @@ test('runOverlayShortcutLocalFallback passes allowWhenRegistered for secondary-s
},
{
openRuntimeOptions: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openJimaku: () => {},
markAudioCard: () => {},
@@ -293,7 +286,6 @@ test('runOverlayShortcutLocalFallback allows registered-global jimaku shortcut',
},
{
openRuntimeOptions: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openJimaku: () => {},
markAudioCard: () => {},
@@ -322,9 +314,6 @@ test('runOverlayShortcutLocalFallback returns false when no action matches', ()
openRuntimeOptions: () => {
called = true;
},
openCharacterDictionary: () => {
called = true;
},
openCharacterDictionaryManager: () => {
called = true;
},
@@ -410,7 +399,6 @@ test('registerOverlayShortcutsRuntime reports active shortcuts when configured',
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
@@ -438,7 +426,6 @@ test('unregisterOverlayShortcutsRuntime clears pending shortcut work when active
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
@@ -6,7 +6,6 @@ const logger = createLogger('main:overlay-shortcut-handler');
export interface OverlayShortcutFallbackHandlers {
openRuntimeOptions: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
markAudioCard: () => void;
@@ -23,7 +22,6 @@ export interface OverlayShortcutFallbackHandlers {
export interface OverlayShortcutRuntimeDeps {
showMpvOsd: (text: string) => void;
openRuntimeOptions: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
@@ -99,9 +97,6 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
openRuntimeOptions: () => {
deps.openRuntimeOptions();
},
openCharacterDictionary: () => {
deps.openCharacterDictionary();
},
openCharacterDictionaryManager: () => {
deps.openCharacterDictionaryManager();
},
@@ -112,7 +107,6 @@ export function createOverlayShortcutRuntimeHandlers(deps: OverlayShortcutRuntim
const fallbackHandlers: OverlayShortcutFallbackHandlers = {
openRuntimeOptions: overlayHandlers.openRuntimeOptions,
openCharacterDictionary: overlayHandlers.openCharacterDictionary,
openCharacterDictionaryManager: overlayHandlers.openCharacterDictionaryManager,
openJimaku: overlayHandlers.openJimaku,
markAudioCard: overlayHandlers.markAudioCard,
@@ -43,7 +43,6 @@ test('registerOverlayShortcuts reports active overlay shortcuts when configured'
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
@@ -64,7 +63,6 @@ test('registerOverlayShortcuts stays inactive when overlay shortcuts are absent'
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
@@ -87,7 +85,6 @@ test('syncOverlayShortcutsRuntime deactivates cleanly when shortcuts were active
mineSentenceMultiple: () => {},
toggleSecondarySub: () => {},
markAudioCard: () => {},
openCharacterDictionary: () => {},
openCharacterDictionaryManager: () => {},
openRuntimeOptions: () => {},
openJimaku: () => {},
-1
View File
@@ -10,7 +10,6 @@ export interface OverlayShortcutHandlers {
mineSentenceMultiple: (timeoutMs: number) => void;
toggleSecondarySub: () => void;
markAudioCard: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openRuntimeOptions: () => void;
openJimaku: () => void;
@@ -34,7 +34,6 @@ function createDeps(overrides: Partial<SessionActionExecutorDeps> = {}) {
},
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openSessionHelp: () => calls.push('session-help'),
openCharacterDictionary: () => calls.push('character-dictionary'),
openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'),
openControllerSelect: () => calls.push('controller-select'),
openControllerDebug: () => calls.push('controller-debug'),
-4
View File
@@ -18,7 +18,6 @@ export interface SessionActionExecutorDeps {
markActiveVideoWatched: () => Promise<boolean>;
openRuntimeOptionsPalette: () => void;
openSessionHelp: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openControllerSelect: () => void;
openControllerDebug: () => void;
@@ -97,9 +96,6 @@ export async function dispatchSessionAction(
case 'openSessionHelp':
deps.openSessionHelp();
return;
case 'openCharacterDictionary':
deps.openCharacterDictionaryManager();
return;
case 'openCharacterDictionaryManager':
deps.openCharacterDictionaryManager();
return;
+50 -5
View File
@@ -4,7 +4,7 @@ import type { Keybinding } from '../../types';
import type { ConfiguredShortcuts } from '../utils/shortcut-config';
import { DEFAULT_CONFIG, DEFAULT_KEYBINDINGS, SPECIAL_COMMANDS } from '../../config/definitions';
import { resolveConfiguredShortcuts } from '../utils/shortcut-config';
import { compileSessionBindings } from './session-bindings';
import { buildPluginSessionBindingsArtifact, compileSessionBindings } from './session-bindings';
function createShortcuts(overrides: Partial<ConfiguredShortcuts> = {}): ConfiguredShortcuts {
return {
@@ -220,10 +220,7 @@ test('compileSessionBindings keeps only the character dictionary manager bound b
const characterDictionaryBindings = result.bindings.flatMap((binding) => {
if (binding.actionType !== 'session-action') return [];
if (
binding.actionId !== 'openCharacterDictionary' &&
binding.actionId !== 'openCharacterDictionaryManager'
) {
if (binding.actionId !== 'openCharacterDictionaryManager') {
return [];
}
return [
@@ -471,3 +468,51 @@ test('compileSessionBindings wires every configured shortcut key into the shared
shortcutKeys.map((key) => `shortcuts.${key}`).sort(),
);
});
test('buildPluginSessionBindingsArtifact emits CLI args for plugin-bound session actions', () => {
const result = compileSessionBindings({
shortcuts: createShortcuts({
openCharacterDictionaryManager: 'Ctrl+D',
}),
keybindings: [
createKeybinding('Ctrl+Alt+KeyR', [
`${SPECIAL_COMMANDS.RUNTIME_OPTION_CYCLE_PREFIX}anki.autoUpdateNewCards:prev`,
]),
],
platform: 'linux',
});
const artifact = buildPluginSessionBindingsArtifact({
bindings: result.bindings,
warnings: result.warnings,
numericSelectionTimeoutMs: 2500,
now: new Date('2026-05-26T00:00:00.000Z'),
});
const byActionId = new Map(
artifact.bindings.flatMap((binding) =>
binding.actionType === 'session-action' ? [[binding.actionId, binding]] : [],
),
);
const compiledManagerBinding = result.bindings.find(
(binding) =>
binding.actionType === 'session-action' &&
binding.actionId === 'openCharacterDictionaryManager',
);
assert.equal(compiledManagerBinding && 'cliArgs' in compiledManagerBinding, false);
const managerCliArgs = byActionId.get('openCharacterDictionaryManager')?.cliArgs;
const cycleCliArgs = byActionId.get('cycleRuntimeOption')?.cliArgs;
assert.equal(managerCliArgs?.[0], '--session-action');
assert.deepEqual(JSON.parse(managerCliArgs?.[1] ?? ''), {
actionId: 'openCharacterDictionaryManager',
});
assert.equal(cycleCliArgs?.[0], '--session-action');
assert.deepEqual(JSON.parse(cycleCliArgs?.[1] ?? ''), {
actionId: 'cycleRuntimeOption',
payload: {
runtimeOptionId: 'anki.autoUpdateNewCards',
direction: -1,
},
});
});
+18 -1
View File
@@ -4,6 +4,7 @@ import type {
CompiledMpvCommandBinding,
CompiledSessionActionBinding,
CompiledSessionBinding,
PluginSessionBinding,
PluginSessionBindingsArtifact,
SessionActionId,
SessionBindingWarning,
@@ -344,6 +345,22 @@ function getBindingFingerprint(binding: CompiledSessionBinding): string {
return `session:${binding.actionId}:${JSON.stringify(binding.payload ?? null)}`;
}
function buildSessionActionCliArgs(binding: CompiledSessionActionBinding): string[] {
const request =
binding.payload === undefined
? { actionId: binding.actionId }
: { actionId: binding.actionId, payload: binding.payload };
return ['--session-action', JSON.stringify(request)];
}
function toPluginSessionBinding(binding: CompiledSessionBinding): PluginSessionBinding {
if (binding.actionType !== 'session-action') {
return binding;
}
return { ...binding, cliArgs: buildSessionActionCliArgs(binding) };
}
export function compileSessionBindings(input: CompileSessionBindingsInput): {
bindings: CompiledSessionBinding[];
warnings: SessionBindingWarning[];
@@ -516,7 +533,7 @@ export function buildPluginSessionBindingsArtifact(input: {
version: 1,
generatedAt: (input.now ?? new Date()).toISOString(),
numericSelectionTimeoutMs: input.numericSelectionTimeoutMs,
bindings: input.bindings,
bindings: input.bindings.map(toPluginSessionBinding),
warnings: input.warnings,
};
}
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
+13
View File
@@ -29,6 +29,7 @@ export interface StartupBootstrapRuntimeDeps {
argv: string[];
parseArgs: (argv: string[]) => CliArgs;
setLogLevel: (level: string, source: LogLevelSource) => void;
setLogRotation?: (rotation: number) => void;
forceX11Backend: (args: CliArgs) => void;
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
getDefaultSocketPath: () => string;
@@ -95,6 +96,12 @@ interface AppReadyConfigLike {
};
logging?: {
level?: 'debug' | 'info' | 'warn' | 'error';
rotation?: number;
files?: {
app?: boolean;
launcher?: boolean;
mpv?: boolean;
};
};
}
@@ -115,6 +122,10 @@ export interface AppReadyRuntimeDeps {
getConfigWarnings: () => ConfigValidationWarning[];
logConfigWarning: (warning: ConfigValidationWarning) => void;
setLogLevel: (level: string, source: LogLevelSource) => void;
setLogRotation?: (rotation: number) => void;
setLogFileToggles?: (
files: { app?: boolean; launcher?: boolean; mpv?: boolean } | undefined,
) => void;
initRuntimeOptionsManager: () => void;
setSecondarySubMode: (mode: SecondarySubMode) => void;
defaultSecondarySubMode: SecondarySubMode;
@@ -263,6 +274,8 @@ export async function runAppReadyRuntime(deps: AppReadyRuntimeDeps): Promise<voi
}
deps.setLogLevel(config.logging?.level ?? 'info', 'config');
deps.setLogRotation?.(config.logging?.rotation ?? 7);
deps.setLogFileToggles?.(config.logging?.files);
for (const warning of deps.getConfigWarnings()) {
deps.logConfigWarning(warning);
}
+3
View File
@@ -1,6 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { PartOfSpeech } from '../../types';
import { setLogLevel } from '../../logger';
import { createTokenizerDepsRuntime, TokenizerServiceDeps, tokenizeSubtitle } from './tokenizer';
function makeDeps(overrides: Partial<TokenizerServiceDeps> = {}): TokenizerServiceDeps {
@@ -1865,6 +1866,7 @@ test('tokenizeSubtitle uses Yomitan parser result when available and drops no-he
test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled', async () => {
const infoLogs: string[] = [];
const originalInfo = console.info;
setLogLevel('info');
console.info = (...args: unknown[]) => {
infoLogs.push(args.map((value) => String(value)).join(' '));
};
@@ -1912,6 +1914,7 @@ test('tokenizeSubtitle logs selected Yomitan groups when debug toggle is enabled
);
} finally {
console.info = originalInfo;
setLogLevel(undefined);
}
assert.ok(infoLogs.some((line) => line.includes('Selected Yomitan token groups')));
@@ -601,6 +601,47 @@ test('requestYomitanScanTokens prefers parseText tokenization over termsFind fra
assert.ok(scripts.some((script) => script.includes('termsFind')));
});
test('requestYomitanScanTokens warns when active Yomitan profile has no dictionaries', async () => {
const warnings: Array<{ message: string; details: unknown }> = [];
const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) {
return {
profileCurrent: 0,
profiles: [
{
options: {
scanning: { length: 40 },
dictionaries: [],
},
},
],
};
}
if (script.includes('parseText')) {
return [];
}
if (script.includes('termsFind')) {
return [];
}
return null;
});
await requestYomitanScanTokens('字幕', deps, {
error: () => undefined,
warn: (message, details) => warnings.push({ message, details }),
});
assert.equal(warnings.length, 1);
assert.match(warnings[0]!.message, /no enabled dictionaries/);
assert.deepEqual(warnings[0]!.details, {
profileIndex: 0,
scanLength: 40,
dictionaryCount: 0,
dictionaries: [],
omittedDictionaryCount: 0,
});
});
test('requestYomitanScanTokens keeps scanner metadata when parse spans agree', async () => {
const deps = createDeps(async (script) => {
if (script.includes('optionsGetFull')) {
@@ -7,6 +7,7 @@ import { selectYomitanParseTokens } from './parser-selection-stage';
interface LoggerLike {
error: (message: string, ...args: unknown[]) => void;
info?: (message: string, ...args: unknown[]) => void;
warn?: (message: string, ...args: unknown[]) => void;
}
interface YomitanParserRuntimeDeps {
@@ -72,6 +73,7 @@ export interface YomitanAddNoteResult {
const DEFAULT_YOMITAN_SCAN_LENGTH = 40;
const yomitanProfileMetadataByWindow = new WeakMap<BrowserWindow, YomitanProfileMetadata>();
const yomitanProfileDiagnosticsLoggedByWindow = new WeakSet<BrowserWindow>();
const yomitanFrequencyCacheByWindow = new WeakMap<
BrowserWindow,
Map<string, YomitanTermFrequency[]>
@@ -532,6 +534,7 @@ async function requestYomitanProfileMetadata(
return null;
}
yomitanProfileMetadataByWindow.set(parserWindow, metadata);
logYomitanProfileDiagnostics(parserWindow, metadata, logger);
return metadata;
} catch (err) {
logger.error('Yomitan parser metadata request failed:', (err as Error).message);
@@ -539,6 +542,37 @@ async function requestYomitanProfileMetadata(
}
}
function logYomitanProfileDiagnostics(
parserWindow: BrowserWindow,
metadata: YomitanProfileMetadata,
logger: LoggerLike,
): void {
if (yomitanProfileDiagnosticsLoggedByWindow.has(parserWindow)) {
return;
}
yomitanProfileDiagnosticsLoggedByWindow.add(parserWindow);
const visibleDictionaries = metadata.dictionaries.slice(0, 8);
const details = {
profileIndex: metadata.profileIndex,
scanLength: metadata.scanLength,
dictionaryCount: metadata.dictionaries.length,
dictionaries: visibleDictionaries,
omittedDictionaryCount: Math.max(0, metadata.dictionaries.length - visibleDictionaries.length),
};
if (metadata.dictionaries.length === 0) {
const logWarning = logger.warn ?? logger.info;
logWarning?.(
'Yomitan active profile has no enabled dictionaries; lookup popups may not show definitions.',
details,
);
return;
}
logger.info?.('Yomitan active profile dictionaries loaded.', details);
}
async function ensureYomitanParserWindow(
deps: YomitanParserRuntimeDeps,
logger: LoggerLike,
+12 -3
View File
@@ -14,6 +14,10 @@ type ExtensionCopyResult = {
copied: boolean;
};
type ExtensionCopyOptions = {
platform?: NodeJS.Platform;
};
const asyncExtensionCopyInFlight = new Map<string, Promise<ExtensionCopyResult>>();
function readManifestVersion(manifestPath: string): string | null {
@@ -142,8 +146,12 @@ export function shouldCopyYomitanExtension(sourceDir: string, targetDir: string)
return sourceHash === null || targetHash === null || sourceHash !== targetHash;
}
export function ensureExtensionCopy(sourceDir: string, userDataPath: string): ExtensionCopyResult {
if (process.platform === 'win32') {
export function ensureExtensionCopy(
sourceDir: string,
userDataPath: string,
options?: ExtensionCopyOptions,
): ExtensionCopyResult {
if ((options?.platform ?? process.platform) === 'win32') {
return { targetDir: sourceDir, copied: false };
}
@@ -167,8 +175,9 @@ export function ensureExtensionCopy(sourceDir: string, userDataPath: string): Ex
export async function ensureExtensionCopyAsync(
sourceDir: string,
userDataPath: string,
options?: ExtensionCopyOptions,
): Promise<ExtensionCopyResult> {
if (process.platform === 'win32') {
if ((options?.platform ?? process.platform) === 'win32') {
return { targetDir: sourceDir, copied: false };
}
@@ -135,7 +135,7 @@ test('ensureExtensionCopy refreshes copied extension when display files change',
'old display code',
);
const result = ensureExtensionCopy(sourceDir, userDataRoot);
const result = ensureExtensionCopy(sourceDir, userDataRoot, { platform: 'linux' });
assert.equal(result.targetDir, targetDir);
assert.equal(result.copied, true);
@@ -170,7 +170,9 @@ test('ensureExtensionCopyAsync refreshes copied extension without completing syn
);
let completed = false;
const resultPromise = ensureExtensionCopyAsync(sourceDir, userDataRoot).then((result) => {
const resultPromise = ensureExtensionCopyAsync(sourceDir, userDataRoot, {
platform: 'linux',
}).then((result) => {
completed = true;
return result;
});
@@ -233,9 +235,9 @@ test('ensureExtensionCopyAsync shares an in-flight refresh for the same copied e
});
try {
const first = ensureExtensionCopyAsync(sourceDir, userDataRoot);
const first = ensureExtensionCopyAsync(sourceDir, userDataRoot, { platform: 'linux' });
await firstCopyStartedPromise;
const second = ensureExtensionCopyAsync(sourceDir, userDataRoot);
const second = ensureExtensionCopyAsync(sourceDir, userDataRoot, { platform: 'linux' });
releaseFirstCopy();
const results = await Promise.all([first, second]);
@@ -142,6 +142,10 @@ export async function loadYomitanExtension(
}
targetSession = session.fromPath(resolvedProfilePath);
logger.info('Loading Yomitan extension from external profile', {
profilePath: resolvedProfilePath,
extensionPath: extPath,
});
} else {
const searchPaths = getYomitanExtensionSearchPaths({
explicitPath: deps.extensionPath,
@@ -174,6 +178,10 @@ export async function loadYomitanExtension(
logger.debug(`Copied yomitan extension to ${extensionCopy.targetDir}`);
}
extPath = extensionCopy.targetDir;
logger.info('Loading bundled Yomitan extension', {
extensionPath: extPath,
copied: extensionCopy.copied,
});
}
clearParserState();
@@ -191,6 +199,12 @@ export async function loadYomitanExtension(
}),
);
deps.setYomitanExtension(extension);
logger.info('Yomitan extension loaded', {
extensionId: extension.id,
extensionName: extension.name,
extensionPath: extPath,
externalProfile: externalProfilePath.length > 0,
});
return extension;
} catch (err) {
logger.error('Failed to load Yomitan extension:', (err as Error).message);
+7
View File
@@ -3,6 +3,7 @@ import fs from 'node:fs';
import path from 'node:path';
import test from 'node:test';
import {
generateConfigExampleTemplate,
resolveConfigExampleOutputPaths,
writeConfigExampleArtifacts,
} from './generate-config-example';
@@ -80,3 +81,9 @@ test('writeConfigExampleArtifacts creates parent directories for resolved output
fs.rmSync(workspace, { recursive: true, force: true });
}
});
test('generateConfigExampleTemplate uses the canonical example socket path', () => {
const template = generateConfigExampleTemplate();
assert.match(template, /"socketPath": "\\\\\\\\.\\\\pipe\\\\subminer-socket"/);
});
+11 -2
View File
@@ -1,6 +1,15 @@
import * as fs from 'fs';
import * as path from 'path';
import { DEFAULT_CONFIG, generateConfigTemplate } from './config';
import { DEFAULT_CONFIG, deepCloneConfig, generateConfigTemplate } from './config';
import { getDefaultMpvSocketPath } from './shared/mpv-socket-path';
const CONFIG_EXAMPLE_PLATFORM: NodeJS.Platform = 'win32';
export function generateConfigExampleTemplate(): string {
const config = deepCloneConfig(DEFAULT_CONFIG);
config.mpv.socketPath = getDefaultMpvSocketPath(CONFIG_EXAMPLE_PLATFORM);
return generateConfigTemplate(config);
}
type ConfigExampleFsDeps = {
existsSync?: (candidate: string) => boolean;
@@ -54,7 +63,7 @@ export function writeConfigExampleArtifacts(
}
function main(): void {
const template = generateConfigTemplate(DEFAULT_CONFIG);
const template = generateConfigExampleTemplate();
writeConfigExampleArtifacts(template);
}
+35 -3
View File
@@ -1,9 +1,10 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import path from 'node:path';
import { resolveDefaultLogFilePath } from './logger';
import { resolveDefaultLogFilePath, setLogRotation } from './logger';
test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
const today = new Date().toISOString().slice(0, 10);
const resolved = resolveDefaultLogFilePath({
platform: 'win32',
homeDir: 'C:\\Users\\tester',
@@ -17,13 +18,14 @@ test('resolveDefaultLogFilePath uses APPDATA on windows', () => {
'C:\\Users\\tester\\AppData\\Roaming',
'SubMiner',
'logs',
`app-${new Date().toISOString().slice(0, 10)}.log`,
`app-${today}.log`,
),
),
);
});
test('resolveDefaultLogFilePath uses .config on linux', () => {
const today = new Date().toISOString().slice(0, 10);
const resolved = resolveDefaultLogFilePath({
platform: 'linux',
homeDir: '/home/tester',
@@ -36,7 +38,37 @@ test('resolveDefaultLogFilePath uses .config on linux', () => {
'.config',
'SubMiner',
'logs',
`app-${new Date().toISOString().slice(0, 10)}.log`,
`app-${today}.log`,
),
);
});
test('setLogRotation accepts numeric retention days', () => {
const previous = process.env.SUBMINER_LOG_ROTATION;
const today = new Date().toISOString().slice(0, 10);
setLogRotation(14);
try {
const resolved = resolveDefaultLogFilePath({
platform: 'linux',
homeDir: '/home/tester',
});
assert.equal(
resolved,
path.join(
'/home/tester',
'.config',
'SubMiner',
'logs',
`app-${today}.log`,
),
);
assert.equal(process.env.SUBMINER_LOG_ROTATION, '14');
} finally {
if (previous == null) {
delete process.env.SUBMINER_LOG_ROTATION;
} else {
process.env.SUBMINER_LOG_ROTATION = previous;
}
}
});
+22 -2
View File
@@ -1,5 +1,11 @@
import {
appendLogLine,
applyLogFileTogglesToEnv,
DEFAULT_LOG_ROTATION,
isLogFileEnabled,
normalizeLogRotation,
type LogFileToggles,
type LogRotation,
resolveDefaultLogFilePath as resolveSharedDefaultLogFilePath,
} from './shared/log-files';
@@ -24,10 +30,11 @@ const LEVEL_PRIORITY: Record<LogLevel, number> = {
error: 40,
};
const DEFAULT_LOG_LEVEL: LogLevel = 'info';
const DEFAULT_LOG_LEVEL: LogLevel = 'warn';
let cliLogLevel: LogLevel | undefined;
let configLogLevel: LogLevel | undefined;
let configLogRotation: LogRotation = DEFAULT_LOG_ROTATION;
function pad(value: number): string {
return String(value).padStart(2, '0');
@@ -76,6 +83,15 @@ export function setLogLevel(level: string | undefined, source: LogLevelSource =
}
}
export function setLogRotation(rotation: unknown): void {
configLogRotation = normalizeLogRotation(rotation) ?? DEFAULT_LOG_ROTATION;
process.env.SUBMINER_LOG_ROTATION = String(configLogRotation);
}
export function setLogFileToggles(files: Partial<LogFileToggles> | undefined): void {
applyLogFileTogglesToEnv(files);
}
function normalizeError(error: Error): { message: string; stack?: string } {
return {
message: error.message,
@@ -124,12 +140,16 @@ export function resolveDefaultLogFilePath(options?: {
platform?: NodeJS.Platform;
homeDir?: string;
appDataDir?: string;
rotation?: unknown;
}): string {
return resolveSharedDefaultLogFilePath('app', options);
}
function appendToLogFile(line: string): void {
appendLogLine(resolveLogFilePath(), line);
if (!isLogFileEnabled('app')) {
return;
}
appendLogLine(resolveLogFilePath(), line, { rotation: configLogRotation });
}
function emit(level: LogLevel, scope: string, message: string, meta: unknown[]): void {
+11 -1
View File
@@ -2,21 +2,28 @@ import path from 'node:path';
import { loadRawConfigStrict } from './config/load';
import { resolveConfig } from './config/resolve';
import type { MpvLaunchMode, ResolvedConfig } from './types/config';
import type { LogFileToggles, LogRotation } from './shared/log-files';
import type { SharedLogLevel } from './shared/mpv-logging-args';
import type { SubminerPluginRuntimeScriptOptConfig } from './shared/subminer-plugin-script-opts';
export interface ConfiguredWindowsMpvLaunch {
executablePath: string;
launchMode: MpvLaunchMode;
logLevel: SharedLogLevel;
logRotation: LogRotation;
logFiles: LogFileToggles;
pluginRuntimeConfig: SubminerPluginRuntimeScriptOptConfig;
}
export function buildWindowsMpvPluginRuntimeConfig(
config: Pick<ResolvedConfig, 'auto_start_overlay' | 'mpv' | 'texthooker'>,
config: Pick<ResolvedConfig, 'auto_start_overlay' | 'logging' | 'mpv' | 'texthooker'>,
): SubminerPluginRuntimeScriptOptConfig {
return {
socketPath: config.mpv.socketPath,
binaryPath: config.mpv.subminerBinaryPath,
backend: config.mpv.backend,
logLevel: config.logging.level,
logRotation: config.logging.rotation,
autoStart: config.mpv.autoStartSubMiner,
autoStartVisibleOverlay: config.auto_start_overlay,
autoStartPauseUntilReady: config.mpv.pauseUntilOverlayReady,
@@ -38,6 +45,9 @@ export function readConfiguredWindowsMpvLaunch(configDir: string): ConfiguredWin
return {
executablePath: resolved.mpv.executablePath,
launchMode: resolved.mpv.launchMode,
logLevel: resolved.logging.level,
logRotation: resolved.logging.rotation,
logFiles: resolved.logging.files,
pluginRuntimeConfig: buildWindowsMpvPluginRuntimeConfig(resolved),
};
}
+17
View File
@@ -313,10 +313,15 @@ test('readConfiguredWindowsMpvLaunch includes defaults for runtime plugin script
assert.equal(launch.executablePath, DEFAULT_CONFIG.mpv.executablePath);
assert.equal(launch.launchMode, DEFAULT_CONFIG.mpv.launchMode);
assert.equal(launch.logLevel, DEFAULT_CONFIG.logging.level);
assert.equal(launch.logRotation, DEFAULT_CONFIG.logging.rotation);
assert.deepEqual(launch.logFiles, DEFAULT_CONFIG.logging.files);
assert.deepEqual(launch.pluginRuntimeConfig, {
socketPath: DEFAULT_CONFIG.mpv.socketPath,
binaryPath: DEFAULT_CONFIG.mpv.subminerBinaryPath,
backend: DEFAULT_CONFIG.mpv.backend,
logLevel: DEFAULT_CONFIG.logging.level,
logRotation: DEFAULT_CONFIG.logging.rotation,
autoStart: DEFAULT_CONFIG.mpv.autoStartSubMiner,
autoStartVisibleOverlay: DEFAULT_CONFIG.auto_start_overlay,
autoStartPauseUntilReady: DEFAULT_CONFIG.mpv.pauseUntilOverlayReady,
@@ -339,6 +344,13 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script
texthooker: {
launchAtStartup: true,
},
logging: {
level: 'debug',
rotation: 14,
files: {
mpv: true,
},
},
mpv: {
executablePath: ' C:\\tools\\mpv.exe ',
launchMode: 'maximized',
@@ -357,10 +369,15 @@ test('readConfiguredWindowsMpvLaunch preserves configured runtime plugin script
assert.equal(launch.executablePath, 'C:\\tools\\mpv.exe');
assert.equal(launch.launchMode, 'maximized');
assert.equal(launch.logLevel, 'debug');
assert.equal(launch.logRotation, 14);
assert.equal(launch.logFiles.mpv, true);
assert.deepEqual(launch.pluginRuntimeConfig, {
socketPath: '\\\\.\\pipe\\custom-subminer-socket',
binaryPath: 'C:\\SubMiner\\Custom.exe',
backend: 'windows',
logLevel: 'debug',
logRotation: 14,
autoStart: false,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: false,
+31 -1
View File
@@ -31,9 +31,30 @@ import {
import { createWindowsMpvLaunchDeps, launchWindowsMpv } from './main/runtime/windows-mpv-launch';
import { runStatsDaemonControlFromProcess } from './stats-daemon-entry';
import { createFatalErrorReporter, registerFatalErrorHandlers } from './main/fatal-error';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import {
applyLogFileTogglesToEnv,
isLogFileEnabled,
appendLogLine,
pruneLogDirectoryForPath,
resolveDefaultLogFilePath,
type LogRotation,
} from './shared/log-files';
const DEFAULT_TEXTHOOKER_PORT = 5174;
function appendWindowsMpvLaunchLog(message: string, logRotation?: LogRotation): void {
if (!isLogFileEnabled('app')) {
return;
}
const timestamp = new Date().toISOString().replace('T', ' ').slice(0, 19);
appendLogLine(
process.env.SUBMINER_APP_LOG?.trim() || resolveDefaultLogFilePath('app'),
`[subminer] - ${timestamp} - INFO - [main:windows-mpv-launch] ${message}`,
{ rotation: logRotation },
);
}
function applySanitizedEnv(sanitizedEnv: NodeJS.ProcessEnv): void {
if (sanitizedEnv.NODE_NO_WARNINGS) {
process.env.NODE_NO_WARNINGS = sanitizedEnv.NODE_NO_WARNINGS;
@@ -216,6 +237,14 @@ async function runEntryProcess(): Promise<void> {
applySanitizedEnv(sanitizedEnv);
await app.whenReady();
const configuredMpvLaunch = readConfiguredWindowsMpvLaunch(userDataPath);
const extraArgs = normalizeLaunchMpvExtraArgs(process.argv);
applyLogFileTogglesToEnv(configuredMpvLaunch.logFiles);
const mpvLogPath = isLogFileEnabled('mpv')
? process.env.SUBMINER_MPV_LOG?.trim() || resolveDefaultLogFilePath('mpv')
: '';
if (mpvLogPath) {
pruneLogDirectoryForPath(mpvLogPath, configuredMpvLaunch.logRotation);
}
const result = await launchWindowsMpv(
normalizeLaunchMpvTargets(process.argv),
createWindowsMpvLaunchDeps({
@@ -223,8 +252,9 @@ async function runEntryProcess(): Promise<void> {
showError: (title, content) => {
dialog.showErrorBox(title, content);
},
logInfo: (message) => appendWindowsMpvLaunchLog(message, configuredMpvLaunch.logRotation),
}),
normalizeLaunchMpvExtraArgs(process.argv),
[...extraArgs, ...buildMpvLoggingArgs(configuredMpvLaunch.logLevel, mpvLogPath, extraArgs)],
process.execPath,
resolveBundledWindowsMpvPluginEntrypoint(),
configuredMpvLaunch.executablePath,
+123 -35
View File
@@ -123,10 +123,16 @@ import { RuntimeOptionsManager } from './runtime-options';
import { downloadToFile, isRemoteMediaPath, parseMediaInfo } from './jimaku/utils';
import {
createLogger,
setLogFileToggles,
setLogLevel,
resolveDefaultLogFilePath,
setLogRotation,
type LogLevelSource,
} from './logger';
import {
isLogFileEnabled,
pruneLogDirectoryForPath,
resolveDefaultLogFilePath,
} from './shared/log-files';
import { createFatalErrorReporter } from './main/fatal-error';
import { createWindowTracker as createWindowTrackerCore } from './window-trackers';
import {
@@ -148,6 +154,7 @@ import {
} from './cli/args';
import { printHelp } from './cli/help';
import { IPC_CHANNELS, type OverlayHostedModal } from './shared/ipc/contracts';
import { buildMpvLoggingArgs } from './shared/mpv-logging-args';
import { AnkiConnectClient } from './anki-connect';
import {
getStartupModeFlags,
@@ -258,6 +265,7 @@ import {
createMpvOsdRuntimeHandlers,
createCycleSecondarySubModeRuntimeHandler,
} from './main/runtime/domains/mpv';
import { buildSubtitleTrackDiagnostics } from './main/runtime/mpv-track-diagnostics';
import {
createBuildCopyCurrentSubtitleMainDepsHandler,
createBuildHandleMineSentenceDigitMainDepsHandler,
@@ -430,6 +438,7 @@ import {
shouldQuitOnMpvShutdownForTrayState,
shouldQuitOnWindowAllClosedForTrayState,
} from './main/runtime/startup-tray-policy';
import { exportLogsArchive } from './main/runtime/log-export';
import { createImmersionTrackerStartupHandler } from './main/runtime/immersion-startup';
import { createBuildImmersionTrackerStartupMainDepsHandler } from './main/runtime/immersion-startup-main-deps';
import {
@@ -500,10 +509,7 @@ import { openRuntimeOptionsModal as openRuntimeOptionsModalRuntime } from './mai
import { openJimakuModal as openJimakuModalRuntime } from './main/runtime/jimaku-open';
import { openSubsyncManualModal as openSubsyncManualModalRuntime } from './main/runtime/subsync-open';
import { openSessionHelpModal as openSessionHelpModalRuntime } from './main/runtime/session-help-open';
import {
openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime,
openCharacterDictionaryModal as openCharacterDictionaryModalRuntime,
} from './main/runtime/character-dictionary-open';
import { openCharacterDictionaryManagerModal as openCharacterDictionaryManagerModalRuntime } from './main/runtime/character-dictionary-open';
import { openControllerSelectModal as openControllerSelectModalRuntime } from './main/runtime/controller-select-open';
import { openControllerDebugModal as openControllerDebugModalRuntime } from './main/runtime/controller-debug-open';
import { createPlaylistBrowserIpcRuntime } from './main/runtime/playlist-browser-ipc';
@@ -532,6 +538,7 @@ import {
} from './main/runtime/character-dictionary-auto-sync';
import { handleCharacterDictionaryAutoSyncComplete } from './main/runtime/character-dictionary-auto-sync-completion';
import { notifyCharacterDictionaryAutoSyncStatus } from './main/runtime/character-dictionary-auto-sync-notifications';
import { openCharacterDictionaryManagerWithConfigGate } from './main/runtime/character-dictionary-manager-gate';
import { createCurrentMediaTokenizationGate } from './main/runtime/current-media-tokenization-gate';
import { resolveCurrentSubtitleForRenderer } from './main/runtime/current-subtitle-snapshot';
import { createJellyfinSubtitleCacheIo } from './main/runtime/jellyfin-subtitle-cache-io';
@@ -641,7 +648,7 @@ if (process.platform === 'linux') {
app.setName('SubMiner');
const DEFAULT_TEXTHOOKER_PORT = 5174;
const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath({
const DEFAULT_MPV_LOG_FILE = resolveDefaultLogFilePath('mpv', {
platform: process.platform,
homeDir: os.homedir(),
appDataDir: process.env.APPDATA,
@@ -723,7 +730,7 @@ const isDev = process.argv.includes('--dev') || process.argv.includes('--debug')
const texthookerService = new Texthooker(() => {
const config = getResolvedConfig();
const characterDictionaryEnabled =
config.anilist.characterDictionary.enabled &&
config.subtitleStyle.nameMatchEnabled &&
yomitanProfilePolicy.isCharacterDictionaryEnabled();
const knownWordColoringEnabled = getRuntimeBooleanOption(
'subtitle.annotation.knownWords.highlightEnabled',
@@ -1295,17 +1302,24 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
await ensureYoutubePlaybackRuntimeReady();
},
resolveYoutubePlaybackUrl: (url, format) => resolveYoutubePlaybackUrl(url, format),
launchWindowsMpv: (playbackUrl, args) =>
launchWindowsMpv(
launchWindowsMpv: (playbackUrl, args) => {
const config = getResolvedConfig();
setLogFileToggles(config.logging.files);
const mpvLogPath = isLogFileEnabled('mpv') ? resolveDefaultLogFilePath('mpv') : '';
if (mpvLogPath) {
pruneLogDirectoryForPath(mpvLogPath, config.logging.rotation);
}
const mpvArgs = [...args, ...buildMpvLoggingArgs(config.logging.level, mpvLogPath, args)];
return launchWindowsMpv(
[playbackUrl],
createWindowsMpvLaunchDeps({
showError: (title, content) => dialog.showErrorBox(title, content),
}),
[...args, `--log-file=${DEFAULT_MPV_LOG_PATH}`],
mpvArgs,
process.execPath,
resolveBundledMpvRuntimePluginEntrypoint(),
getResolvedConfig().mpv.executablePath,
getResolvedConfig().mpv.launchMode,
config.mpv.executablePath,
config.mpv.launchMode,
{
detectInstalledMpvPlugin: detectWindowsInstalledMpvPlugin,
notifyInstalledPluginDetected: logInstalledMpvPluginDetected,
@@ -1313,7 +1327,8 @@ const youtubePlaybackRuntime = createYoutubePlaybackRuntime({
promptForLegacyMpvPluginRemovalBeforeWindowsLaunch(mpvPath, detection),
},
getMpvPluginRuntimeConfig(),
),
);
},
waitForYoutubeMpvConnected: (timeoutMs) => waitForYoutubeMpvConnected(timeoutMs),
prepareYoutubePlaybackInMpv: (request) => prepareYoutubePlaybackInMpv(request),
runYoutubePlaybackFlow: (request) => youtubeFlowRuntime.runYoutubePlaybackFlow(request),
@@ -1705,6 +1720,7 @@ const subtitleProcessingController = createSubtitleProcessingController(
let subtitlePrefetchService: SubtitlePrefetchService | null = null;
let subtitlePrefetchRefreshTimer: ReturnType<typeof setTimeout> | null = null;
let lastObservedTimePos = 0;
let lastObservedPrimarySubtitleTrackId: number | null = null;
let cancelLinuxMpvFullscreenOverlayRefreshBurst: CancelLinuxMpvFullscreenOverlayRefreshBurst | null =
null;
const SEEK_THRESHOLD_SECONDS = 3;
@@ -1913,9 +1929,6 @@ const overlayShortcutsRuntime = createOverlayShortcutsRuntimeService(
openRuntimeOptionsPalette: () => {
openRuntimeOptionsPalette();
},
openCharacterDictionary: () => {
openCharacterDictionaryOverlay();
},
openCharacterDictionaryManager: () => {
openCharacterDictionaryManagerOverlay();
},
@@ -2003,6 +2016,12 @@ const buildConfigHotReloadAppliedMainDepsHandler = createBuildConfigHotReloadApp
setLogLevel: (level) => {
setLogLevel(level, 'config');
},
setLogRotation: (rotation) => {
setLogRotation(rotation);
},
setLogFileToggles: (files) => {
setLogFileToggles(files);
},
},
);
const applyConfigHotReloadDiff = createConfigHotReloadAppliedHandler(
@@ -2217,7 +2236,7 @@ const characterDictionaryAutoSyncRuntime = createCharacterDictionaryAutoSyncRunt
const config = getResolvedConfig().anilist.characterDictionary;
return {
enabled:
config.enabled &&
getResolvedConfig().subtitleStyle.nameMatchEnabled &&
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
!isYoutubePlaybackActiveNow(),
maxLoaded: config.maxLoaded,
@@ -2931,20 +2950,21 @@ function openSessionHelpOverlay(): void {
);
}
function openCharacterDictionaryOverlay(): void {
openOverlayHostedModalWithOsd(
openCharacterDictionaryModalRuntime,
'Character dictionary overlay unavailable.',
'Failed to open character dictionary overlay.',
);
}
function openCharacterDictionaryManagerOverlay(): void {
openOverlayHostedModalWithOsd(
openCharacterDictionaryManagerModalRuntime,
'Character dictionary manager unavailable.',
'Failed to open character dictionary manager.',
);
openCharacterDictionaryManagerWithConfigGate({
isCharacterDictionaryEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
getNotificationType: () => getResolvedConfig().ankiConnect.behavior.notificationType,
openManager: () => {
openOverlayHostedModalWithOsd(
openCharacterDictionaryManagerModalRuntime,
'Character dictionary manager unavailable.',
'Failed to open character dictionary manager.',
);
},
showOsd: (message) => showMpvOsd(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
logWarn: (message, error) => logger.warn(message, error),
});
}
function openControllerSelectOverlay(): void {
@@ -3054,7 +3074,7 @@ const {
mpvExecutablePath: getResolvedConfig().mpv.executablePath,
}),
getPluginRuntimeConfig: () => getMpvPluginRuntimeConfig(),
defaultMpvLogPath: DEFAULT_MPV_LOG_PATH,
getDefaultMpvLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''),
defaultMpvArgs: MPV_JELLYFIN_DEFAULT_ARGS,
removeSocketPath: (socketPath) => {
fs.rmSync(socketPath, { force: true });
@@ -4312,6 +4332,8 @@ const { appReadyRuntimeRunner } = composeAppReadyRuntime({
getConfigWarnings: () => configService.getWarnings(),
logConfigWarning: (warning) => appLogger.logConfigWarning(warning),
setLogLevel: (level: string, source: LogLevelSource) => setLogLevel(level, source),
setLogRotation: (rotation: number) => setLogRotation(rotation),
setLogFileToggles: (files) => setLogFileToggles(files),
initRuntimeOptionsManager: () => {
appState.runtimeOptionsManager = new RuntimeOptionsManager(
() => configService.getConfig().ankiConnect,
@@ -4638,7 +4660,11 @@ const {
markJellyfinRemotePlaybackLoadedState(activeJellyfinRemotePlayback, path);
},
scheduleCharacterDictionarySync: () => {
if (!yomitanProfilePolicy.isCharacterDictionaryEnabled() || isYoutubePlaybackActiveNow()) {
if (
!getResolvedConfig().subtitleStyle.nameMatchEnabled ||
!yomitanProfilePolicy.isCharacterDictionaryEnabled() ||
isYoutubePlaybackActiveNow()
) {
return;
}
characterDictionaryAutoSyncRuntime.scheduleSync();
@@ -4674,10 +4700,23 @@ const {
);
},
onSubtitleTrackChange: (sid) => {
lastObservedPrimarySubtitleTrackId = sid;
logger.info('[mpv-subtitles] primary subtitle track changed', { sid });
scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackChange(sid);
},
onSubtitleTrackListChange: (trackList) => {
const diagnostics = buildSubtitleTrackDiagnostics(
lastObservedPrimarySubtitleTrackId,
trackList,
);
if (!diagnostics.trackListReadable) {
logger.warn('[mpv-subtitles] mpv reported an unreadable subtitle track list', diagnostics);
} else if (diagnostics.subtitleTrackCount === 0) {
logger.warn('[mpv-subtitles] mpv reported no subtitle tracks', diagnostics);
} else {
logger.info('[mpv-subtitles] subtitle track list updated', diagnostics);
}
managedLocalSubtitleSelectionRuntime.handleSubtitleTrackListChange(trackList);
scheduleSubtitlePrefetchRefresh();
youtubePrimarySubtitleNotificationRuntime.handleSubtitleTrackListChange(trackList);
@@ -4763,7 +4802,7 @@ const {
getResolvedConfig().subtitleStyle.enableJlpt,
),
getCharacterDictionaryEnabled: () =>
getResolvedConfig().anilist.characterDictionary.enabled &&
getResolvedConfig().subtitleStyle.nameMatchEnabled &&
yomitanProfilePolicy.isCharacterDictionaryEnabled() &&
!isYoutubePlaybackActiveNow(),
getNameMatchEnabled: () => getResolvedConfig().subtitleStyle.nameMatchEnabled,
@@ -5131,6 +5170,53 @@ function openYomitanSettings(): boolean {
return true;
}
function describeUnknownError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
async function exportLogsFromTray(): Promise<void> {
try {
await flushMpvLog();
} catch (error) {
logger.warn('Failed to flush mpv log before exporting logs from tray.', error);
}
try {
const result = exportLogsArchive({
platform: process.platform,
homeDir: os.homedir(),
appDataDir: app.getPath('appData'),
});
logger.info(
`Exported ${result.exportedFiles.length} sanitized log file(s) to ${result.zipPath}`,
);
void dialog
.showMessageBox({
type: 'info',
title: 'SubMiner logs exported',
message: 'SubMiner log export created.',
detail: result.zipPath,
buttons: ['OK', 'Show in Folder'],
defaultId: 0,
cancelId: 0,
})
.then((response) => {
if (response.response === 1) {
shell.showItemInFolder(result.zipPath);
}
});
} catch (error) {
const message = describeUnknownError(error);
logger.warn('Failed to export logs from tray.', error);
void dialog.showMessageBox({
type: 'error',
title: 'SubMiner log export failed',
message: 'Could not export SubMiner logs.',
detail: message,
});
}
}
const {
getConfiguredShortcuts,
registerGlobalShortcuts,
@@ -5241,7 +5327,7 @@ function refreshCurrentSessionBindings(): void {
const { flushMpvLog, showMpvOsd } = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: {
logPath: DEFAULT_MPV_LOG_PATH,
getLogPath: () => (isLogFileEnabled('mpv') ? DEFAULT_MPV_LOG_PATH : ''),
dirname: (targetPath) => path.dirname(targetPath),
mkdir: async (targetPath, options) => {
await fs.promises.mkdir(targetPath, options);
@@ -5757,7 +5843,6 @@ async function dispatchSessionAction(request: SessionActionDispatchRequest): Pro
openRuntimeOptionsPalette: () => openRuntimeOptionsPalette(),
openJimaku: () => openJimakuOverlay(),
openSessionHelp: () => openSessionHelpOverlay(),
openCharacterDictionary: () => openCharacterDictionaryOverlay(),
openCharacterDictionaryManager: () => openCharacterDictionaryManagerOverlay(),
openControllerSelect: () => openControllerSelectOverlay(),
openControllerDebug: () => openControllerDebugOverlay(),
@@ -6436,6 +6521,9 @@ const { ensureTray: ensureTrayHandler, destroyTray: destroyTrayHandler } =
showWindowsMpvLauncherSetup: () => process.platform === 'win32',
openYomitanSettings: () => openYomitanSettings(),
openConfigSettingsWindow: () => openConfigSettingsWindow(),
exportLogs: () => {
void exportLogsFromTray();
},
openJellyfinSetupWindow: () => openJellyfinSetupWindow(),
isJellyfinConfigured: () =>
isJellyfinConfiguredForTrayRuntime(getJellyfinTrayDiscoveryDeps()),
+4
View File
@@ -40,6 +40,8 @@ export interface AppReadyRuntimeDepsFactoryInput {
startTexthooker: AppReadyRuntimeDeps['startTexthooker'];
log: AppReadyRuntimeDeps['log'];
setLogLevel: AppReadyRuntimeDeps['setLogLevel'];
setLogRotation?: AppReadyRuntimeDeps['setLogRotation'];
setLogFileToggles?: AppReadyRuntimeDeps['setLogFileToggles'];
createMecabTokenizerAndCheck: AppReadyRuntimeDeps['createMecabTokenizerAndCheck'];
createSubtitleTimingTracker: AppReadyRuntimeDeps['createSubtitleTimingTracker'];
createImmersionTracker?: AppReadyRuntimeDeps['createImmersionTracker'];
@@ -107,6 +109,8 @@ export function createAppReadyRuntimeDeps(
startTexthooker: params.startTexthooker,
log: params.log,
setLogLevel: params.setLogLevel,
setLogRotation: params.setLogRotation,
setLogFileToggles: params.setLogFileToggles,
createMecabTokenizerAndCheck: params.createMecabTokenizerAndCheck,
createSubtitleTimingTracker: params.createSubtitleTimingTracker,
createImmersionTracker: params.createImmersionTracker,
+1 -180
View File
@@ -1,24 +1,8 @@
import * as fs from 'fs';
import * as path from 'path';
import { writeStoredZip } from '../../shared/stored-zip';
import { ensureDir } from './fs-utils';
import type { CharacterDictionarySnapshotImage, CharacterDictionaryTermEntry } from './types';
type ZipEntry = {
name: string;
crc32: number;
size: number;
localHeaderOffset: number;
};
function writeUint32LE(buffer: Buffer, value: number, offset: number): number {
const normalized = value >>> 0;
buffer[offset] = normalized & 0xff;
buffer[offset + 1] = (normalized >>> 8) & 0xff;
buffer[offset + 2] = (normalized >>> 16) & 0xff;
buffer[offset + 3] = (normalized >>> 24) & 0xff;
return offset + 4;
}
export function buildDictionaryTitle(mediaId: number): string {
return `SubMiner Character Dictionary (AniList ${mediaId})`;
}
@@ -47,169 +31,6 @@ function createTagBank(): Array<[string, string, number, string, number]> {
];
}
const CRC32_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let crc = i;
for (let j = 0; j < 8; j += 1) {
crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
table[i] = crc >>> 0;
}
return table;
})();
function crc32(data: Buffer): number {
let crc = 0xffffffff;
for (const byte of data) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function createLocalFileHeader(fileName: Buffer, fileCrc32: number, fileSize: number): Buffer {
const local = Buffer.alloc(30 + fileName.length);
let cursor = 0;
writeUint32LE(local, 0x04034b50, cursor);
cursor += 4;
local.writeUInt16LE(20, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
writeUint32LE(local, fileCrc32, cursor);
cursor += 4;
writeUint32LE(local, fileSize, cursor);
cursor += 4;
writeUint32LE(local, fileSize, cursor);
cursor += 4;
local.writeUInt16LE(fileName.length, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
fileName.copy(local, cursor);
return local;
}
function createCentralDirectoryHeader(entry: ZipEntry): Buffer {
const fileName = Buffer.from(entry.name, 'utf8');
const central = Buffer.alloc(46 + fileName.length);
let cursor = 0;
writeUint32LE(central, 0x02014b50, cursor);
cursor += 4;
central.writeUInt16LE(20, cursor);
cursor += 2;
central.writeUInt16LE(20, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
writeUint32LE(central, entry.crc32, cursor);
cursor += 4;
writeUint32LE(central, entry.size, cursor);
cursor += 4;
writeUint32LE(central, entry.size, cursor);
cursor += 4;
central.writeUInt16LE(fileName.length, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
writeUint32LE(central, 0, cursor);
cursor += 4;
writeUint32LE(central, entry.localHeaderOffset, cursor);
cursor += 4;
fileName.copy(central, cursor);
return central;
}
function createEndOfCentralDirectory(
entriesLength: number,
centralSize: number,
centralStart: number,
): Buffer {
const end = Buffer.alloc(22);
let cursor = 0;
writeUint32LE(end, 0x06054b50, cursor);
cursor += 4;
end.writeUInt16LE(0, cursor);
cursor += 2;
end.writeUInt16LE(0, cursor);
cursor += 2;
end.writeUInt16LE(entriesLength, cursor);
cursor += 2;
end.writeUInt16LE(entriesLength, cursor);
cursor += 2;
writeUint32LE(end, centralSize, cursor);
cursor += 4;
writeUint32LE(end, centralStart, cursor);
cursor += 4;
end.writeUInt16LE(0, cursor);
return end;
}
function writeBuffer(fd: number, buffer: Buffer): void {
let written = 0;
while (written < buffer.length) {
written += fs.writeSync(fd, buffer, written, buffer.length - written);
}
}
function writeStoredZip(outputPath: string, files: Iterable<{ name: string; data: Buffer }>): void {
const entries: ZipEntry[] = [];
let offset = 0;
const fd = fs.openSync(outputPath, 'w');
try {
for (const file of files) {
const fileName = Buffer.from(file.name, 'utf8');
const fileSize = file.data.length;
const fileCrc32 = crc32(file.data);
const localHeader = createLocalFileHeader(fileName, fileCrc32, fileSize);
writeBuffer(fd, localHeader);
writeBuffer(fd, file.data);
entries.push({
name: file.name,
crc32: fileCrc32,
size: fileSize,
localHeaderOffset: offset,
});
offset += localHeader.length + fileSize;
}
const centralStart = offset;
for (const entry of entries) {
const centralHeader = createCentralDirectoryHeader(entry);
writeBuffer(fd, centralHeader);
offset += centralHeader.length;
}
const centralSize = offset - centralStart;
writeBuffer(fd, createEndOfCentralDirectory(entries.length, centralSize, centralStart));
} catch (error) {
fs.closeSync(fd);
fs.rmSync(outputPath, { force: true });
throw error;
}
fs.closeSync(fd);
}
export function buildDictionaryZip(
outputPath: string,
dictionaryTitle: string,
-4
View File
@@ -19,7 +19,6 @@ export interface OverlayShortcutRuntimeServiceInput {
isOverlayShortcutContextActive?: () => boolean;
showMpvOsd: (text: string) => void;
openRuntimeOptionsPalette: () => void;
openCharacterDictionary: () => void;
openCharacterDictionaryManager: () => void;
openJimaku: () => void;
markAudioCard: () => Promise<void>;
@@ -51,9 +50,6 @@ export function createOverlayShortcutsRuntimeService(
openRuntimeOptions: () => {
input.openRuntimeOptionsPalette();
},
openCharacterDictionary: () => {
input.openCharacterDictionary();
},
openCharacterDictionaryManager: () => {
input.openCharacterDictionaryManager();
},
+2
View File
@@ -22,6 +22,8 @@ export function createBuildAppReadyRuntimeMainDepsHandler(deps: AppReadyRuntimeD
startTexthooker: deps.startTexthooker,
log: deps.log,
setLogLevel: deps.setLogLevel,
setLogRotation: deps.setLogRotation,
setLogFileToggles: deps.setLogFileToggles,
createMecabTokenizerAndCheck: deps.createMecabTokenizerAndCheck,
createSubtitleTimingTracker: deps.createSubtitleTimingTracker,
createImmersionTracker: deps.createImmersionTracker,
@@ -0,0 +1,55 @@
import { strict as assert } from 'node:assert';
import { test } from 'node:test';
import {
CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
openCharacterDictionaryManagerWithConfigGate,
type CharacterDictionaryManagerNotificationType,
} from './character-dictionary-manager-gate';
function makeDeps(options: {
enabled?: boolean;
notificationType?: CharacterDictionaryManagerNotificationType;
}) {
const calls: string[] = [];
return {
calls,
deps: {
isCharacterDictionaryEnabled: () => options.enabled ?? false,
getNotificationType: () => options.notificationType ?? 'osd',
openManager: () => calls.push('open'),
showOsd: (message: string) => calls.push(`osd:${message}`),
showDesktopNotification: (title: string, opts: { body: string }) =>
calls.push(`system:${title}:${opts.body}`),
logWarn: (message: string) => calls.push(`warn:${message}`),
},
};
}
test('opens character dictionary manager when character dictionary is enabled', () => {
const { calls, deps } = makeDeps({ enabled: true, notificationType: 'both' });
openCharacterDictionaryManagerWithConfigGate(deps);
assert.deepEqual(calls, ['open']);
});
test('routes disabled manager notification to configured surfaces', () => {
for (const [type, expected] of [
['osd', [`osd:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`]],
['system', [`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`]],
[
'both',
[
`osd:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
`system:SubMiner:${CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE}`,
],
],
['none', []],
] as const) {
const { calls, deps } = makeDeps({ enabled: false, notificationType: type });
openCharacterDictionaryManagerWithConfigGate(deps);
assert.deepEqual(calls, expected);
}
});
@@ -0,0 +1,39 @@
export type CharacterDictionaryManagerNotificationType = 'osd' | 'system' | 'both' | 'none';
export const CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE =
'Enable Name Match in Settings to use the character dictionary manager.';
export interface CharacterDictionaryManagerGateDeps {
isCharacterDictionaryEnabled: () => boolean;
getNotificationType: () => CharacterDictionaryManagerNotificationType;
openManager: () => void;
showOsd: (message: string) => void;
showDesktopNotification: (title: string, options: { body: string }) => void;
logWarn?: (message: string, error?: unknown) => void;
}
function notifyManagerDisabled(deps: CharacterDictionaryManagerGateDeps): void {
const type = deps.getNotificationType();
if (type === 'osd' || type === 'both') {
deps.showOsd(CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE);
}
if (type === 'system' || type === 'both') {
try {
deps.showDesktopNotification('SubMiner', {
body: CHARACTER_DICTIONARY_MANAGER_DISABLED_MESSAGE,
});
} catch (error) {
deps.logWarn?.('Unable to show character dictionary manager notification.', error);
}
}
}
export function openCharacterDictionaryManagerWithConfigGate(
deps: CharacterDictionaryManagerGateDeps,
): void {
if (deps.isCharacterDictionaryEnabled()) {
deps.openManager();
return;
}
notifyManagerDisabled(deps);
}
@@ -53,17 +53,6 @@ type OpenCharacterDictionaryModalDeps = Omit<
'channel' | 'retryWarning'
>;
export async function openCharacterDictionaryModal(
deps: OpenCharacterDictionaryModalDeps,
): Promise<boolean> {
return await openCharacterDictionaryModalChannel({
...deps,
channel: IPC_CHANNELS.event.characterDictionaryOpen,
retryWarning:
'Character dictionary modal did not acknowledge modal open on first attempt; retrying dedicated modal window.',
});
}
export async function openCharacterDictionaryManagerModal(
deps: OpenCharacterDictionaryModalDeps,
): Promise<boolean> {
@@ -27,7 +27,7 @@ test('composeJellyfinRuntimeHandlers returns callable jellyfin runtime handlers'
getLaunchMode: () => 'normal',
platform: 'linux',
execPath: process.execPath,
defaultMpvLogPath: '/tmp/test-mpv.log',
getDefaultMpvLogPath: () => '/tmp/test-mpv.log',
defaultMpvArgs: [],
removeSocketPath: () => {},
spawnMpv: () => ({ unref: () => {} }) as never,
@@ -73,6 +73,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
config.ankiConnect.isLapis.sentenceCardModel = 'Sentence Card Custom';
config.ankiConnect.isKiku.fieldGrouping = 'manual';
config.logging.level = 'debug';
config.logging.rotation = 14;
config.logging.files.mpv = true;
const calls: string[] = [];
const ankiPatches: unknown[] = [];
@@ -90,6 +92,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
refreshSubtitlePrefetch: () => calls.push('refresh:prefetch'),
refreshCurrentSubtitle: () => calls.push('refresh:subtitle'),
setLogLevel: (level) => calls.push(`log:${level}`),
setLogRotation: (rotation) => calls.push(`rotation:${rotation}`),
setLogFileToggles: (files) => calls.push(`files:${files.mpv}`),
});
applyHotReload(
@@ -109,6 +113,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
'ankiConnect.isLapis.sentenceCardModel',
'ankiConnect.isKiku.fieldGrouping',
'logging.level',
'logging.rotation',
'logging.files.mpv',
],
restartRequiredFields: [],
},
@@ -135,6 +141,8 @@ test('createConfigHotReloadAppliedHandler applies safe Anki, annotation, and log
assert.ok(calls.includes('refresh:prefetch'));
assert.ok(calls.includes('refresh:subtitle'));
assert.ok(calls.includes('log:debug'));
assert.ok(calls.includes('rotation:14'));
assert.ok(calls.includes('files:true'));
assert.ok(calls.includes('broadcast:config:hot-reload'));
});
@@ -20,6 +20,8 @@ type ConfigHotReloadAppliedDeps = {
refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
};
type ConfigHotReloadMessageDeps = {
@@ -158,6 +160,12 @@ export function createConfigHotReloadAppliedHandler(deps: ConfigHotReloadApplied
if (diff.hotReloadFields.includes('logging.level')) {
deps.setLogLevel?.(config.logging.level);
}
if (diff.hotReloadFields.includes('logging.rotation')) {
deps.setLogRotation?.(config.logging.rotation);
}
if (hasAnyHotReloadField(diff, ['logging.files'])) {
deps.setLogFileToggles?.(config.logging.files);
}
if (diff.hotReloadFields.length > 0) {
deps.broadcastToOverlayWindows('config:hot-reload', payload);
@@ -75,6 +75,8 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
refreshSubtitlePrefetch?: () => void;
refreshCurrentSubtitle?: () => void;
setLogLevel?: (level: ResolvedConfig['logging']['level']) => void;
setLogRotation?: (rotation: ResolvedConfig['logging']['rotation']) => void;
setLogFileToggles?: (files: ResolvedConfig['logging']['files']) => void;
}) {
return () => ({
setKeybindings: (keybindings: ConfigHotReloadPayload['keybindings']) =>
@@ -93,6 +95,10 @@ export function createBuildConfigHotReloadAppliedMainDepsHandler(deps: {
refreshSubtitlePrefetch: () => deps.refreshSubtitlePrefetch?.(),
refreshCurrentSubtitle: () => deps.refreshCurrentSubtitle?.(),
setLogLevel: (level: ResolvedConfig['logging']['level']) => deps.setLogLevel?.(level),
setLogRotation: (rotation: ResolvedConfig['logging']['rotation']) =>
deps.setLogRotation?.(rotation),
setLogFileToggles: (files: ResolvedConfig['logging']['files']) =>
deps.setLogFileToggles?.(files),
});
}
@@ -111,15 +111,15 @@ test('detectInstalledFirstRunPlugin ignores legacy loader file', () => {
test('detectInstalledFirstRunPluginCandidates returns all legacy autoload entries without script opts', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const xdgConfigHome = path.join(root, 'xdg');
const homeDir = path.posix.join(root, 'home');
const xdgConfigHome = path.posix.join(root, 'xdg');
const installPaths = resolveDefaultMpvInstallPaths('linux', homeDir, xdgConfigHome);
const directoryInstall = installPaths.pluginDir;
const legacyScript = path.join(installPaths.scriptsDir, 'subminer.lua');
const legacyLoader = path.join(installPaths.scriptsDir, 'subminer-loader.lua');
const legacyScript = path.posix.join(installPaths.scriptsDir, 'subminer.lua');
const legacyLoader = path.posix.join(installPaths.scriptsDir, 'subminer-loader.lua');
fs.mkdirSync(directoryInstall, { recursive: true });
fs.writeFileSync(path.join(directoryInstall, 'main.lua'), '-- plugin');
fs.writeFileSync(path.posix.join(directoryInstall, 'main.lua'), '-- plugin');
fs.writeFileSync(legacyScript, '-- legacy plugin');
fs.writeFileSync(legacyLoader, '-- legacy loader');
fs.mkdirSync(path.dirname(installPaths.pluginConfigPath), { recursive: true });
@@ -203,9 +203,15 @@ test('detectInstalledMpvPlugin prefers Windows portable plugin and parses versio
test('detectInstalledMpvPlugin detects Linux legacy single-file plugin without version', () => {
withTempDir((root) => {
const homeDir = path.join(root, 'home');
const legacyPath = path.join(homeDir, '.config', 'mpv', 'scripts', 'subminer-loader.lua');
fs.mkdirSync(path.dirname(legacyPath), { recursive: true });
const homeDir = path.posix.join(root, 'home');
const legacyPath = path.posix.join(
homeDir,
'.config',
'mpv',
'scripts',
'subminer-loader.lua',
);
fs.mkdirSync(path.posix.dirname(legacyPath), { recursive: true });
fs.writeFileSync(legacyPath, '-- legacy');
const detection = detectInstalledMpvPlugin({
@@ -56,7 +56,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
openJimaku: false,
openYoutubePicker: false,
openPlaylistBrowser: false,
openCharacterDictionary: false,
replayCurrentSubtitle: false,
playNextSubtitle: false,
shiftSubDelayPrevLine: false,
@@ -44,7 +44,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
source: null,
message: null,
}),
defaultMpvLogPath: '/tmp/mpv.log',
getDefaultMpvLogPath: () => '/tmp/mpv.log',
defaultMpvArgs: ['--no-config'],
removeSocketPath: (socketPath) => calls.push(`rm:${socketPath}`),
spawnMpv: (args) => {
@@ -61,7 +61,7 @@ test('launch mpv for jellyfin main deps builder maps callbacks', () => {
assert.equal(deps.execPath, '/tmp/subminer');
assert.equal(deps.getRuntimePluginEntrypoint?.(), '/tmp/plugin/subminer/main.lua');
assert.equal(deps.getInstalledPluginDetection?.().installed, false);
assert.equal(deps.defaultMpvLogPath, '/tmp/mpv.log');
assert.equal(deps.getDefaultMpvLogPath(), '/tmp/mpv.log');
assert.deepEqual(deps.defaultMpvArgs, ['--no-config']);
deps.removeSocketPath('/tmp/mpv.sock');
deps.spawnMpv(['--idle=yes']);
@@ -23,7 +23,7 @@ export function createBuildLaunchMpvIdleForJellyfinPlaybackMainDepsHandler(
getRuntimePluginEntrypoint: deps.getRuntimePluginEntrypoint,
getInstalledPluginDetection: deps.getInstalledPluginDetection,
getPluginRuntimeConfig: deps.getPluginRuntimeConfig,
defaultMpvLogPath: deps.defaultMpvLogPath,
getDefaultMpvLogPath: () => deps.getDefaultMpvLogPath(),
defaultMpvArgs: deps.defaultMpvArgs,
removeSocketPath: (socketPath: string) => deps.removeSocketPath(socketPath),
spawnMpv: (args: string[]) => deps.spawnMpv(args),
@@ -36,7 +36,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
execPath: '/Applications/SubMiner.app/Contents/MacOS/SubMiner',
getRuntimePluginEntrypoint: () =>
'/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
defaultMpvLogPath: '/tmp/mp.log',
getDefaultMpvLogPath: () => ' /tmp/mp.log ',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
@@ -59,6 +59,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler builds expected mpv args', (
'--script=/Applications/SubMiner.app/Contents/Resources/plugin/subminer/main.lua',
),
);
assert.ok(spawnedArgs[0]!.includes('--log-file=/tmp/mp.log'));
assert.ok(spawnedArgs[0]!.some((arg) => arg.includes('--input-ipc-server=/tmp/subminer.sock')));
assert.ok(logs.some((entry) => entry.includes('Launched mpv for Jellyfin playback')));
});
@@ -81,7 +82,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler forwards runtime plugin conf
aniskipEnabled: true,
aniskipButtonKey: 'F8',
}),
defaultMpvLogPath: '/tmp/mp.log',
getDefaultMpvLogPath: () => '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
@@ -123,7 +124,7 @@ test('createLaunchMpvIdleForJellyfinPlaybackHandler skips bundled script when in
source: 'default-config',
message: null,
}),
defaultMpvLogPath: '/tmp/mp.log',
getDefaultMpvLogPath: () => '/tmp/mp.log',
defaultMpvArgs: ['--sid=auto'],
removeSocketPath: () => {},
spawnMpv: (args) => {
@@ -48,7 +48,7 @@ export type LaunchMpvForJellyfinDeps = {
getRuntimePluginEntrypoint?: () => string | null | undefined;
getInstalledPluginDetection?: () => InstalledMpvPluginDetection;
getPluginRuntimeConfig?: () => SubminerPluginRuntimeScriptOptConfig;
defaultMpvLogPath: string;
getDefaultMpvLogPath: () => string;
defaultMpvArgs: readonly string[];
removeSocketPath: (socketPath: string) => void;
spawnMpv: (args: string[]) => SpawnedProcessLike;
@@ -85,13 +85,14 @@ export function createLaunchMpvIdleForJellyfinPlaybackHandler(deps: LaunchMpvFor
if (installedPlugin?.installed && installedPlugin.path) {
deps.logInfo(`Using installed mpv plugin for Jellyfin playback: ${installedPlugin.path}`);
}
const defaultMpvLogPath = deps.getDefaultMpvLogPath().trim();
const mpvArgs = [
...deps.defaultMpvArgs,
...buildMpvLaunchModeArgs(deps.getLaunchMode()),
...(runtimePluginEntrypoint ? [`--script=${runtimePluginEntrypoint}`] : []),
'--idle=yes',
scriptOpts,
`--log-file=${deps.defaultMpvLogPath}`,
...(defaultMpvLogPath ? [`--log-file=${defaultMpvLogPath}`] : []),
`--input-ipc-server=${socketPath}`,
];
const proc = deps.spawnMpv(mpvArgs);
+204
View File
@@ -0,0 +1,204 @@
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 { writeStoredZip } from '../../shared/stored-zip';
import { exportLogsArchive, maskUsernamesInLogText } from './log-export';
function makeTempDir(): string {
return fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-log-export-'));
}
function cleanupDir(dirPath: string): void {
fs.rmSync(dirPath, { recursive: true, force: true });
}
function writeLog(logsDir: string, name: string, content: string, mtime: string): string {
const logPath = path.join(logsDir, name);
fs.writeFileSync(logPath, content, 'utf8');
const date = new Date(mtime);
fs.utimesSync(logPath, date, date);
return logPath;
}
function readStoredZipEntries(zipPath: string): Map<string, Buffer> {
const archive = fs.readFileSync(zipPath);
const entries = new Map<string, Buffer>();
let cursor = 0;
while (cursor + 4 <= archive.length) {
const signature = archive.readUInt32LE(cursor);
if (signature === 0x02014b50 || signature === 0x06054b50) {
break;
}
assert.equal(signature, 0x04034b50);
const compressedSize = archive.readUInt32LE(cursor + 18);
const fileNameLength = archive.readUInt16LE(cursor + 26);
const extraLength = archive.readUInt16LE(cursor + 28);
const fileNameStart = cursor + 30;
const dataStart = fileNameStart + fileNameLength + extraLength;
const fileName = archive
.subarray(fileNameStart, fileNameStart + fileNameLength)
.toString('utf8');
const data = archive.subarray(dataStart, dataStart + compressedSize);
entries.set(fileName, Buffer.from(data));
cursor = dataStart + compressedSize;
}
return entries;
}
test('maskUsernamesInLogText redacts linux macOS and Windows home paths', () => {
const masked = maskUsernamesInLogText(
[
'/home/kyle/.config/SubMiner',
'/Users/kyle/Library/Application Support/SubMiner',
'C:\\Users\\kyle\\AppData\\Roaming\\SubMiner',
'C:\\\\Users\\\\kyle\\\\AppData\\\\Roaming\\\\SubMiner',
].join('\n'),
);
assert.match(masked, /\/home\/<user>\/\.config/);
assert.match(masked, /\/Users\/<user>\/Library/);
assert.match(masked, /C:\\Users\\<user>\\AppData/);
assert.match(masked, /C:\\\\Users\\\\<user>\\\\AppData/);
assert.doesNotMatch(masked, /kyle/);
});
test('exportLogsArchive exports current-day logs and masks usernames', () => {
const root = makeTempDir();
const logsDir = path.join(root, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
try {
const currentLog = writeLog(
logsDir,
'app-2026-W21.log',
'opened /home/kyle/video.mkv and C:\\Users\\kyle\\AppData\\Roaming\\SubMiner\n',
'2026-05-26T12:00:00.000Z',
);
writeLog(logsDir, 'launcher-2026-W20.log', 'old /Users/kyle/Library\n', '2026-05-20T12:00:00Z');
const result = exportLogsArchive({
logsDir,
outputDir: root,
now: new Date('2026-05-26T16:00:00.000Z'),
});
assert.equal(result.mode, 'current-day');
assert.deepEqual(result.exportedFiles, [currentLog]);
const entries = readStoredZipEntries(result.zipPath);
assert.deepEqual([...entries.keys()], ['logs/app-2026-W21.log']);
const content = entries.get('logs/app-2026-W21.log')!.toString('utf8');
assert.match(content, /\/home\/<user>\/video\.mkv/);
assert.match(content, /C:\\Users\\<user>\\AppData/);
assert.doesNotMatch(content, /kyle/);
} finally {
cleanupDir(root);
}
});
test('writeStoredZip rejects names outside ZIP32 limits', () => {
const dir = makeTempDir();
const outputPath = path.join(dir, 'logs.zip');
try {
assert.throws(
() =>
writeStoredZip(outputPath, [
{
name: `${'a'.repeat(0x10000)}.log`,
data: Buffer.from('log\n', 'utf8'),
},
]),
/ZIP entry name too long/,
);
assert.equal(fs.existsSync(outputPath), false);
} finally {
cleanupDir(dir);
}
});
test('exportLogsArchive ignores older dated logs when current-day dated logs exist', () => {
const root = makeTempDir();
const logsDir = path.join(root, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
try {
const currentLog = writeLog(
logsDir,
'app-2026-05-25.log',
'current day\n',
'2026-05-25T18:00:00Z',
);
writeLog(logsDir, 'app-2026-05-24.log', 'previous day touched today\n', '2026-05-25T18:00:00Z');
const result = exportLogsArchive({
logsDir,
outputDir: root,
now: new Date('2026-05-25T20:00:00.000Z'),
});
assert.equal(result.mode, 'current-day');
assert.deepEqual(result.exportedFiles, [currentLog]);
const entries = readStoredZipEntries(result.zipPath);
assert.deepEqual([...entries.keys()], ['logs/app-2026-05-25.log']);
} finally {
cleanupDir(root);
}
});
test('exportLogsArchive falls back to newest log per kind', () => {
const root = makeTempDir();
const logsDir = path.join(root, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
try {
writeLog(logsDir, 'app-2026-W18.log', 'older app\n', '2026-05-01T12:00:00Z');
const appLog = writeLog(logsDir, 'app-2026-W19.log', 'newer app\n', '2026-05-12T12:00:00Z');
const mpvLog = writeLog(logsDir, 'mpv-2026-W17.log', 'latest mpv\n', '2026-05-10T12:00:00Z');
const launcherLog = writeLog(
logsDir,
'launcher-2026-W16.log',
'latest launcher\n',
'2026-05-09T12:00:00Z',
);
const result = exportLogsArchive({
logsDir,
outputDir: root,
now: new Date('2026-05-26T16:00:00.000Z'),
});
assert.equal(result.mode, 'most-recent');
assert.deepEqual(result.exportedFiles.sort(), [appLog, launcherLog, mpvLog].sort());
const entries = readStoredZipEntries(result.zipPath);
assert.deepEqual([...entries.keys()].sort(), [
'logs/app-2026-W19.log',
'logs/launcher-2026-W16.log',
'logs/mpv-2026-W17.log',
]);
} finally {
cleanupDir(root);
}
});
test('exportLogsArchive fails when no logs exist', () => {
const root = makeTempDir();
const logsDir = path.join(root, 'logs');
fs.mkdirSync(logsDir, { recursive: true });
try {
assert.throws(
() => exportLogsArchive({ logsDir, outputDir: root }),
/No SubMiner log files found/,
);
} finally {
cleanupDir(root);
}
});
+184
View File
@@ -0,0 +1,184 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { resolveLogBaseDir } from '../../shared/log-files';
import { writeStoredZip } from '../../shared/stored-zip';
type LogCandidate = {
path: string;
name: string;
kind: string;
mtimeMs: number;
mtimeDateKey: string;
fileDateKey: string | null;
};
export type ExportLogsResult = {
zipPath: string;
exportedFiles: string[];
mode: 'current-day' | 'most-recent';
};
export type ExportLogsOptions = {
platform?: NodeJS.Platform;
homeDir?: string;
appDataDir?: string;
logsDir?: string;
outputDir?: string;
now?: Date;
};
const REDACTED_USER = '<user>';
function pad(value: number): string {
return String(value).padStart(2, '0');
}
function localDateKey(date: Date): string {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
function filenameDateKey(fileName: string): string | null {
return fileName.match(/\d{4}-\d{2}-\d{2}/)?.[0] ?? null;
}
function fileKind(fileName: string): string {
const match = fileName.match(/^([A-Za-z0-9_-]+)-/);
return match?.[1] ?? fileName;
}
function zipTimestamp(date: Date): string {
return `${localDateKey(date)}-${pad(date.getHours())}${pad(date.getMinutes())}${pad(
date.getSeconds(),
)}`;
}
function resolveLogsDir(options: ExportLogsOptions): string {
if (options.logsDir) return options.logsDir;
return path.join(
resolveLogBaseDir({
platform: options.platform,
homeDir: options.homeDir,
appDataDir: options.appDataDir,
}),
'logs',
);
}
function buildCandidate(logsDir: string, entry: string): LogCandidate | null {
if (!entry.endsWith('.log')) return null;
const candidatePath = path.join(logsDir, entry);
let stats: fs.Stats;
try {
stats = fs.statSync(candidatePath);
} catch {
return null;
}
if (!stats.isFile()) return null;
return {
path: candidatePath,
name: entry,
kind: fileKind(entry),
mtimeMs: stats.mtimeMs,
mtimeDateKey: localDateKey(stats.mtime),
fileDateKey: filenameDateKey(entry),
};
}
function listLogCandidates(logsDir: string): LogCandidate[] {
let entries: string[];
try {
entries = fs.readdirSync(logsDir);
} catch {
return [];
}
return entries
.map((entry) => buildCandidate(logsDir, entry))
.filter((entry): entry is LogCandidate => entry !== null)
.sort((left, right) => left.name.localeCompare(right.name));
}
function selectMostRecentPerKind(candidates: LogCandidate[]): LogCandidate[] {
const byKind = new Map<string, LogCandidate>();
for (const candidate of [...candidates].sort(
(left, right) => candidateFreshnessMs(right) - candidateFreshnessMs(left),
)) {
if (!byKind.has(candidate.kind)) {
byKind.set(candidate.kind, candidate);
}
}
return [...byKind.values()].sort((left, right) => left.name.localeCompare(right.name));
}
function candidateFreshnessMs(candidate: LogCandidate): number {
if (candidate.fileDateKey) {
return Date.parse(`${candidate.fileDateKey}T23:59:59.999Z`);
}
return candidate.mtimeMs;
}
function selectLogCandidates(
candidates: LogCandidate[],
now: Date,
): { mode: ExportLogsResult['mode']; selected: LogCandidate[] } {
const today = localDateKey(now);
const currentDated = candidates.filter((candidate) => candidate.fileDateKey === today);
if (currentDated.length > 0) {
return { mode: 'current-day', selected: currentDated };
}
const currentUndated = candidates.filter(
(candidate) => candidate.fileDateKey === null && candidate.mtimeDateKey === today,
);
if (currentUndated.length > 0) {
return { mode: 'current-day', selected: currentUndated };
}
return { mode: 'most-recent', selected: selectMostRecentPerKind(candidates) };
}
export function maskUsernamesInLogText(text: string): string {
return text
.replace(/(\/(?:home|Users)\/)([^/\r\n]+)(?=\/|$)/g, `$1${REDACTED_USER}`)
.replace(/([A-Za-z]:[\\/]+Users[\\/]+)([^\\/:\r\n]+)(?=[\\/]|$)/g, `$1${REDACTED_USER}`);
}
export function exportLogsArchive(options: ExportLogsOptions = {}): ExportLogsResult {
const now = options.now ?? new Date();
const logsDir = resolveLogsDir(options);
const outputDir = options.outputDir ?? logsDir;
const candidates = listLogCandidates(logsDir);
const { mode, selected } = selectLogCandidates(candidates, now);
if (selected.length === 0) {
throw new Error(`No SubMiner log files found in ${logsDir}`);
}
fs.mkdirSync(outputDir, { recursive: true });
const zipPath = path.join(outputDir, `subminer-logs-${zipTimestamp(now)}.zip`);
writeStoredZip(
zipPath,
selected.map((candidate) => ({
name: `logs/${candidate.name}`,
data: Buffer.from(maskUsernamesInLogText(fs.readFileSync(candidate.path, 'utf8')), 'utf8'),
})),
);
return {
zipPath,
exportedFiles: selected.map((candidate) => candidate.path),
mode,
};
}
export function exportLogsArchiveForCurrentUser(now: Date = new Date()): ExportLogsResult {
return exportLogsArchive({
platform: process.platform,
homeDir: os.homedir(),
appDataDir: process.env.APPDATA,
now,
});
}
@@ -8,7 +8,7 @@ import {
test('append to mpv log main deps map filesystem functions and log path', async () => {
const calls: string[] = [];
const deps = createBuildAppendToMpvLogMainDepsHandler({
logPath: '/tmp/mpv.log',
getLogPath: () => '/tmp/mpv.log',
dirname: (targetPath) => {
calls.push(`dirname:${targetPath}`);
return '/tmp';
@@ -22,7 +22,7 @@ test('append to mpv log main deps map filesystem functions and log path', async
now: () => new Date('2026-02-20T00:00:00.000Z'),
})();
assert.equal(deps.logPath, '/tmp/mpv.log');
assert.equal(deps.getLogPath(), '/tmp/mpv.log');
assert.equal(deps.dirname('/tmp/mpv.log'), '/tmp');
await deps.mkdir('/tmp', { recursive: true });
await deps.appendFile('/tmp/mpv.log', 'line', { encoding: 'utf8' });
+1 -1
View File
@@ -5,7 +5,7 @@ type ShowMpvOsdMainDeps = Parameters<typeof createShowMpvOsdHandler>[0];
export function createBuildAppendToMpvLogMainDepsHandler(deps: AppendToMpvLogMainDeps) {
return (): AppendToMpvLogMainDeps => ({
logPath: deps.logPath,
getLogPath: () => deps.getLogPath(),
dirname: (targetPath: string) => deps.dirname(targetPath),
mkdir: (targetPath: string, options: { recursive: boolean }) => deps.mkdir(targetPath, options),
appendFile: (targetPath: string, data: string, options: { encoding: 'utf8' }) =>
+24 -3
View File
@@ -5,7 +5,7 @@ import { createAppendToMpvLogHandler, createShowMpvOsdHandler } from './mpv-osd-
test('append mpv log writes timestamped message', () => {
const writes: string[] = [];
const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({
logPath: '/tmp/subminer/mpv.log',
getLogPath: () => '/tmp/subminer/mpv.log',
dirname: (targetPath: string) => {
writes.push(`dirname:${targetPath}`);
return '/tmp/subminer';
@@ -29,10 +29,31 @@ test('append mpv log writes timestamped message', () => {
});
});
test('append mpv log observes path changes', async () => {
const writes: string[] = [];
let logPath = '';
const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({
getLogPath: () => logPath,
dirname: () => '/tmp/subminer',
mkdir: async () => {},
appendFile: async (targetPath: string, data: string) => {
writes.push(`${targetPath}:${data.trimEnd()}`);
},
now: () => new Date('2026-02-20T00:00:00.000Z'),
});
appendToMpvLog('disabled');
logPath = '/tmp/subminer/mpv.log';
appendToMpvLog('enabled');
await flushMpvLog();
assert.deepEqual(writes, ['/tmp/subminer/mpv.log:[2026-02-20T00:00:00.000Z] enabled']);
});
test('append mpv log queues multiple messages and flush waits for pending write', async () => {
const writes: string[] = [];
const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({
logPath: '/tmp/subminer/mpv.log',
getLogPath: () => '/tmp/subminer/mpv.log',
dirname: () => '/tmp/subminer',
mkdir: async () => {
writes.push('mkdir');
@@ -76,7 +97,7 @@ test('append mpv log queues multiple messages and flush waits for pending write'
test('append mpv log swallows async filesystem errors', async () => {
const { appendToMpvLog, flushMpvLog } = createAppendToMpvLogHandler({
logPath: '/tmp/subminer/mpv.log',
getLogPath: () => '/tmp/subminer/mpv.log',
dirname: () => '/tmp/subminer',
mkdir: async () => {
throw new Error('disk error');
+10 -3
View File
@@ -1,7 +1,7 @@
import type { MpvRuntimeClientLike } from '../../core/services/mpv';
export function createAppendToMpvLogHandler(deps: {
logPath: string;
getLogPath: () => string;
dirname: (targetPath: string) => string;
mkdir: (targetPath: string, options: { recursive: boolean }) => Promise<void>;
appendFile: (targetPath: string, data: string, options: { encoding: 'utf8' }) => Promise<void>;
@@ -13,9 +13,13 @@ export function createAppendToMpvLogHandler(deps: {
const drainPendingLines = async (): Promise<void> => {
while (pendingLines.length > 0) {
const chunk = pendingLines.splice(0, pendingLines.length).join('');
const logPath = deps.getLogPath();
if (!logPath.trim()) {
continue;
}
try {
await deps.mkdir(deps.dirname(deps.logPath), { recursive: true });
await deps.appendFile(deps.logPath, chunk, { encoding: 'utf8' });
await deps.mkdir(deps.dirname(logPath), { recursive: true });
await deps.appendFile(logPath, chunk, { encoding: 'utf8' });
} catch {
// best-effort logging
}
@@ -35,6 +39,9 @@ export function createAppendToMpvLogHandler(deps: {
};
const appendToMpvLog = (message: string): void => {
if (!deps.getLogPath().trim()) {
return;
}
pendingLines.push(`[${deps.now().toISOString()}] ${message}\n`);
void scheduleDrain();
};
@@ -6,7 +6,7 @@ test('mpv osd runtime handlers compose append and osd logging flow', async () =>
const calls: string[] = [];
const runtime = createMpvOsdRuntimeHandlers({
appendToMpvLogMainDeps: {
logPath: '/tmp/subminer/mpv.log',
getLogPath: () => '/tmp/subminer/mpv.log',
dirname: () => '/tmp/subminer',
mkdir: async () => {},
appendFile: async (_targetPath: string, data: string) => {
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { buildSubtitleTrackDiagnostics } from './mpv-track-diagnostics';
test('buildSubtitleTrackDiagnostics summarizes subtitle tracks without dumping track list', () => {
const diagnostics = buildSubtitleTrackDiagnostics(3, [
{ type: 'video', id: 1, selected: true },
{ type: 'sub', id: 3, lang: 'ja', selected: true, external: false, codec: 'ass' },
{ type: 'sub', id: '4', title: 'English', external: true, codec: 'srt' },
{ type: 'audio', id: 2, lang: 'jpn' },
]);
assert.deepEqual(diagnostics, {
trackListReadable: true,
trackCount: 4,
subtitleTrackCount: 2,
activePrimarySid: 3,
selectedSubtitleIds: [3],
externalSubtitleCount: 1,
internalSubtitleCount: 1,
languages: ['ja'],
selectedSubtitleLabels: ['internal#3:ja'],
});
});
test('buildSubtitleTrackDiagnostics marks unreadable track list', () => {
assert.deepEqual(buildSubtitleTrackDiagnostics(null, null), {
trackListReadable: false,
trackCount: 0,
subtitleTrackCount: 0,
activePrimarySid: null,
selectedSubtitleIds: [],
externalSubtitleCount: 0,
internalSubtitleCount: 0,
languages: [],
selectedSubtitleLabels: [],
});
});
+113
View File
@@ -0,0 +1,113 @@
type MpvTrackDiagnosticEntry = {
id: number | null;
type: string | null;
selected: boolean;
external: boolean;
lang: string | null;
title: string | null;
codec: string | null;
};
export type SubtitleTrackDiagnostics = {
trackListReadable: boolean;
trackCount: number;
subtitleTrackCount: number;
activePrimarySid: number | null;
selectedSubtitleIds: number[];
externalSubtitleCount: number;
internalSubtitleCount: number;
languages: string[];
selectedSubtitleLabels: string[];
};
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object');
}
function parseTrackId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed.length || trimmed === 'no' || trimmed === 'auto') {
return null;
}
const parsed = Number(trimmed);
if (Number.isInteger(parsed)) {
return parsed;
}
}
return null;
}
function readString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function normalizeTrack(track: unknown): MpvTrackDiagnosticEntry | null {
if (!isRecord(track)) {
return null;
}
return {
id: parseTrackId(track.id),
type: readString(track.type),
selected: track.selected === true,
external: track.external === true,
lang: readString(track.lang),
title: readString(track.title),
codec: readString(track.codec),
};
}
function formatSubtitleTrackLabel(track: MpvTrackDiagnosticEntry): string {
const id = track.id === null ? '?' : String(track.id);
const source = track.external ? 'external' : 'internal';
const label = track.lang ?? track.title ?? track.codec ?? 'unknown';
return `${source}#${id}:${label}`;
}
export function buildSubtitleTrackDiagnostics(
activePrimarySid: number | null,
trackList: unknown[] | null,
): SubtitleTrackDiagnostics {
if (!Array.isArray(trackList)) {
return {
trackListReadable: false,
trackCount: 0,
subtitleTrackCount: 0,
activePrimarySid,
selectedSubtitleIds: [],
externalSubtitleCount: 0,
internalSubtitleCount: 0,
languages: [],
selectedSubtitleLabels: [],
};
}
const normalizedTracks = trackList.map(normalizeTrack).filter((track) => track !== null);
const subtitleTracks = normalizedTracks.filter((track) => track.type === 'sub');
const selectedSubtitleTracks = subtitleTracks.filter((track) => track.selected);
const languages = Array.from(
new Set(
subtitleTracks
.map((track) => track.lang)
.filter((language): language is string => language !== null),
),
).sort((left, right) => left.localeCompare(right));
return {
trackListReadable: true,
trackCount: normalizedTracks.length,
subtitleTrackCount: subtitleTracks.length,
activePrimarySid,
selectedSubtitleIds: selectedSubtitleTracks
.map((track) => track.id)
.filter((id): id is number => id !== null),
externalSubtitleCount: subtitleTracks.filter((track) => track.external).length,
internalSubtitleCount: subtitleTracks.filter((track) => !track.external).length,
languages,
selectedSubtitleLabels: selectedSubtitleTracks.map(formatSubtitleTrackLabel),
};
}
@@ -16,7 +16,6 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
isOverlayShortcutContextActive: () => false,
showMpvOsd: (text) => calls.push(`osd:${text}`),
openRuntimeOptionsPalette: () => calls.push('runtime-options'),
openCharacterDictionary: () => calls.push('character-dictionary'),
openCharacterDictionaryManager: () => calls.push('character-dictionary-manager'),
openJimaku: () => calls.push('jimaku'),
markAudioCard: async () => {
@@ -49,7 +48,6 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
assert.equal(shortcutsRegistered, true);
deps.showMpvOsd('x');
deps.openRuntimeOptionsPalette();
deps.openCharacterDictionary();
deps.openCharacterDictionaryManager();
deps.openJimaku();
await deps.markAudioCard();
@@ -67,7 +65,6 @@ test('overlay shortcuts runtime main deps builder maps lifecycle and action call
'registered:true',
'osd:x',
'runtime-options',
'character-dictionary',
'character-dictionary-manager',
'jimaku',
'mark-audio',
@@ -11,7 +11,6 @@ export function createBuildOverlayShortcutsRuntimeMainDepsHandler(
isOverlayShortcutContextActive: () => deps.isOverlayShortcutContextActive?.() ?? true,
showMpvOsd: (text: string) => deps.showMpvOsd(text),
openRuntimeOptionsPalette: () => deps.openRuntimeOptionsPalette(),
openCharacterDictionary: () => deps.openCharacterDictionary(),
openCharacterDictionaryManager: () => deps.openCharacterDictionaryManager(),
openJimaku: () => deps.openJimaku(),
markAudioCard: () => deps.markAudioCard(),
@@ -64,7 +64,7 @@ test('tokenizer deps builder records known-word lookups and maps readers', () =>
assert.deepEqual(calls, ['lookup:true', 'lookup:false', 'set-window', 'set-ready', 'set-init']);
});
test('tokenizer deps builder disables name matching when character dictionary is disabled', () => {
test('tokenizer deps builder disables name matching when character dictionary runtime is disabled', () => {
const deps = createBuildTokenizerDepsMainHandler({
getYomitanExt: () => null,
getYomitanParserWindow: () => null,
@@ -50,6 +50,7 @@ test('build tray template handler wires actions and init guards', () => {
handlers.openWindowsMpvLauncherSetup();
handlers.openYomitanSettings();
handlers.openConfigSettings();
handlers.exportLogs();
handlers.openJellyfinSetup();
handlers.toggleJellyfinDiscovery(true);
handlers.openAnilistSetup();
@@ -70,6 +71,7 @@ test('build tray template handler wires actions and init guards', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false,
@@ -94,6 +96,7 @@ test('build tray template handler wires actions and init guards', () => {
'setup-forced',
'yomitan',
'configuration',
'export-logs',
'jellyfin',
'jellyfin-discovery:true',
'anilist',
@@ -121,6 +124,7 @@ test('windows mpv launcher tray action force-opens completed setup', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => false,
isJellyfinDiscoveryActive: () => false,
+5
View File
@@ -47,6 +47,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
@@ -65,6 +66,7 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
@@ -101,6 +103,9 @@ export function createBuildTrayMenuTemplateHandler<TMenuItem>(deps: {
openConfigSettings: () => {
deps.openConfigSettingsWindow();
},
exportLogs: () => {
deps.exportLogs();
},
openJellyfinSetup: () => {
deps.openJellyfinSetupWindow();
},
+2
View File
@@ -32,6 +32,7 @@ test('tray main deps builders return mapped handlers', () => {
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettingsWindow: () => calls.push('configuration'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetupWindow: () => calls.push('jellyfin'),
isJellyfinConfigured: () => true,
isJellyfinDiscoveryActive: () => false,
@@ -56,6 +57,7 @@ test('tray main deps builders return mapped handlers', () => {
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('open-yomitan'),
openConfigSettings: () => calls.push('open-configuration'),
exportLogs: () => calls.push('open-export-logs'),
openJellyfinSetup: () => calls.push('open-jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
+3
View File
@@ -37,6 +37,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
@@ -55,6 +56,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: () => boolean;
openYomitanSettings: () => void;
openConfigSettingsWindow: () => void;
exportLogs: () => void;
openJellyfinSetupWindow: () => void;
isJellyfinConfigured: () => boolean;
isJellyfinDiscoveryActive: () => boolean;
@@ -77,6 +79,7 @@ export function createBuildTrayMenuTemplateMainDepsHandler<TMenuItem>(deps: {
showWindowsMpvLauncherSetup: deps.showWindowsMpvLauncherSetup,
openYomitanSettings: deps.openYomitanSettings,
openConfigSettingsWindow: deps.openConfigSettingsWindow,
exportLogs: deps.exportLogs,
openJellyfinSetupWindow: deps.openJellyfinSetupWindow,
isJellyfinConfigured: deps.isJellyfinConfigured,
isJellyfinDiscoveryActive: deps.isJellyfinDiscoveryActive,
@@ -32,6 +32,7 @@ test('tray runtime handlers compose resolve/menu/ensure/destroy handlers', () =>
showWindowsMpvLauncherSetup: () => true,
openYomitanSettings: () => {},
openConfigSettingsWindow: () => {},
exportLogs: () => {},
openJellyfinSetupWindow: () => {},
isJellyfinConfigured: () => false,
isJellyfinDiscoveryActive: () => false,
+13 -5
View File
@@ -38,6 +38,7 @@ test('tray menu template contains expected entries and handlers', () => {
showWindowsMpvLauncherSetup: true,
openYomitanSettings: () => calls.push('yomitan'),
openConfigSettings: () => calls.push('configuration'),
exportLogs: () => calls.push('export-logs'),
openJellyfinSetup: () => calls.push('jellyfin'),
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: false,
@@ -47,7 +48,7 @@ test('tray menu template contains expected entries and handlers', () => {
quitApp: () => calls.push('quit'),
});
assert.equal(template.length, 12);
assert.equal(template.length, 13);
assert.equal(
template.some((entry) => entry.label === 'Open Runtime Options'),
false,
@@ -66,14 +67,17 @@ test('tray menu template contains expected entries and handlers', () => {
assert.equal(template[1]!.label, 'Open Texthooker');
template[1]!.click?.();
assert.equal(template[5]!.label, 'Open SubMiner Settings');
assert.equal(template[9]!.label, 'Check for Updates');
template[9]!.click?.();
template[10]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[11]!.click?.();
assert.equal(template[6]!.label, 'Export Logs');
template[6]!.click?.();
assert.equal(template[10]!.label, 'Check for Updates');
template[10]!.click?.();
template[11]!.type === 'separator' ? calls.push('separator') : calls.push('bad');
template[12]!.click?.();
assert.deepEqual(calls, [
'jellyfin-discovery:true',
'help',
'texthooker',
'export-logs',
'updates',
'separator',
'quit',
@@ -91,6 +95,7 @@ test('tray menu template omits first-run setup entry when setup is complete', ()
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
jellyfinDiscoveryActive: false,
@@ -118,6 +123,7 @@ test('tray menu template omits texthooker entry when texthooker page is disabled
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: false,
jellyfinDiscoveryActive: false,
@@ -143,6 +149,7 @@ test('tray menu template renders active jellyfin discovery checkbox', () => {
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: true,
@@ -169,6 +176,7 @@ test('tray menu template renders a visible linux discovery check mark when activ
showWindowsMpvLauncherSetup: false,
openYomitanSettings: () => undefined,
openConfigSettings: () => undefined,
exportLogs: () => undefined,
openJellyfinSetup: () => undefined,
showJellyfinDiscovery: true,
jellyfinDiscoveryActive: true,
+5
View File
@@ -40,6 +40,7 @@ export type TrayMenuActionHandlers = {
showWindowsMpvLauncherSetup: boolean;
openYomitanSettings: () => void;
openConfigSettings: () => void;
exportLogs: () => void;
openJellyfinSetup: () => void;
showJellyfinDiscovery: boolean;
jellyfinDiscoveryActive: boolean;
@@ -102,6 +103,10 @@ export function buildTrayMenuTemplateRuntime(handlers: TrayMenuActionHandlers):
label: 'Open SubMiner Settings',
click: handlers.openConfigSettings,
},
{
label: 'Export Logs',
click: handlers.exportLogs,
},
{
label: 'Configure Jellyfin',
click: handlers.openJellyfinSetup,
+4 -3
View File
@@ -139,9 +139,10 @@ export async function updateAppImageFromRelease(options: {
};
}
const tempPath = path.join(
path.dirname(options.appImagePath),
`.${path.basename(options.appImagePath)}.update`,
const appImagePathApi = options.appImagePath.startsWith('/') ? path.posix : path;
const tempPath = appImagePathApi.join(
appImagePathApi.dirname(options.appImagePath),
`.${appImagePathApi.basename(options.appImagePath)}.update`,
);
try {
await fsDeps.writeFile(tempPath, data);
@@ -63,7 +63,7 @@ test('buildProtectedSupportAssetsCommand cleans up temporary extraction director
test('updateSupportAssetsFromRelease updates only the Linux rofi theme', async () => {
const xdgDataHome = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-xdg-data-'));
const dataDir = path.join(xdgDataHome, 'SubMiner');
const dataDir = path.posix.join(xdgDataHome, 'SubMiner');
fs.mkdirSync(path.join(dataDir, 'themes'), { recursive: true });
fs.mkdirSync(path.join(dataDir, 'plugin/subminer'), { recursive: true });
fs.writeFileSync(path.join(dataDir, 'themes/subminer.rasi'), 'old theme\n');
+6 -2
View File
@@ -30,8 +30,12 @@ export function detectSupportAssetDataDirs(options: {
xdgDataHome?: string;
}): string[] {
if (options.platform === 'linux') {
const xdgDataHome = options.xdgDataHome || path.join(options.homeDir, '.local/share');
return [path.join(xdgDataHome, 'SubMiner'), '/usr/local/share/SubMiner', '/usr/share/SubMiner'];
const xdgDataHome = options.xdgDataHome || path.posix.join(options.homeDir, '.local/share');
return [
path.posix.join(xdgDataHome, 'SubMiner'),
'/usr/local/share/SubMiner',
'/usr/share/SubMiner',
];
}
return [];
}
@@ -269,11 +269,13 @@ test('launchWindowsMpv reports missing mpv path', async () => {
test('launchWindowsMpv spawns detached mpv with targets', async () => {
const calls: string[] = [];
const logs: string[] = [];
const result = await launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
logInfo: (message) => logs.push(message),
spawnDetached: async (command, args) => {
calls.push(command);
calls.push(args.join('|'));
@@ -290,6 +292,59 @@ test('launchWindowsMpv spawns detached mpv with targets', async () => {
'C:\\mpv\\mpv.exe',
'--player-operation-mode=pseudo-gui|--force-window=immediate|--script=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua|--input-ipc-server=\\\\.\\pipe\\subminer-socket|--alang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--slang=ja,jp,jpn,japanese,en,eng,english,enus,en-us|--sub-auto=fuzzy|--sub-file-paths=subs;subtitles|--sid=auto|--secondary-sid=auto|--sub-visibility=no|--secondary-sub-visibility=no|--script-opts=subminer-binary_path=C:\\SubMiner\\SubMiner.exe,subminer-socket_path=\\\\.\\pipe\\subminer-socket|C:\\video.mkv',
]);
assert.match(logs[0] ?? '', /mpvPath=C:\\mpv\\mpv\.exe/);
assert.match(logs[0] ?? '', /inputIpcServer=\\\\\.\\pipe\\subminer-socket/);
assert.match(
logs[0] ?? '',
/bundledPlugin=C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main\.lua/,
);
assert.match(logs[0] ?? '', /installedPlugin=none/);
});
test('launchWindowsMpv forwards runtime logging config to mpv and plugin', async () => {
const calls: string[] = [];
const result = await launchWindowsMpv(
['C:\\video.mkv'],
createDeps({
getEnv: (name) => (name === 'SUBMINER_MPV_PATH' ? 'C:\\mpv\\mpv.exe' : undefined),
fileExists: (candidate) => candidate === 'C:\\mpv\\mpv.exe',
spawnDetached: async (command, args, env) => {
calls.push(command);
calls.push(args.join('|'));
calls.push(env?.SUBMINER_LOG_LEVEL ?? '');
calls.push(env?.SUBMINER_LOG_ROTATION ?? '');
},
}),
['--log-file=C:\\Users\\tester\\AppData\\Roaming\\SubMiner\\logs\\mpv-2026-05-26.log'],
'C:\\SubMiner\\SubMiner.exe',
'C:\\Program Files\\SubMiner\\resources\\plugin\\subminer\\main.lua',
'',
'normal',
undefined,
{
socketPath: '\\\\.\\pipe\\subminer-socket',
binaryPath: '',
backend: 'windows',
logLevel: 'debug',
logRotation: 0,
autoStart: true,
autoStartVisibleOverlay: false,
autoStartPauseUntilReady: true,
texthookerEnabled: false,
aniskipEnabled: true,
aniskipButtonKey: 'TAB',
},
);
assert.equal(result.ok, true);
assert.match(calls[1] ?? '', /--msg-level=all=warn,subminer=debug/);
assert.doesNotMatch(calls[1] ?? '', /subminer-log_level=debug/);
assert.match(
calls[1] ?? '',
/--log-file=C:\\Users\\tester\\AppData\\Roaming\\SubMiner\\logs\\mpv-2026-05-26\.log/,
);
assert.equal(calls[2], 'debug');
assert.equal(calls[3], '0');
});
test('launchWindowsMpv skips bundled script when installed plugin is detected', async () => {
+48 -12
View File
@@ -1,6 +1,8 @@
import fs from 'node:fs';
import { spawn, spawnSync } from 'node:child_process';
import { isLogFileEnabled } from '../../shared/log-files';
import { buildMpvLaunchModeArgs } from '../../shared/mpv-launch-mode';
import { buildMpvMsgLevel } from '../../shared/mpv-logging-args';
import { buildSubminerPluginRuntimeScriptOptParts } from '../../shared/subminer-plugin-script-opts';
import type { MpvLaunchMode } from '../../types/config';
import type { SubminerPluginRuntimeScriptOptConfig } from '../../shared/subminer-plugin-script-opts';
@@ -10,8 +12,9 @@ export interface WindowsMpvLaunchDeps {
getEnv: (name: string) => string | undefined;
runWhere: () => { status: number | null; stdout: string; error?: Error };
fileExists: (candidate: string) => boolean;
spawnDetached: (command: string, args: string[]) => Promise<void>;
spawnDetached: (command: string, args: string[], env?: NodeJS.ProcessEnv) => Promise<void>;
showError: (title: string, content: string) => void;
logInfo?: (message: string) => void;
}
export type ConfiguredWindowsMpvPathStatus = 'blank' | 'configured' | 'invalid';
@@ -126,6 +129,13 @@ export function buildWindowsMpvLaunchArgs(
: shouldPassSubminerScriptOpts
? [`subminer-socket_path=${inputIpcServer.replace(/,/g, '\\,')}`]
: [];
const logLevel = pluginRuntimeConfig?.logLevel;
const hasMsgLevel = readExtraArgValue(extraArgs, '--msg-level') !== undefined;
const hasLogFile = readExtraArgValue(extraArgs, '--log-file') !== undefined;
const mpvLogLevelArg =
logLevel && !hasMsgLevel && (isLogFileEnabled('mpv') || hasLogFile)
? `--msg-level=${buildMpvMsgLevel(logLevel)}`
: null;
if (!pluginRuntimeConfig && hasBinaryPath) {
scriptOptPairs.unshift(`subminer-binary_path=${binaryPath.trim().replace(/,/g, '\\,')}`);
}
@@ -147,6 +157,7 @@ export function buildWindowsMpvLaunchArgs(
'--secondary-sub-visibility=no',
...(scriptOpts ? [scriptOpts] : []),
...buildMpvLaunchModeArgs(launchMode),
...(mpvLogLevelArg ? [mpvLogLevelArg] : []),
...extraArgs,
...targets,
];
@@ -197,17 +208,39 @@ export async function launchWindowsMpv(
if (installedPlugin?.installed && !installedPluginPrompted) {
runtimePluginPolicy?.notifyInstalledPluginDetected?.(installedPlugin);
}
await deps.spawnDetached(
mpvPath,
buildWindowsMpvLaunchArgs(
targets,
extraArgs,
binaryPath,
runtimePluginEntrypointPath,
launchMode,
pluginRuntimeConfig,
),
const hasLogLevel = pluginRuntimeConfig?.logLevel !== undefined;
const hasLogRotation = pluginRuntimeConfig?.logRotation !== undefined;
const launchEnv =
hasLogLevel || hasLogRotation
? {
...(hasLogLevel
? { SUBMINER_LOG_LEVEL: pluginRuntimeConfig.logLevel }
: {}),
...(hasLogRotation
? { SUBMINER_LOG_ROTATION: String(pluginRuntimeConfig.logRotation) }
: {}),
}
: undefined;
const launchArgs = buildWindowsMpvLaunchArgs(
targets,
extraArgs,
binaryPath,
runtimePluginEntrypointPath,
launchMode,
pluginRuntimeConfig,
);
const inputIpcServer =
readExtraArgValue(launchArgs, '--input-ipc-server') ?? DEFAULT_WINDOWS_MPV_SOCKET;
deps.logInfo?.(
[
`Launching mpv: mpvPath=${mpvPath}`,
`inputIpcServer=${inputIpcServer}`,
`bundledPlugin=${runtimePluginEntrypointPath ?? 'not injected'}`,
`installedPlugin=${installedPlugin?.installed ? (installedPlugin.path ?? 'unknown') : 'none'}`,
`targets=${targets.length}`,
].join('; '),
);
await deps.spawnDetached(mpvPath, launchArgs, launchEnv);
return { ok: true, mpvPath };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
@@ -220,6 +253,7 @@ export function createWindowsMpvLaunchDeps(options: {
getEnv?: (name: string) => string | undefined;
fileExists?: (candidate: string) => boolean;
showError: (title: string, content: string) => void;
logInfo?: (message: string) => void;
}): WindowsMpvLaunchDeps {
return {
getEnv: options.getEnv ?? ((name) => process.env[name]),
@@ -235,13 +269,15 @@ export function createWindowsMpvLaunchDeps(options: {
};
},
fileExists: options.fileExists ?? defaultWindowsMpvFileExists,
spawnDetached: (command, args) =>
logInfo: options.logInfo,
spawnDetached: (command, args, env) =>
new Promise((resolve, reject) => {
try {
const child = spawn(command, args, {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: env ? { ...process.env, ...env } : process.env,
});
let settled = false;
child.once('error', (error) => {
+2
View File
@@ -7,6 +7,7 @@ export interface StartupBootstrapRuntimeFactoryDeps {
argv: string[];
parseArgs: (argv: string[]) => CliArgs;
setLogLevel: (level: string, source: LogLevelSource) => void;
setLogRotation?: (rotation: number) => void;
forceX11Backend: (args: CliArgs) => void;
enforceUnsupportedWaylandMode: (args: CliArgs) => void;
shouldStartApp: (args: CliArgs) => boolean;
@@ -35,6 +36,7 @@ export function createStartupBootstrapRuntimeDeps(
argv: params.argv,
parseArgs: params.parseArgs,
setLogLevel: params.setLogLevel,
setLogRotation: params.setLogRotation,
forceX11Backend: (args: CliArgs) => params.forceX11Backend(args),
enforceUnsupportedWaylandMode: (args: CliArgs) => params.enforceUnsupportedWaylandMode(args),
getDefaultSocketPath: params.getDefaultSocketPath,
-4
View File
@@ -156,9 +156,6 @@ function createLatestValueIpcListenerWithPayload<T>(
const onOpenRuntimeOptionsEvent = createQueuedIpcListener(IPC_CHANNELS.event.runtimeOptionsOpen);
const onOpenSessionHelpEvent = createQueuedIpcListener(IPC_CHANNELS.event.sessionHelpOpen);
const onOpenCharacterDictionaryEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryOpen,
);
const onOpenCharacterDictionaryManagerEvent = createQueuedIpcListener(
IPC_CHANNELS.event.characterDictionaryManagerOpen,
);
@@ -390,7 +387,6 @@ const electronAPI: ElectronAPI = {
onOpenJimaku: onOpenJimakuEvent,
onOpenYoutubeTrackPicker: onOpenYoutubeTrackPickerEvent,
onOpenPlaylistBrowser: onOpenPlaylistBrowserEvent,
onOpenCharacterDictionary: onOpenCharacterDictionaryEvent,
onOpenCharacterDictionaryManager: onOpenCharacterDictionaryManagerEvent,
onSubtitleSidebarToggle: onSubtitleSidebarToggleEvent,
onPrimarySubtitleBarToggle: onPrimarySubtitleBarToggleEvent,
@@ -204,8 +204,6 @@ function describeSessionAction(
return 'Open runtime options';
case 'openSessionHelp':
return 'Open session help';
case 'openCharacterDictionary':
return 'Open AniList override selector';
case 'openCharacterDictionaryManager':
return 'Open character dictionary manager';
case 'openControllerSelect':
@@ -256,7 +254,6 @@ function sectionForSessionBinding(binding: CompiledSessionBinding): string {
return 'Subtitle sync';
case 'openRuntimeOptions':
case 'openJimaku':
case 'openCharacterDictionary':
case 'openCharacterDictionaryManager':
case 'openControllerSelect':
case 'openControllerDebug':
-5
View File
@@ -460,11 +460,6 @@ function registerModalOpenHandlers(): void {
window.electronAPI.notifyOverlayModalOpened('runtime-options');
});
});
window.electronAPI.onOpenCharacterDictionary(() => {
runGuardedAsync('character-dictionary:open', async () => {
await characterDictionaryModal.openCharacterDictionaryModal();
});
});
window.electronAPI.onOpenCharacterDictionaryManager(() => {
runGuardedAsync('character-dictionary-manager:open', async () => {
await characterDictionaryModal.openCharacterDictionaryManagerModal();
-1
View File
@@ -133,7 +133,6 @@ export const IPC_CHANNELS = {
keyboardModeToggleRequested: 'keyboard-mode-toggle:requested',
lookupWindowToggleRequested: 'lookup-window-toggle:requested',
sessionHelpOpen: 'session-help:open',
characterDictionaryOpen: 'character-dictionary:open',
characterDictionaryManagerOpen: 'character-dictionary:manager-open',
controllerSelectOpen: 'controller-select:open',
controllerDebugOpen: 'controller-debug:open',
-1
View File
@@ -33,7 +33,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
'toggleSubtitleSidebar',
'openRuntimeOptions',
'openSessionHelp',
'openCharacterDictionary',
'openCharacterDictionaryManager',
'openControllerSelect',
'openControllerDebug',
+65 -8
View File
@@ -3,7 +3,14 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { appendLogLine, pruneLogFiles, resolveDefaultLogFilePath } from './log-files';
import {
applyLogFileTogglesToEnv,
appendLogLine,
isLogFileEnabled,
pruneLogDirectoryForPath,
pruneLogFiles,
resolveDefaultLogFilePath,
} from './log-files';
test('resolveDefaultLogFilePath uses app prefix by default', () => {
const now = new Date('2026-03-22T12:00:00.000Z');
@@ -15,16 +22,47 @@ test('resolveDefaultLogFilePath uses app prefix by default', () => {
assert.equal(
resolved,
path.join(
'/home/tester',
'.config',
'SubMiner',
'logs',
`app-${now.toISOString().slice(0, 10)}.log`,
),
path.join('/home/tester', '.config', 'SubMiner', 'logs', 'app-2026-03-22.log'),
);
});
test('resolveDefaultLogFilePath uses daily filenames for mpv logs', () => {
const now = new Date('2026-03-22T12:00:00.000Z');
const resolved = resolveDefaultLogFilePath('mpv', {
platform: 'linux',
homeDir: '/home/tester',
now,
});
assert.equal(
resolved,
path.join('/home/tester', '.config', 'SubMiner', 'logs', 'mpv-2026-03-22.log'),
);
});
test('log file toggles keep app and launcher enabled while mpv defaults off', () => {
assert.equal(isLogFileEnabled('app', {}), true);
assert.equal(isLogFileEnabled('launcher', {}), true);
assert.equal(isLogFileEnabled('mpv', {}), false);
assert.equal(isLogFileEnabled('mpv', { SUBMINER_MPV_LOG: '/tmp/mpv.log' }), true);
assert.equal(
isLogFileEnabled('mpv', {
SUBMINER_MPV_LOG: '/tmp/mpv.log',
SUBMINER_MPV_LOG_ENABLED: 'false',
}),
false,
);
});
test('applyLogFileTogglesToEnv writes log enable env flags', () => {
const env: NodeJS.ProcessEnv = {};
applyLogFileTogglesToEnv({ app: false, launcher: true, mpv: true }, env);
assert.equal(env.SUBMINER_APP_LOG_ENABLED, 'false');
assert.equal(env.SUBMINER_LAUNCHER_LOG_ENABLED, 'true');
assert.equal(env.SUBMINER_MPV_LOG_ENABLED, 'true');
});
test('pruneLogFiles removes logs older than retention window', () => {
const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-log-prune-'));
const stalePath = path.join(logsDir, 'app-old.log');
@@ -69,3 +107,22 @@ test('appendLogLine trims oversized logs to newest bytes', () => {
fs.rmSync(logsDir, { recursive: true, force: true });
}
});
test('empty log path operations are no-ops', () => {
const cwd = process.cwd();
const logsDir = fs.mkdtempSync(path.join(os.tmpdir(), 'subminer-empty-log-path-'));
const candidate = path.join(logsDir, 'cwd.log');
try {
process.chdir(logsDir);
fs.writeFileSync(candidate, 'keep\n', 'utf8');
pruneLogDirectoryForPath('', 1);
appendLogLine('', 'ignored', { retentionDays: 1 });
assert.equal(fs.readFileSync(candidate, 'utf8'), 'keep\n');
} finally {
process.chdir(cwd);
fs.rmSync(logsDir, { recursive: true, force: true });
}
});
+76 -3
View File
@@ -3,15 +3,35 @@ import os from 'node:os';
import path from 'node:path';
export type LogKind = 'app' | 'launcher' | 'mpv';
export type LogRotation = number;
export type LogFileToggles = Record<LogKind, boolean>;
export const DEFAULT_LOG_RETENTION_DAYS = 7;
export const DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024;
export const DEFAULT_LOG_ROTATION: LogRotation = DEFAULT_LOG_RETENTION_DAYS;
export const DEFAULT_LOG_FILE_TOGGLES: LogFileToggles = {
app: true,
launcher: true,
mpv: false,
};
const TRUNCATED_MARKER = '[truncated older log content]\n';
const prunedDirectories = new Set<string>();
const NS_PER_MS = 1_000_000n;
const MS_PER_DAY = 86_400_000n;
const LOG_ENABLED_ENV_BY_KIND: Record<LogKind, string> = {
app: 'SUBMINER_APP_LOG_ENABLED',
launcher: 'SUBMINER_LAUNCHER_LOG_ENABLED',
mpv: 'SUBMINER_MPV_LOG_ENABLED',
};
const LOG_PATH_ENV_BY_KIND: Record<LogKind, string> = {
app: 'SUBMINER_APP_LOG',
launcher: 'SUBMINER_LAUNCHER_LOG',
mpv: 'SUBMINER_MPV_LOG',
};
function floorDiv(left: number, right: number): number {
return Math.floor(left / right);
}
@@ -54,10 +74,56 @@ export function resolveDefaultLogFilePath(
homeDir?: string;
appDataDir?: string;
now?: Date;
rotation?: unknown;
},
): string {
const date = (options?.now ?? new Date()).toISOString().slice(0, 10);
return path.join(resolveLogBaseDir(options), 'logs', `${kind}-${date}.log`);
const now = options?.now ?? new Date();
const suffix = now.toISOString().slice(0, 10);
return path.join(resolveLogBaseDir(options), 'logs', `${kind}-${suffix}.log`);
}
export function normalizeLogRotation(rotation: unknown): LogRotation | undefined {
const parsed =
typeof rotation === 'number'
? rotation
: typeof rotation === 'string' && /^\d+$/.test(rotation.trim())
? Number(rotation.trim())
: Number.NaN;
if (!Number.isInteger(parsed) || parsed <= 0) return undefined;
return parsed;
}
export function normalizeLogFileEnabled(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value !== 'string') return undefined;
const normalized = value.trim().toLowerCase();
if (['1', 'true', 'yes', 'on'].includes(normalized)) return true;
if (['0', 'false', 'no', 'off'].includes(normalized)) return false;
return undefined;
}
export function isLogFileEnabled(kind: LogKind, env: NodeJS.ProcessEnv = process.env): boolean {
const configured = normalizeLogFileEnabled(env[LOG_ENABLED_ENV_BY_KIND[kind]]);
if (configured !== undefined) return configured;
const explicitPath = env[LOG_PATH_ENV_BY_KIND[kind]]?.trim();
if (explicitPath) return true;
return DEFAULT_LOG_FILE_TOGGLES[kind];
}
export function applyLogFileTogglesToEnv(
files: Partial<LogFileToggles> | undefined,
env: NodeJS.ProcessEnv = process.env,
): void {
for (const kind of Object.keys(LOG_ENABLED_ENV_BY_KIND) as LogKind[]) {
const explicitPath = env[LOG_PATH_ENV_BY_KIND[kind]]?.trim();
const enabled = files?.[kind] ?? (explicitPath ? true : DEFAULT_LOG_FILE_TOGGLES[kind]);
env[LOG_ENABLED_ENV_BY_KIND[kind]] = String(enabled);
}
}
export function getLogRetentionDays(rotation: unknown): number {
const normalized = normalizeLogRotation(rotation) ?? DEFAULT_LOG_ROTATION;
return normalized;
}
export function pruneLogFiles(
@@ -107,6 +173,11 @@ function maybePruneLogDirectory(logPath: string, retentionDays: number): void {
prunedDirectories.add(key);
}
export function pruneLogDirectoryForPath(logPath: string, rotation?: unknown): void {
if (!logPath.trim()) return;
maybePruneLogDirectory(logPath, getLogRetentionDays(rotation));
}
function trimLogFileToMaxBytes(logPath: string, maxBytes: number): void {
if (!Number.isFinite(maxBytes) || maxBytes <= 0) return;
@@ -135,10 +206,12 @@ export function appendLogLine(
line: string,
options?: {
retentionDays?: number;
rotation?: unknown;
maxBytes?: number;
},
): void {
const retentionDays = options?.retentionDays ?? DEFAULT_LOG_RETENTION_DAYS;
if (!logPath.trim()) return;
const retentionDays = options?.retentionDays ?? getLogRetentionDays(options?.rotation);
const maxBytes = options?.maxBytes ?? DEFAULT_LOG_MAX_BYTES;
try {
+27
View File
@@ -0,0 +1,27 @@
export type SharedLogLevel = 'debug' | 'info' | 'warn' | 'error';
function hasOption(args: readonly string[], option: string): boolean {
return args.some((arg) => arg === option || arg.startsWith(`${option}=`));
}
export function buildMpvMsgLevel(logLevel: SharedLogLevel): string {
return `all=warn,subminer=${logLevel}`;
}
export function buildMpvLoggingArgs(
logLevel: SharedLogLevel,
logPath: string,
existingArgs: readonly string[] = [],
): string[] {
if (!logPath.trim()) {
return [];
}
const args: string[] = [];
if (!hasOption(existingArgs, '--log-file')) {
args.push(`--log-file=${logPath}`);
}
if (!hasOption(existingArgs, '--msg-level')) {
args.push(`--msg-level=${buildMpvMsgLevel(logLevel)}`);
}
return args;
}
+219
View File
@@ -0,0 +1,219 @@
import * as fs from 'fs';
type ZipEntry = {
name: string;
crc32: number;
size: number;
localHeaderOffset: number;
};
const ZIP32_MAX_UINT16 = 0xffff;
const ZIP32_MAX_UINT32 = 0xffffffff;
export type StoredZipFile = {
name: string;
data: Buffer;
};
function writeUint32LE(buffer: Buffer, value: number, offset: number): number {
const normalized = value >>> 0;
buffer[offset] = normalized & 0xff;
buffer[offset + 1] = (normalized >>> 8) & 0xff;
buffer[offset + 2] = (normalized >>> 16) & 0xff;
buffer[offset + 3] = (normalized >>> 24) & 0xff;
return offset + 4;
}
const CRC32_TABLE = (() => {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i += 1) {
let crc = i;
for (let j = 0; j < 8; j += 1) {
crc = (crc & 1) !== 0 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
}
table[i] = crc >>> 0;
}
return table;
})();
function crc32(data: Buffer): number {
let crc = 0xffffffff;
for (const byte of data) {
crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
function createLocalFileHeader(fileName: Buffer, fileCrc32: number, fileSize: number): Buffer {
const local = Buffer.alloc(30 + fileName.length);
let cursor = 0;
writeUint32LE(local, 0x04034b50, cursor);
cursor += 4;
local.writeUInt16LE(20, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
writeUint32LE(local, fileCrc32, cursor);
cursor += 4;
writeUint32LE(local, fileSize, cursor);
cursor += 4;
writeUint32LE(local, fileSize, cursor);
cursor += 4;
local.writeUInt16LE(fileName.length, cursor);
cursor += 2;
local.writeUInt16LE(0, cursor);
cursor += 2;
fileName.copy(local, cursor);
return local;
}
function createCentralDirectoryHeader(entry: ZipEntry): Buffer {
const fileName = Buffer.from(entry.name, 'utf8');
const central = Buffer.alloc(46 + fileName.length);
let cursor = 0;
writeUint32LE(central, 0x02014b50, cursor);
cursor += 4;
central.writeUInt16LE(20, cursor);
cursor += 2;
central.writeUInt16LE(20, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
writeUint32LE(central, entry.crc32, cursor);
cursor += 4;
writeUint32LE(central, entry.size, cursor);
cursor += 4;
writeUint32LE(central, entry.size, cursor);
cursor += 4;
central.writeUInt16LE(fileName.length, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
central.writeUInt16LE(0, cursor);
cursor += 2;
writeUint32LE(central, 0, cursor);
cursor += 4;
writeUint32LE(central, entry.localHeaderOffset, cursor);
cursor += 4;
fileName.copy(central, cursor);
return central;
}
function createEndOfCentralDirectory(
entriesLength: number,
centralSize: number,
centralStart: number,
): Buffer {
if (
entriesLength > ZIP32_MAX_UINT16 ||
centralSize > ZIP32_MAX_UINT32 ||
centralStart > ZIP32_MAX_UINT32
) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
const end = Buffer.alloc(22);
let cursor = 0;
writeUint32LE(end, 0x06054b50, cursor);
cursor += 4;
end.writeUInt16LE(0, cursor);
cursor += 2;
end.writeUInt16LE(0, cursor);
cursor += 2;
end.writeUInt16LE(entriesLength, cursor);
cursor += 2;
end.writeUInt16LE(entriesLength, cursor);
cursor += 2;
writeUint32LE(end, centralSize, cursor);
cursor += 4;
writeUint32LE(end, centralStart, cursor);
cursor += 4;
end.writeUInt16LE(0, cursor);
return end;
}
function writeBuffer(fd: number, buffer: Buffer): void {
let written = 0;
while (written < buffer.length) {
written += fs.writeSync(fd, buffer, written, buffer.length - written);
}
}
export function writeStoredZip(
outputPath: string,
files: Iterable<StoredZipFile>,
): { entryCount: number } {
const entries: ZipEntry[] = [];
let offset = 0;
const fd = fs.openSync(outputPath, 'w');
try {
for (const file of files) {
const fileName = Buffer.from(file.name, 'utf8');
const fileSize = file.data.length;
if (fileName.length > ZIP32_MAX_UINT16) {
throw new RangeError(`ZIP entry name too long: ${file.name}`);
}
if (fileSize > ZIP32_MAX_UINT32) {
throw new RangeError(`ZIP entry too large for ZIP32: ${file.name}`);
}
if (offset > ZIP32_MAX_UINT32) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
const fileCrc32 = crc32(file.data);
const localHeader = createLocalFileHeader(fileName, fileCrc32, fileSize);
const nextOffset = offset + localHeader.length + fileSize;
if (nextOffset > ZIP32_MAX_UINT32) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
writeBuffer(fd, localHeader);
writeBuffer(fd, file.data);
entries.push({
name: file.name,
crc32: fileCrc32,
size: fileSize,
localHeaderOffset: offset,
});
if (nextOffset > ZIP32_MAX_UINT32) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
offset = nextOffset;
}
const centralStart = offset;
if (centralStart > ZIP32_MAX_UINT32) {
throw new RangeError('Archive exceeds ZIP32 limits (Zip64 not implemented)');
}
for (const entry of entries) {
const centralHeader = createCentralDirectoryHeader(entry);
writeBuffer(fd, centralHeader);
offset += centralHeader.length;
}
const centralSize = offset - centralStart;
writeBuffer(fd, createEndOfCentralDirectory(entries.length, centralSize, centralStart));
} catch (error) {
fs.closeSync(fd);
fs.rmSync(outputPath, { force: true });
throw error;
}
fs.closeSync(fd);
return { entryCount: entries.length };
}
@@ -1,9 +1,12 @@
import type { MpvBackend } from '../types/config';
import type { LogRotation } from './log-files';
export interface SubminerPluginRuntimeScriptOptConfig {
socketPath: string;
binaryPath?: string;
backend: MpvBackend;
logLevel?: 'debug' | 'info' | 'warn' | 'error';
logRotation?: LogRotation;
autoStart: boolean;
autoStartVisibleOverlay: boolean;
autoStartPauseUntilReady: boolean;

Some files were not shown because too many files have changed in this diff Show More