mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-20 12:11:28 -07:00
Enhance AniList character dictionary sync and subtitle features (#15)
This commit is contained in:
@@ -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