mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-27 06:12:05 -07:00
Enhance AniList character dictionary sync and subtitle features (#15)
This commit is contained in:
@@ -16,9 +16,20 @@ test('loads defaults when config is missing', () => {
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
|
||||
assert.equal(config.annotationWebsocket.enabled, DEFAULT_CONFIG.annotationWebsocket.enabled);
|
||||
assert.equal(config.annotationWebsocket.port, DEFAULT_CONFIG.annotationWebsocket.port);
|
||||
assert.equal(config.texthooker.launchAtStartup, true);
|
||||
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
|
||||
assert.deepEqual(config.ankiConnect.tags, ['SubMiner']);
|
||||
assert.equal(config.anilist.enabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.enabled, false);
|
||||
assert.equal(config.anilist.characterDictionary.refreshTtlHours, 168);
|
||||
assert.equal(config.anilist.characterDictionary.maxLoaded, 3);
|
||||
assert.equal(config.anilist.characterDictionary.evictionPolicy, 'delete');
|
||||
assert.equal(config.anilist.characterDictionary.profileScope, 'all');
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, false);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, false);
|
||||
assert.equal(config.jellyfin.remoteControlEnabled, true);
|
||||
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
|
||||
assert.equal(config.jellyfin.autoAnnounce, false);
|
||||
@@ -123,6 +134,88 @@ test('parses subtitleStyle.preserveLineBreaks and warns on invalid values', () =
|
||||
);
|
||||
});
|
||||
|
||||
test('parses texthooker.launchAtStartup and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"texthooker": {
|
||||
"launchAtStartup": false
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().texthooker.launchAtStartup, false);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"texthooker": {
|
||||
"launchAtStartup": "yes"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().texthooker.launchAtStartup,
|
||||
DEFAULT_CONFIG.texthooker.launchAtStartup,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService.getWarnings().some((warning) => warning.path === 'texthooker.launchAtStartup'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses annotationWebsocket settings and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"annotationWebsocket": {
|
||||
"enabled": false,
|
||||
"port": 7788
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().annotationWebsocket.enabled, false);
|
||||
assert.equal(validService.getConfig().annotationWebsocket.port, 7788);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"annotationWebsocket": {
|
||||
"enabled": "yes",
|
||||
"port": "bad"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().annotationWebsocket.enabled,
|
||||
DEFAULT_CONFIG.annotationWebsocket.enabled,
|
||||
);
|
||||
assert.equal(
|
||||
invalidService.getConfig().annotationWebsocket.port,
|
||||
DEFAULT_CONFIG.annotationWebsocket.port,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService.getWarnings().some((warning) => warning.path === 'annotationWebsocket.enabled'),
|
||||
);
|
||||
assert.ok(
|
||||
invalidService.getWarnings().some((warning) => warning.path === 'annotationWebsocket.port'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.autoPauseVideoOnHover and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -237,6 +330,47 @@ test('parses subtitleStyle.hoverTokenColor and warns on invalid values', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.nameMatchColor and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"nameMatchColor": "#eed49f"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(
|
||||
((validService.getConfig().subtitleStyle as unknown as Record<string, unknown>)
|
||||
.nameMatchColor ?? null) as string | null,
|
||||
'#eed49f',
|
||||
);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"nameMatchColor": "pink"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
((invalidService.getConfig().subtitleStyle as unknown as Record<string, unknown>)
|
||||
.nameMatchColor ?? null) as string | null,
|
||||
'#f5bde6',
|
||||
);
|
||||
assert.ok(
|
||||
invalidService.getWarnings().some((warning) => warning.path === 'subtitleStyle.nameMatchColor'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -275,6 +409,44 @@ test('parses subtitleStyle.hoverTokenBackgroundColor and warns on invalid values
|
||||
);
|
||||
});
|
||||
|
||||
test('parses subtitleStyle.nameMatchEnabled and warns on invalid values', () => {
|
||||
const validDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(validDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"nameMatchEnabled": false
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const validService = new ConfigService(validDir);
|
||||
assert.equal(validService.getConfig().subtitleStyle.nameMatchEnabled, false);
|
||||
|
||||
const invalidDir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(invalidDir, 'config.jsonc'),
|
||||
`{
|
||||
"subtitleStyle": {
|
||||
"nameMatchEnabled": "no"
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const invalidService = new ConfigService(invalidDir);
|
||||
assert.equal(
|
||||
invalidService.getConfig().subtitleStyle.nameMatchEnabled,
|
||||
DEFAULT_CONFIG.subtitleStyle.nameMatchEnabled,
|
||||
);
|
||||
assert.ok(
|
||||
invalidService
|
||||
.getWarnings()
|
||||
.some((warning) => warning.path === 'subtitleStyle.nameMatchEnabled'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses anilist.enabled and warns for invalid value', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -298,6 +470,78 @@ test('parses anilist.enabled and warns for invalid value', () => {
|
||||
assert.equal(service.getConfig().anilist.enabled, true);
|
||||
});
|
||||
|
||||
test('parses anilist.characterDictionary config with clamping and enum validation', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"anilist": {
|
||||
"characterDictionary": {
|
||||
"enabled": true,
|
||||
"refreshTtlHours": 0,
|
||||
"maxLoaded": 1000,
|
||||
"evictionPolicy": "remove",
|
||||
"profileScope": "everywhere"
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
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');
|
||||
assert.equal(config.anilist.characterDictionary.profileScope, 'all');
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === 'anilist.characterDictionary.refreshTtlHours'),
|
||||
);
|
||||
assert.ok(warnings.some((warning) => warning.path === 'anilist.characterDictionary.maxLoaded'));
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === 'anilist.characterDictionary.evictionPolicy'),
|
||||
);
|
||||
assert.ok(
|
||||
warnings.some((warning) => warning.path === 'anilist.characterDictionary.profileScope'),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses anilist.characterDictionary.collapsibleSections booleans and warns on invalid values', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'config.jsonc'),
|
||||
`{
|
||||
"anilist": {
|
||||
"characterDictionary": {
|
||||
"collapsibleSections": {
|
||||
"description": true,
|
||||
"characterInformation": "yes",
|
||||
"voicedBy": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
const service = new ConfigService(dir);
|
||||
const config = service.getConfig();
|
||||
const warnings = service.getWarnings();
|
||||
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.description, true);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.characterInformation, false);
|
||||
assert.equal(config.anilist.characterDictionary.collapsibleSections.voicedBy, true);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'anilist.characterDictionary.collapsibleSections.characterInformation',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('parses jellyfin remote control fields', () => {
|
||||
const dir = makeTempDir();
|
||||
fs.writeFileSync(
|
||||
@@ -721,6 +965,10 @@ test('warning emission order is deterministic across reloads', () => {
|
||||
"enabled": "sometimes",
|
||||
"port": -1
|
||||
},
|
||||
"annotationWebsocket": {
|
||||
"enabled": "sometimes",
|
||||
"port": -1
|
||||
},
|
||||
"logging": {
|
||||
"level": "trace"
|
||||
}
|
||||
@@ -737,7 +985,14 @@ test('warning emission order is deterministic across reloads', () => {
|
||||
assert.deepEqual(secondWarnings, firstWarnings);
|
||||
assert.deepEqual(
|
||||
firstWarnings.map((warning) => warning.path),
|
||||
['unknownFeature', 'websocket.enabled', 'websocket.port', 'logging.level'],
|
||||
[
|
||||
'unknownFeature',
|
||||
'websocket.enabled',
|
||||
'websocket.port',
|
||||
'annotationWebsocket.enabled',
|
||||
'annotationWebsocket.port',
|
||||
'logging.level',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1292,6 +1547,7 @@ test('template generator includes known keys', () => {
|
||||
assert.match(output, /"discordPresence":/);
|
||||
assert.match(output, /"startupWarmups":/);
|
||||
assert.match(output, /"youtubeSubgen":/);
|
||||
assert.match(output, /"characterDictionary":\s*\{/);
|
||||
assert.match(output, /"preserveLineBreaks": false/);
|
||||
assert.match(output, /"nPlusOne"\s*:\s*\{/);
|
||||
assert.match(output, /"nPlusOne": "#c6a0f6"/);
|
||||
@@ -1306,8 +1562,17 @@ test('template generator includes known keys', () => {
|
||||
output,
|
||||
/"enabled": "auto",? \/\/ Built-in subtitle websocket server mode\. Values: auto \| true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"enabled": true,? \/\/ Annotated subtitle websocket server enabled state\. Values: true \| false/,
|
||||
);
|
||||
assert.match(output, /"port": 6678,? \/\/ Annotated subtitle websocket server port\./);
|
||||
assert.match(
|
||||
output,
|
||||
/"enabled": false,? \/\/ Enable AnkiConnect integration\. Values: true \| false/,
|
||||
);
|
||||
assert.match(
|
||||
output,
|
||||
/"launchAtStartup": true,? \/\/ Launch texthooker server automatically when SubMiner starts\. Values: true \| false/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ const {
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
annotationWebsocket,
|
||||
logging,
|
||||
texthooker,
|
||||
shortcuts,
|
||||
@@ -39,6 +40,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
|
||||
subtitlePosition,
|
||||
keybindings,
|
||||
websocket,
|
||||
annotationWebsocket,
|
||||
logging,
|
||||
texthooker,
|
||||
ankiConnect,
|
||||
|
||||
@@ -5,6 +5,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
| 'subtitlePosition'
|
||||
| 'keybindings'
|
||||
| 'websocket'
|
||||
| 'annotationWebsocket'
|
||||
| 'logging'
|
||||
| 'texthooker'
|
||||
| 'shortcuts'
|
||||
@@ -19,10 +20,15 @@ export const CORE_DEFAULT_CONFIG: Pick<
|
||||
enabled: 'auto',
|
||||
port: 6677,
|
||||
},
|
||||
annotationWebsocket: {
|
||||
enabled: true,
|
||||
port: 6678,
|
||||
},
|
||||
logging: {
|
||||
level: 'info',
|
||||
},
|
||||
texthooker: {
|
||||
launchAtStartup: true,
|
||||
openBrowser: true,
|
||||
},
|
||||
shortcuts: {
|
||||
|
||||
@@ -86,6 +86,18 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
|
||||
anilist: {
|
||||
enabled: false,
|
||||
accessToken: '',
|
||||
characterDictionary: {
|
||||
enabled: false,
|
||||
refreshTtlHours: 168,
|
||||
maxLoaded: 3,
|
||||
evictionPolicy: 'delete',
|
||||
profileScope: 'all',
|
||||
collapsibleSections: {
|
||||
description: false,
|
||||
characterInformation: false,
|
||||
voicedBy: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
jellyfin: {
|
||||
enabled: false,
|
||||
|
||||
@@ -8,6 +8,8 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
||||
autoPauseVideoOnYomitanPopup: false,
|
||||
hoverTokenColor: '#f4dbd6',
|
||||
hoverTokenBackgroundColor: 'rgba(54, 58, 79, 0.84)',
|
||||
nameMatchEnabled: true,
|
||||
nameMatchColor: '#f5bde6',
|
||||
fontFamily: 'M PLUS 1 Medium, Source Han Sans JP, Noto Sans CJK JP',
|
||||
fontSize: 35,
|
||||
fontColor: '#cad3f5',
|
||||
@@ -37,7 +39,7 @@ export const SUBTITLE_DEFAULT_CONFIG: Pick<ResolvedConfig, 'subtitleStyle'> = {
|
||||
mode: 'single',
|
||||
matchMode: 'headword',
|
||||
singleColor: '#f5a97f',
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#a6e3a1', '#8aadf4'],
|
||||
bandedColors: ['#ed8796', '#f5a97f', '#f9e2af', '#8bd5ca', '#8aadf4'],
|
||||
},
|
||||
secondary: {
|
||||
fontFamily: 'Inter, Noto Sans, Helvetica Neue, sans-serif',
|
||||
|
||||
@@ -18,10 +18,13 @@ test('config option registry includes critical paths and has unique entries', ()
|
||||
|
||||
for (const requiredPath of [
|
||||
'logging.level',
|
||||
'annotationWebsocket.enabled',
|
||||
'startupWarmups.lowPowerMode',
|
||||
'subtitleStyle.enableJlpt',
|
||||
'subtitleStyle.autoPauseVideoOnYomitanPopup',
|
||||
'ankiConnect.enabled',
|
||||
'anilist.characterDictionary.enabled',
|
||||
'anilist.characterDictionary.collapsibleSections.description',
|
||||
'immersionTracking.enabled',
|
||||
]) {
|
||||
assert.ok(paths.includes(requiredPath), `missing config path: ${requiredPath}`);
|
||||
@@ -34,6 +37,7 @@ test('config template sections include expected domains and unique keys', () =>
|
||||
const keys = CONFIG_TEMPLATE_SECTIONS.map((section) => section.key);
|
||||
const requiredKeys: (typeof keys)[number][] = [
|
||||
'websocket',
|
||||
'annotationWebsocket',
|
||||
'startupWarmups',
|
||||
'subtitleStyle',
|
||||
'ankiConnect',
|
||||
|
||||
@@ -12,6 +12,12 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.logging.level,
|
||||
description: 'Minimum log level for runtime logging.',
|
||||
},
|
||||
{
|
||||
path: 'texthooker.launchAtStartup',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.texthooker.launchAtStartup,
|
||||
description: 'Launch texthooker server automatically when SubMiner starts.',
|
||||
},
|
||||
{
|
||||
path: 'websocket.enabled',
|
||||
kind: 'enum',
|
||||
@@ -25,6 +31,18 @@ export function buildCoreConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.websocket.port,
|
||||
description: 'Built-in subtitle websocket server port.',
|
||||
},
|
||||
{
|
||||
path: 'annotationWebsocket.enabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.annotationWebsocket.enabled,
|
||||
description: 'Annotated subtitle websocket server enabled state.',
|
||||
},
|
||||
{
|
||||
path: 'annotationWebsocket.port',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.annotationWebsocket.port,
|
||||
description: 'Annotated subtitle websocket server port.',
|
||||
},
|
||||
{
|
||||
path: 'subsync.defaultMode',
|
||||
kind: 'enum',
|
||||
|
||||
@@ -135,6 +135,64 @@ 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',
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.refreshTtlHours,
|
||||
description:
|
||||
'Legacy setting; merged character dictionary retention is now usage-based and this value is ignored.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.maxLoaded',
|
||||
kind: 'number',
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.maxLoaded,
|
||||
description:
|
||||
'Maximum number of most-recently-used anime snapshots included in the merged Yomitan character dictionary.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.evictionPolicy',
|
||||
kind: 'enum',
|
||||
enumValues: ['disable', 'delete'],
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.evictionPolicy,
|
||||
description:
|
||||
'Legacy setting; merged character dictionary eviction is usage-based and this value is ignored.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.profileScope',
|
||||
kind: 'enum',
|
||||
enumValues: ['all', 'active'],
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.profileScope,
|
||||
description: 'Yomitan profile scope for dictionary enable/disable updates.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.collapsibleSections.description',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.collapsibleSections.description,
|
||||
description:
|
||||
'Open the Description section by default in character dictionary glossary entries.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.collapsibleSections.characterInformation',
|
||||
kind: 'boolean',
|
||||
defaultValue:
|
||||
defaultConfig.anilist.characterDictionary.collapsibleSections.characterInformation,
|
||||
description:
|
||||
'Open the Character Information section by default in character dictionary glossary entries.',
|
||||
},
|
||||
{
|
||||
path: 'anilist.characterDictionary.collapsibleSections.voicedBy',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.anilist.characterDictionary.collapsibleSections.voicedBy,
|
||||
description:
|
||||
'Open the Voiced by section by default in character dictionary glossary entries.',
|
||||
},
|
||||
{
|
||||
path: 'jellyfin.enabled',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -47,6 +47,20 @@ export function buildSubtitleConfigOptionRegistry(
|
||||
defaultValue: defaultConfig.subtitleStyle.hoverTokenBackgroundColor,
|
||||
description: 'CSS color used for hovered subtitle token background highlight in mpv.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.nameMatchEnabled',
|
||||
kind: 'boolean',
|
||||
defaultValue: defaultConfig.subtitleStyle.nameMatchEnabled,
|
||||
description:
|
||||
'Enable subtitle token coloring for matches from the SubMiner character dictionary.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.nameMatchColor',
|
||||
kind: 'string',
|
||||
defaultValue: defaultConfig.subtitleStyle.nameMatchColor,
|
||||
description:
|
||||
'Hex color used when a subtitle token matches an entry from the SubMiner character dictionary.',
|
||||
},
|
||||
{
|
||||
path: 'subtitleStyle.frequencyDictionary.enabled',
|
||||
kind: 'boolean',
|
||||
|
||||
@@ -10,7 +10,7 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
},
|
||||
{
|
||||
title: 'Texthooker Server',
|
||||
description: ['Control whether browser opens automatically for texthooker.'],
|
||||
description: ['Configure texthooker startup launch and browser opening behavior.'],
|
||||
key: 'texthooker',
|
||||
},
|
||||
{
|
||||
@@ -21,6 +21,14 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
],
|
||||
key: 'websocket',
|
||||
},
|
||||
{
|
||||
title: 'Annotation WebSocket',
|
||||
description: [
|
||||
'Dedicated annotated subtitle websocket for bundled texthooker and token-aware clients.',
|
||||
'Independent from websocket.auto and defaults to port 6678.',
|
||||
],
|
||||
key: 'annotationWebsocket',
|
||||
},
|
||||
{
|
||||
title: 'Logging',
|
||||
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
|
||||
@@ -104,7 +112,11 @@ const INTEGRATION_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
|
||||
},
|
||||
{
|
||||
title: 'Anilist',
|
||||
description: ['Anilist API credentials and update behavior.'],
|
||||
description: [
|
||||
'Anilist API credentials and update behavior.',
|
||||
'Includes optional auto-sync for a merged MRU-based character dictionary in bundled Yomitan.',
|
||||
'Character dictionaries are keyed by AniList media ID (no season/franchise merge).',
|
||||
],
|
||||
key: 'anilist',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,18 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
const { src, resolved, warn } = context;
|
||||
|
||||
if (isObject(src.texthooker)) {
|
||||
const launchAtStartup = asBoolean(src.texthooker.launchAtStartup);
|
||||
if (launchAtStartup !== undefined) {
|
||||
resolved.texthooker.launchAtStartup = launchAtStartup;
|
||||
} else if (src.texthooker.launchAtStartup !== undefined) {
|
||||
warn(
|
||||
'texthooker.launchAtStartup',
|
||||
src.texthooker.launchAtStartup,
|
||||
resolved.texthooker.launchAtStartup,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const openBrowser = asBoolean(src.texthooker.openBrowser);
|
||||
if (openBrowser !== undefined) {
|
||||
resolved.texthooker.openBrowser = openBrowser;
|
||||
@@ -44,6 +56,32 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.annotationWebsocket)) {
|
||||
const enabled = asBoolean(src.annotationWebsocket.enabled);
|
||||
if (enabled !== undefined) {
|
||||
resolved.annotationWebsocket.enabled = enabled;
|
||||
} else if (src.annotationWebsocket.enabled !== undefined) {
|
||||
warn(
|
||||
'annotationWebsocket.enabled',
|
||||
src.annotationWebsocket.enabled,
|
||||
resolved.annotationWebsocket.enabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
const port = asNumber(src.annotationWebsocket.port);
|
||||
if (port !== undefined && port > 0 && port <= 65535) {
|
||||
resolved.annotationWebsocket.port = Math.floor(port);
|
||||
} else if (src.annotationWebsocket.port !== undefined) {
|
||||
warn(
|
||||
'annotationWebsocket.port',
|
||||
src.annotationWebsocket.port,
|
||||
resolved.annotationWebsocket.port,
|
||||
'Expected integer between 1 and 65535.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.logging)) {
|
||||
const logLevel = asString(src.logging.level);
|
||||
if (
|
||||
|
||||
@@ -23,6 +23,140 @@ export function applyIntegrationConfig(context: ResolveContext): void {
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
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)));
|
||||
if (normalized !== refreshTtlHours) {
|
||||
warn(
|
||||
'anilist.characterDictionary.refreshTtlHours',
|
||||
characterDictionary.refreshTtlHours,
|
||||
normalized,
|
||||
'Out of range; clamped to 1..8760 hours.',
|
||||
);
|
||||
}
|
||||
resolved.anilist.characterDictionary.refreshTtlHours = normalized;
|
||||
} else if (characterDictionary.refreshTtlHours !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary.refreshTtlHours',
|
||||
characterDictionary.refreshTtlHours,
|
||||
resolved.anilist.characterDictionary.refreshTtlHours,
|
||||
'Expected number.',
|
||||
);
|
||||
}
|
||||
|
||||
const maxLoaded = asNumber(characterDictionary.maxLoaded);
|
||||
if (maxLoaded !== undefined) {
|
||||
const normalized = Math.min(20, Math.max(1, Math.floor(maxLoaded)));
|
||||
if (normalized !== maxLoaded) {
|
||||
warn(
|
||||
'anilist.characterDictionary.maxLoaded',
|
||||
characterDictionary.maxLoaded,
|
||||
normalized,
|
||||
'Out of range; clamped to 1..20.',
|
||||
);
|
||||
}
|
||||
resolved.anilist.characterDictionary.maxLoaded = normalized;
|
||||
} else if (characterDictionary.maxLoaded !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary.maxLoaded',
|
||||
characterDictionary.maxLoaded,
|
||||
resolved.anilist.characterDictionary.maxLoaded,
|
||||
'Expected number.',
|
||||
);
|
||||
}
|
||||
|
||||
const evictionPolicyRaw = asString(characterDictionary.evictionPolicy);
|
||||
if (evictionPolicyRaw !== undefined) {
|
||||
const evictionPolicy = evictionPolicyRaw.trim().toLowerCase();
|
||||
if (evictionPolicy === 'disable' || evictionPolicy === 'delete') {
|
||||
resolved.anilist.characterDictionary.evictionPolicy = evictionPolicy;
|
||||
} else {
|
||||
warn(
|
||||
'anilist.characterDictionary.evictionPolicy',
|
||||
characterDictionary.evictionPolicy,
|
||||
resolved.anilist.characterDictionary.evictionPolicy,
|
||||
"Expected one of: 'disable', 'delete'.",
|
||||
);
|
||||
}
|
||||
} else if (characterDictionary.evictionPolicy !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary.evictionPolicy',
|
||||
characterDictionary.evictionPolicy,
|
||||
resolved.anilist.characterDictionary.evictionPolicy,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
const profileScopeRaw = asString(characterDictionary.profileScope);
|
||||
if (profileScopeRaw !== undefined) {
|
||||
const profileScope = profileScopeRaw.trim().toLowerCase();
|
||||
if (profileScope === 'all' || profileScope === 'active') {
|
||||
resolved.anilist.characterDictionary.profileScope = profileScope;
|
||||
} else {
|
||||
warn(
|
||||
'anilist.characterDictionary.profileScope',
|
||||
characterDictionary.profileScope,
|
||||
resolved.anilist.characterDictionary.profileScope,
|
||||
"Expected one of: 'all', 'active'.",
|
||||
);
|
||||
}
|
||||
} else if (characterDictionary.profileScope !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary.profileScope',
|
||||
characterDictionary.profileScope,
|
||||
resolved.anilist.characterDictionary.profileScope,
|
||||
'Expected string.',
|
||||
);
|
||||
}
|
||||
|
||||
if (isObject(characterDictionary.collapsibleSections)) {
|
||||
const collapsibleSections = characterDictionary.collapsibleSections;
|
||||
const keys = ['description', 'characterInformation', 'voicedBy'] as const;
|
||||
for (const key of keys) {
|
||||
const value = asBoolean(collapsibleSections[key]);
|
||||
if (value !== undefined) {
|
||||
resolved.anilist.characterDictionary.collapsibleSections[key] = value;
|
||||
} else if (collapsibleSections[key] !== undefined) {
|
||||
warn(
|
||||
`anilist.characterDictionary.collapsibleSections.${key}`,
|
||||
collapsibleSections[key],
|
||||
resolved.anilist.characterDictionary.collapsibleSections[key],
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (characterDictionary.collapsibleSections !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary.collapsibleSections',
|
||||
characterDictionary.collapsibleSections,
|
||||
resolved.anilist.characterDictionary.collapsibleSections,
|
||||
'Expected object.',
|
||||
);
|
||||
}
|
||||
} else if (src.anilist.characterDictionary !== undefined) {
|
||||
warn(
|
||||
'anilist.characterDictionary',
|
||||
src.anilist.characterDictionary,
|
||||
resolved.anilist.characterDictionary,
|
||||
'Expected object.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isObject(src.jellyfin)) {
|
||||
|
||||
@@ -62,3 +62,45 @@ test('discordPresence invalid values warn and keep defaults', () => {
|
||||
assert.ok(warnedPaths.includes('discordPresence.updateIntervalMs'));
|
||||
assert.ok(warnedPaths.includes('discordPresence.debounceMs'));
|
||||
});
|
||||
|
||||
test('anilist character dictionary fields are parsed, clamped, and enum-validated', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
anilist: {
|
||||
characterDictionary: {
|
||||
enabled: true,
|
||||
refreshTtlHours: 0,
|
||||
maxLoaded: 99,
|
||||
evictionPolicy: 'purge' as never,
|
||||
profileScope: 'global' as never,
|
||||
collapsibleSections: {
|
||||
description: true,
|
||||
characterInformation: 'invalid' as never,
|
||||
voicedBy: true,
|
||||
} as never,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
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');
|
||||
assert.equal(context.resolved.anilist.characterDictionary.profileScope, 'all');
|
||||
assert.equal(context.resolved.anilist.characterDictionary.collapsibleSections.description, true);
|
||||
assert.equal(
|
||||
context.resolved.anilist.characterDictionary.collapsibleSections.characterInformation,
|
||||
false,
|
||||
);
|
||||
assert.equal(context.resolved.anilist.characterDictionary.collapsibleSections.voicedBy, true);
|
||||
|
||||
const warnedPaths = warnings.map((warning) => warning.path);
|
||||
assert.ok(warnedPaths.includes('anilist.characterDictionary.refreshTtlHours'));
|
||||
assert.ok(warnedPaths.includes('anilist.characterDictionary.maxLoaded'));
|
||||
assert.ok(warnedPaths.includes('anilist.characterDictionary.evictionPolicy'));
|
||||
assert.ok(warnedPaths.includes('anilist.characterDictionary.profileScope'));
|
||||
assert.ok(
|
||||
warnedPaths.includes('anilist.characterDictionary.collapsibleSections.characterInformation'),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -105,6 +105,8 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
const fallbackSubtitleStyleHoverTokenColor = resolved.subtitleStyle.hoverTokenColor;
|
||||
const fallbackSubtitleStyleHoverTokenBackgroundColor =
|
||||
resolved.subtitleStyle.hoverTokenBackgroundColor;
|
||||
const fallbackSubtitleStyleNameMatchEnabled = resolved.subtitleStyle.nameMatchEnabled;
|
||||
const fallbackSubtitleStyleNameMatchColor = resolved.subtitleStyle.nameMatchColor;
|
||||
const fallbackFrequencyDictionary = {
|
||||
...resolved.subtitleStyle.frequencyDictionary,
|
||||
};
|
||||
@@ -228,6 +230,38 @@ export function applySubtitleDomainConfig(context: ResolveContext): void {
|
||||
);
|
||||
}
|
||||
|
||||
const nameMatchColor = asColor(
|
||||
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
|
||||
);
|
||||
const nameMatchEnabled = asBoolean(
|
||||
(src.subtitleStyle as { nameMatchEnabled?: unknown }).nameMatchEnabled,
|
||||
);
|
||||
if (nameMatchEnabled !== undefined) {
|
||||
resolved.subtitleStyle.nameMatchEnabled = nameMatchEnabled;
|
||||
} else if (
|
||||
(src.subtitleStyle as { nameMatchEnabled?: unknown }).nameMatchEnabled !== undefined
|
||||
) {
|
||||
resolved.subtitleStyle.nameMatchEnabled = fallbackSubtitleStyleNameMatchEnabled;
|
||||
warn(
|
||||
'subtitleStyle.nameMatchEnabled',
|
||||
(src.subtitleStyle as { nameMatchEnabled?: unknown }).nameMatchEnabled,
|
||||
resolved.subtitleStyle.nameMatchEnabled,
|
||||
'Expected boolean.',
|
||||
);
|
||||
}
|
||||
|
||||
if (nameMatchColor !== undefined) {
|
||||
resolved.subtitleStyle.nameMatchColor = nameMatchColor;
|
||||
} else if ((src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor !== undefined) {
|
||||
resolved.subtitleStyle.nameMatchColor = fallbackSubtitleStyleNameMatchColor;
|
||||
warn(
|
||||
'subtitleStyle.nameMatchColor',
|
||||
(src.subtitleStyle as { nameMatchColor?: unknown }).nameMatchColor,
|
||||
resolved.subtitleStyle.nameMatchColor,
|
||||
'Expected hex color.',
|
||||
);
|
||||
}
|
||||
|
||||
const frequencyDictionary = isObject(
|
||||
(src.subtitleStyle as { frequencyDictionary?: unknown }).frequencyDictionary,
|
||||
)
|
||||
|
||||
@@ -66,6 +66,70 @@ test('subtitleStyle autoPauseVideoOnYomitanPopup falls back on invalid value', (
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle nameMatchEnabled falls back on invalid value', () => {
|
||||
const { context, warnings } = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nameMatchEnabled: 'invalid' as unknown as boolean,
|
||||
},
|
||||
});
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.equal(context.resolved.subtitleStyle.nameMatchEnabled, true);
|
||||
assert.ok(
|
||||
warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.nameMatchEnabled' &&
|
||||
warning.message === 'Expected boolean.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle frequencyDictionary defaults to the teal fourth band color', () => {
|
||||
const { context } = createResolveContext({});
|
||||
|
||||
applySubtitleDomainConfig(context);
|
||||
|
||||
assert.deepEqual(context.resolved.subtitleStyle.frequencyDictionary.bandedColors, [
|
||||
'#ed8796',
|
||||
'#f5a97f',
|
||||
'#f9e2af',
|
||||
'#8bd5ca',
|
||||
'#8aadf4',
|
||||
]);
|
||||
});
|
||||
|
||||
test('subtitleStyle nameMatchColor accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nameMatchColor: '#f5bde6',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(valid.context);
|
||||
assert.equal(
|
||||
(valid.context.resolved.subtitleStyle as { nameMatchColor?: string }).nameMatchColor,
|
||||
'#f5bde6',
|
||||
);
|
||||
|
||||
const invalid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
nameMatchColor: 'pink',
|
||||
},
|
||||
});
|
||||
applySubtitleDomainConfig(invalid.context);
|
||||
assert.equal(
|
||||
(invalid.context.resolved.subtitleStyle as { nameMatchColor?: string }).nameMatchColor,
|
||||
'#f5bde6',
|
||||
);
|
||||
assert.ok(
|
||||
invalid.warnings.some(
|
||||
(warning) =>
|
||||
warning.path === 'subtitleStyle.nameMatchColor' &&
|
||||
warning.message === 'Expected hex color.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test('subtitleStyle frequencyDictionary.matchMode accepts valid values and warns on invalid', () => {
|
||||
const valid = createResolveContext({
|
||||
subtitleStyle: {
|
||||
|
||||
Reference in New Issue
Block a user