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

This commit is contained in:
2026-05-26 00:31:38 -07:00
committed by GitHub
parent 43ebc7d371
commit 11c196821d
150 changed files with 2748 additions and 582 deletions
+108 -4
View File
@@ -63,7 +63,6 @@ test('loads defaults when config is missing', () => {
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
assert.equal(config.ankiConnect.media.audioPadding, 0);
assert.equal(config.anilist.enabled, false);
assert.equal(config.anilist.characterDictionary.enabled, false);
assert.equal(config.subtitleStyle.nameMatchImagesEnabled, false);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
@@ -96,7 +95,6 @@ test('loads defaults when config is missing', () => {
assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
assert.equal(config.shortcuts.markAudioCard, 'CommandOrControl+Shift+A');
assert.equal('openCharacterDictionary' in config.shortcuts, false);
assert.equal(config.shortcuts.openCharacterDictionaryManager, 'CommandOrControl+D');
assert.equal(config.shortcuts.toggleSubtitleSidebar, 'Backslash');
assert.equal(config.discordPresence.enabled, true);
@@ -825,7 +823,6 @@ test('parses anilist.characterDictionary config with clamping and enum validatio
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.anilist.characterDictionary.enabled, true);
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 1);
assert.equal(config.anilist.characterDictionary.maxLoaded, 20);
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
@@ -1462,6 +1459,50 @@ test('accepts valid logging.level', () => {
assert.equal(config.logging.level, 'warn');
});
test('accepts valid logging.rotation', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"rotation": 14
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.logging.rotation, 14);
});
test('accepts valid logging file toggles', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"files": {
"app": false,
"launcher": true,
"mpv": true
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.deepEqual(config.logging.files, {
app: false,
launcher: true,
mpv: true,
});
});
test('falls back for invalid logging.level and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -1482,6 +1523,68 @@ test('falls back for invalid logging.level and reports warning', () => {
assert.ok(warnings.some((warning) => warning.path === 'logging.level'));
});
test('falls back for invalid logging.rotation and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"rotation": 0
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.logging.rotation, DEFAULT_CONFIG.logging.rotation);
assert.ok(warnings.some((warning) => warning.path === 'logging.rotation'));
});
test('falls back for invalid logging file toggles and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"files": {
"mpv": "yes"
}
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.logging.files.mpv, DEFAULT_CONFIG.logging.files.mpv);
assert.ok(warnings.some((warning) => warning.path === 'logging.files.mpv'));
});
test('falls back for invalid logging files object and reports warning', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"logging": {
"files": false
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.deepEqual(config.logging.files, DEFAULT_CONFIG.logging.files);
assert.ok(warnings.some((warning) => warning.path === 'logging.files'));
});
test('warns and ignores unknown top-level config keys', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -2518,6 +2621,7 @@ test('template generator includes known keys', () => {
assert.doesNotMatch(output, /"clientVersion":/);
assert.doesNotMatch(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/);
assert.doesNotMatch(output, /"characterDictionary":\s*\{\s*"enabled":/);
assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"knownWords"\s*:\s*\{/);
assert.match(output, /"knownWordColor": "#a6da95"/);
@@ -2527,7 +2631,7 @@ test('template generator includes known keys', () => {
assert.match(output, /auto-generated from src\/config\/definitions.ts/);
assert.match(
output,
/"level": "info",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/,
/"level": "warn",? \/\/ Minimum log level for runtime logging\. Values: debug \| info \| warn \| error/,
);
assert.match(
output,
+7 -1
View File
@@ -28,7 +28,13 @@ export const CORE_DEFAULT_CONFIG: Pick<
port: 6678,
},
logging: {
level: 'info',
level: 'warn',
rotation: 7,
files: {
app: true,
launcher: true,
mpv: false,
},
},
texthooker: {
launchAtStartup: false,
@@ -110,7 +110,6 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
enabled: false,
accessToken: '',
characterDictionary: {
enabled: false,
refreshTtlHours: 168,
maxLoaded: 3,
evictionPolicy: 'delete',
@@ -92,6 +92,7 @@ test('config option registry includes critical paths and has unique entries', ()
for (const requiredPath of [
'logging.level',
'logging.files.mpv',
'annotationWebsocket.enabled',
'controller.enabled',
'controller.scrollPixelsPerSecond',
@@ -101,7 +102,7 @@ test('config option registry includes critical paths and has unique entries', ()
'subtitleStyle.enableJlpt',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
'ankiConnect.enabled',
'anilist.characterDictionary.enabled',
'subtitleStyle.nameMatchEnabled',
'anilist.characterDictionary.collapsibleSections.description',
'mpv.executablePath',
'mpv.launchMode',
+24
View File
@@ -83,6 +83,30 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.logging.level,
description: 'Minimum log level for runtime logging.',
},
{
path: 'logging.rotation',
kind: 'number',
defaultValue: defaultConfig.logging.rotation,
description: 'Number of days of app, launcher, and mpv logs to retain.',
},
{
path: 'logging.files.app',
kind: 'boolean',
defaultValue: defaultConfig.logging.files.app,
description: 'Write SubMiner app runtime logs.',
},
{
path: 'logging.files.launcher',
kind: 'boolean',
defaultValue: defaultConfig.logging.files.launcher,
description: 'Write launcher command logs.',
},
{
path: 'logging.files.mpv',
kind: 'boolean',
defaultValue: defaultConfig.logging.files.mpv,
description: 'Write mpv player logs. Enable temporarily when debugging mpv/plugin startup.',
},
{
path: 'youtube.primarySubLanguages',
kind: 'string',
@@ -392,13 +392,6 @@ export function buildIntegrationConfigOptionRegistry(
description:
'Optional explicit AniList access token override; leave empty to use locally stored token from setup.',
},
{
path: 'anilist.characterDictionary.enabled',
kind: 'boolean',
defaultValue: defaultConfig.anilist.characterDictionary.enabled,
description:
'Enable automatic Yomitan character dictionary sync for currently watched AniList media.',
},
{
path: 'anilist.characterDictionary.refreshTtlHours',
kind: 'number',
@@ -426,7 +419,7 @@ export function buildIntegrationConfigOptionRegistry(
kind: 'enum',
enumValues: ['all', 'active'],
defaultValue: defaultConfig.anilist.characterDictionary.profileScope,
description: 'Yomitan profile scope for dictionary enable/disable updates.',
description: 'Yomitan profile scope for character dictionary settings updates.',
},
{
path: 'anilist.characterDictionary.collapsibleSections.description',
+1 -1
View File
@@ -74,7 +74,7 @@ export function buildSubtitleConfigOptionRegistry(
kind: 'boolean',
defaultValue: defaultConfig.subtitleStyle.nameMatchEnabled,
description:
'Enable subtitle token coloring for matches from the SubMiner character dictionary.',
'Enable character dictionary sync and subtitle token coloring for character-name matches.',
},
{
path: 'subtitleStyle.nameMatchImagesEnabled',
+1 -1
View File
@@ -33,7 +33,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
{
title: 'Logging',
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
notes: ['Hot-reload: logging.level applies live while SubMiner is running.'],
notes: ['Hot-reload: logging.level and logging.files apply live while SubMiner is running.'],
key: 'logging',
},
{
+30
View File
@@ -100,6 +100,36 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
'Expected debug, info, warn, or error.',
);
}
const logRotation = src.logging.rotation;
if (typeof logRotation === 'number' && Number.isInteger(logRotation) && logRotation > 0) {
resolved.logging.rotation = logRotation;
} else if (src.logging.rotation !== undefined) {
warn(
'logging.rotation',
src.logging.rotation,
resolved.logging.rotation,
'Expected a positive whole number of days.',
);
}
if (isObject(src.logging.files)) {
for (const key of ['app', 'launcher', 'mpv'] as const) {
const enabled = asBoolean(src.logging.files[key]);
if (enabled !== undefined) {
resolved.logging.files[key] = enabled;
} else if (src.logging.files[key] !== undefined) {
warn(
`logging.files.${key}`,
src.logging.files[key],
resolved.logging.files[key],
'Expected boolean.',
);
}
}
} else if (src.logging.files !== undefined) {
warn('logging.files', src.logging.files, resolved.logging.files, 'Expected object.');
}
}
applyControllerConfig(context);
-12
View File
@@ -81,18 +81,6 @@ export function applyIntegrationConfig(context: ResolveContext): void {
if (isObject(src.anilist.characterDictionary)) {
const characterDictionary = src.anilist.characterDictionary;
const dictionaryEnabled = asBoolean(characterDictionary.enabled);
if (dictionaryEnabled !== undefined) {
resolved.anilist.characterDictionary.enabled = dictionaryEnabled;
} else if (characterDictionary.enabled !== undefined) {
warn(
'anilist.characterDictionary.enabled',
characterDictionary.enabled,
resolved.anilist.characterDictionary.enabled,
'Expected boolean.',
);
}
const refreshTtlHours = asNumber(characterDictionary.refreshTtlHours);
if (refreshTtlHours !== undefined) {
const normalized = Math.min(24 * 365, Math.max(1, Math.floor(refreshTtlHours)));
-2
View File
@@ -97,7 +97,6 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
const { context, warnings } = createResolveContext({
anilist: {
characterDictionary: {
enabled: true,
refreshTtlHours: 0,
maxLoaded: 99,
evictionPolicy: 'purge' as never,
@@ -113,7 +112,6 @@ test('anilist character dictionary fields are parsed, clamped, and enum-validate
applyIntegrationConfig(context);
assert.equal(context.resolved.anilist.characterDictionary.enabled, true);
assert.equal(context.resolved.anilist.characterDictionary.refreshTtlHours, 1);
assert.equal(context.resolved.anilist.characterDictionary.maxLoaded, 20);
assert.equal(context.resolved.anilist.characterDictionary.evictionPolicy, 'delete');
+5 -6
View File
@@ -55,10 +55,6 @@ test('settings registry moves AniSkip button key into input shortcuts and hot re
});
test('settings registry exposes character dictionary panel shortcuts dynamically', () => {
assert.equal(
fields.some((candidate) => candidate.configPath === 'shortcuts.openCharacterDictionary'),
false,
);
assert.equal(
field('shortcuts.openCharacterDictionaryManager').label,
'Open Character Dictionary Manager',
@@ -69,7 +65,6 @@ test('settings registry exposes character dictionary panel shortcuts dynamically
test('settings registry hides removed modal-only fields', () => {
for (const path of [
'shortcuts.multiCopyTimeoutMs',
'shortcuts.openCharacterDictionary',
'anilist.characterDictionary.profileScope',
'jellyfin.directPlayContainers',
]) {
@@ -265,7 +260,7 @@ test('settings registry hides app-managed and inactive config surfaces', () => {
]) {
assert.equal(paths.has(hiddenPath), false, `${hiddenPath} should be hidden`);
}
assert.equal(field('anilist.characterDictionary.enabled').section, 'Character Dictionary');
assert.equal(paths.has('anilist.characterDictionary.enabled'), false);
});
test('settings registry marks safe live config paths as hot-reloadable', () => {
@@ -274,6 +269,10 @@ test('settings registry marks safe live config paths as hot-reloadable', () => {
'stats.toggleKey',
'stats.markWatchedKey',
'logging.level',
'logging.rotation',
'logging.files.app',
'logging.files.launcher',
'logging.files.mpv',
'youtube.primarySubLanguages',
'jimaku.apiBaseUrl',
'jimaku.languagePreference',
+8
View File
@@ -63,6 +63,7 @@ export const LEGACY_HIDDEN_CONFIG_PATHS = [
'controller.preferredGamepadLabel',
'controller.profiles',
'youtubeSubgen.primarySubLanguages',
'anilist.characterDictionary.enabled',
'anilist.characterDictionary.refreshTtlHours',
'anilist.characterDictionary.evictionPolicy',
'anilist.characterDictionary.profileScope',
@@ -184,6 +185,11 @@ const PATH_ORDER = new Map<string, number>(
'mpv.launchMode',
'mpv.executablePath',
'mpv.aniskipButtonKey',
'logging.level',
'logging.rotation',
'logging.files.app',
'logging.files.launcher',
'logging.files.mpv',
].map((path, index) => [path, index]),
);
@@ -667,6 +673,8 @@ function restartBehaviorForPath(path: string): ConfigSettingsRestartBehavior {
path === 'stats.toggleKey' ||
path === 'stats.markWatchedKey' ||
path === 'logging.level' ||
path === 'logging.rotation' ||
pathStartsWith(path, 'logging.files') ||
path === 'youtube.primarySubLanguages' ||
pathStartsWith(path, 'jimaku') ||
pathStartsWith(path, 'subsync')