feat: add auto update support

This commit is contained in:
2026-05-15 01:47:56 -07:00
parent d1ec678d7a
commit 094bcce0dc
101 changed files with 4978 additions and 163 deletions
+61
View File
@@ -109,6 +109,58 @@ test('loads defaults when config is missing', () => {
assert.equal(config.immersionTracking.lifetimeSummaries?.anime, true);
assert.equal(config.immersionTracking.lifetimeSummaries?.media, true);
assert.equal(config.stats.autoOpenBrowser, false);
assert.equal(config.updates.enabled, true);
assert.equal(config.updates.checkIntervalHours, 24);
assert.equal(config.updates.notificationType, 'system');
assert.equal(config.updates.channel, 'stable');
});
test('parses updates config and warns on invalid values', () => {
const validDir = makeTempDir();
fs.writeFileSync(
path.join(validDir, 'config.jsonc'),
`{
"updates": {
"enabled": false,
"checkIntervalHours": 6,
"notificationType": "both",
"channel": "prerelease"
}
}`,
'utf-8',
);
const validService = new ConfigService(validDir);
assert.equal(validService.getConfig().updates.enabled, false);
assert.equal(validService.getConfig().updates.checkIntervalHours, 6);
assert.equal(validService.getConfig().updates.notificationType, 'both');
assert.equal(validService.getConfig().updates.channel, 'prerelease');
const invalidDir = makeTempDir();
fs.writeFileSync(
path.join(invalidDir, 'config.jsonc'),
`{
"updates": {
"enabled": "yes",
"checkIntervalHours": 0,
"notificationType": "toast",
"channel": "nightly"
}
}`,
'utf-8',
);
const invalidService = new ConfigService(invalidDir);
const config = invalidService.getConfig();
const warnings = invalidService.getWarnings();
assert.equal(config.updates.enabled, DEFAULT_CONFIG.updates.enabled);
assert.equal(config.updates.checkIntervalHours, DEFAULT_CONFIG.updates.checkIntervalHours);
assert.equal(config.updates.notificationType, DEFAULT_CONFIG.updates.notificationType);
assert.equal(config.updates.channel, DEFAULT_CONFIG.updates.channel);
assert.ok(warnings.some((warning) => warning.path === 'updates.enabled'));
assert.ok(warnings.some((warning) => warning.path === 'updates.checkIntervalHours'));
assert.ok(warnings.some((warning) => warning.path === 'updates.notificationType'));
assert.ok(warnings.some((warning) => warning.path === 'updates.channel'));
});
test('throws actionable startup parse error for malformed config at construction time', () => {
@@ -2124,6 +2176,7 @@ test('template generator includes known keys', () => {
assert.match(output, /"websocket":/);
assert.match(output, /"discordPresence":/);
assert.match(output, /"startupWarmups":/);
assert.match(output, /"updates":/);
assert.match(output, /"youtube":/);
assert.doesNotMatch(output, /"youtubeSubgen":/);
assert.match(output, /"characterDictionary":\s*\{/);
@@ -2210,6 +2263,14 @@ test('template generator includes known keys', () => {
output,
/"autoOpenBrowser": false,? \/\/ Automatically open the stats dashboard in a browser when the server starts\. Values: true \| false/,
);
assert.match(
output,
/"notificationType": "system",? \/\/ How SubMiner announces available updates\. Values: system \| osd \| both \| none/,
);
assert.match(
output,
/"channel": "stable",? \/\/ Release channel used for update checks\. Values: stable \| prerelease/,
);
assert.match(
output,
/"primarySubLanguages": \[\s*"ja",\s*"jpn"\s*\],? \/\/ Comma-separated primary subtitle language priority for managed subtitle auto-selection\./,
+2
View File
@@ -33,6 +33,7 @@ const {
youtube,
subsync,
startupWarmups,
updates,
auto_start_overlay,
} = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, mpv, yomitan, jellyfin, discordPresence, ai, youtubeSubgen } =
@@ -55,6 +56,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
youtube,
subsync,
startupWarmups,
updates,
subtitleStyle,
subtitleSidebar,
auto_start_overlay,
+7
View File
@@ -14,6 +14,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'youtube'
| 'subsync'
| 'startupWarmups'
| 'updates'
| 'auto_start_overlay'
> = {
subtitlePosition: { yPercent: 10 },
@@ -116,5 +117,11 @@ export const CORE_DEFAULT_CONFIG: Pick<
subtitleDictionaries: true,
jellyfinRemoteSession: true,
},
updates: {
enabled: true,
checkIntervalHours: 24,
notificationType: 'system',
channel: 'stable',
},
auto_start_overlay: false,
};
@@ -22,6 +22,7 @@ test('config option registry includes critical paths and has unique entries', ()
'controller.enabled',
'controller.scrollPixelsPerSecond',
'startupWarmups.lowPowerMode',
'updates.channel',
'youtube.primarySubLanguages',
'subtitleStyle.enableJlpt',
'subtitleStyle.autoPauseVideoOnYomitanPopup',
+26
View File
@@ -383,6 +383,32 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.startupWarmups.jellyfinRemoteSession,
description: 'Warm up Jellyfin remote session at startup.',
},
{
path: 'updates.enabled',
kind: 'boolean',
defaultValue: defaultConfig.updates.enabled,
description: 'Run automatic update checks in the background.',
},
{
path: 'updates.checkIntervalHours',
kind: 'number',
defaultValue: defaultConfig.updates.checkIntervalHours,
description: 'Minimum hours between automatic update checks.',
},
{
path: 'updates.notificationType',
kind: 'enum',
enumValues: ['system', 'osd', 'both', 'none'],
defaultValue: defaultConfig.updates.notificationType,
description: 'How SubMiner announces available updates.',
},
{
path: 'updates.channel',
kind: 'enum',
enumValues: ['stable', 'prerelease'],
defaultValue: defaultConfig.updates.channel,
description: 'Release channel used for update checks.',
},
{
path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number',
@@ -53,6 +53,14 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
],
key: 'startupWarmups',
},
{
title: 'Updates',
description: [
'Automatic update check behavior.',
'Manual checks from the tray or launcher are always allowed.',
],
key: 'updates',
},
{
title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],
+56
View File
@@ -478,6 +478,62 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
}
}
if (isObject(src.updates)) {
const enabled = asBoolean(src.updates.enabled);
if (enabled !== undefined) {
resolved.updates.enabled = enabled;
} else if (src.updates.enabled !== undefined) {
warn('updates.enabled', src.updates.enabled, resolved.updates.enabled, 'Expected boolean.');
}
const checkIntervalHours = asNumber(src.updates.checkIntervalHours);
if (
checkIntervalHours !== undefined &&
Number.isFinite(checkIntervalHours) &&
checkIntervalHours > 0
) {
resolved.updates.checkIntervalHours = checkIntervalHours;
} else if (src.updates.checkIntervalHours !== undefined) {
warn(
'updates.checkIntervalHours',
src.updates.checkIntervalHours,
resolved.updates.checkIntervalHours,
'Expected positive number.',
);
}
const notificationType = asString(src.updates.notificationType);
if (
notificationType === 'system' ||
notificationType === 'osd' ||
notificationType === 'both' ||
notificationType === 'none'
) {
resolved.updates.notificationType = notificationType;
} else if (src.updates.notificationType !== undefined) {
warn(
'updates.notificationType',
src.updates.notificationType,
resolved.updates.notificationType,
'Expected system, osd, both, or none.',
);
}
const channel = asString(src.updates.channel);
if (channel === 'stable' || channel === 'prerelease') {
resolved.updates.channel = channel;
} else if (src.updates.channel !== undefined) {
warn(
'updates.channel',
src.updates.channel,
resolved.updates.channel,
'Expected stable or prerelease.',
);
}
} else if (src.updates !== undefined) {
warn('updates', src.updates, resolved.updates, 'Expected object.');
}
if (isObject(src.shortcuts)) {
const shortcutKeys = [
'toggleVisibleOverlayGlobal',