mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-06-10 03:13:32 -07:00
Fix Windows mpv logging and add log export (#88)
This commit is contained in:
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
);
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)));
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -41,7 +41,6 @@ function makeArgs(overrides: Partial<CliArgs> = {}): CliArgs {
|
||||
openJimaku: false,
|
||||
openYoutubePicker: false,
|
||||
openPlaylistBrowser: false,
|
||||
openCharacterDictionary: false,
|
||||
replayCurrentSubtitle: false,
|
||||
playNextSubtitle: false,
|
||||
shiftSubDelayPrevLine: false,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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: () => {},
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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()),
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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' });
|
||||
|
||||
@@ -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' }) =>
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -33,7 +33,6 @@ const SESSION_ACTION_IDS: SessionActionId[] = [
|
||||
'toggleSubtitleSidebar',
|
||||
'openRuntimeOptions',
|
||||
'openSessionHelp',
|
||||
'openCharacterDictionary',
|
||||
'openCharacterDictionaryManager',
|
||||
'openControllerSelect',
|
||||
'openControllerDebug',
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user