Enhance AniList character dictionary sync and subtitle features (#15)

This commit is contained in:
2026-03-07 18:30:59 -08:00
committed by GitHub
parent 2f07c3407a
commit e18985fb14
696 changed files with 14297 additions and 173564 deletions

View File

@@ -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 (

View File

@@ -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)) {

View File

@@ -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'),
);
});

View File

@@ -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,
)

View File

@@ -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: {