feat: make startup warmups configurable with low-power mode

This commit is contained in:
2026-02-27 21:06:12 -08:00
parent d5f938c4b6
commit 1d67b12028
16 changed files with 338 additions and 20 deletions

View File

@@ -0,0 +1,51 @@
---
id: TASK-74
title: 'Startup warmups: configurable warmup vs defer with low-power mode'
status: In Progress
assignee: []
created_date: '2026-02-27 21:05'
labels: []
dependencies: []
references:
- src/types.ts
- src/config/definitions/defaults-core.ts
- src/config/definitions/options-core.ts
- src/config/definitions/template-sections.ts
- src/config/resolve/core-domains.ts
- src/main/runtime/startup-warmups.ts
- src/main/runtime/startup-warmups-main-deps.ts
- src/main/runtime/composers/mpv-runtime-composer.ts
- src/main.ts
- src/config/config.test.ts
- src/main/runtime/startup-warmups.test.ts
priority: medium
---
## Description
<!-- SECTION:DESCRIPTION:BEGIN -->
Add startup warmup controls to allow per-integration warmup or deferred first-use loading.
Scope:
- New config section `startupWarmups` with toggles for `mecab`, `yomitanExtension`, `subtitleDictionaries`, and `jellyfinRemoteSession`.
- New `startupWarmups.lowPowerMode` policy: defer everything except Yomitan extension.
- Keep default behavior as full warmup.
- Ensure deferred integrations lazy-load on first real usage path.
- Add test coverage for config parsing/defaults and warmup scheduling behavior.
<!-- SECTION:DESCRIPTION:END -->
## Final Summary
<!-- SECTION:FINAL_SUMMARY:BEGIN -->
Implemented:
- Added `startupWarmups` to config types/defaults/options/template/resolve.
- Warmup scheduler now uses per-integration gating functions.
- Low-power mode now defers MeCab, subtitle dictionaries, and Jellyfin remote session warmups while still warming Yomitan extension.
- Tokenization path guarantees lazy first-use init for deferred dependencies (Yomitan extension, MeCab when missing, subtitle dictionaries).
- Added/updated tests across config and runtime warmup modules.
Validation:
- `bun run test:config:src`
- `bun run test:core:src`
- `tsc --noEmit`
<!-- SECTION:FINAL_SUMMARY:END -->

View File

@@ -23,6 +23,11 @@ test('loads defaults when config is missing', () => {
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, 'SubMiner');
assert.equal(config.startupWarmups.lowPowerMode, false);
assert.equal(config.startupWarmups.mecab, true);
assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, true);
assert.equal(config.startupWarmups.jellyfinRemoteSession, true);
assert.equal(config.discordPresence.enabled, false);
assert.equal(config.discordPresence.updateIntervalMs, 3_000);
assert.equal(config.subtitleStyle.backgroundColor, 'rgb(30, 32, 48, 0.88)');
@@ -294,6 +299,72 @@ test('parses jellyfin.enabled and remoteControlEnabled disabled combinations', (
);
});
test('parses startup warmup toggles and low-power mode', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"startupWarmups": {
"lowPowerMode": true,
"mecab": false,
"yomitanExtension": true,
"subtitleDictionaries": false,
"jellyfinRemoteSession": false
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.startupWarmups.lowPowerMode, true);
assert.equal(config.startupWarmups.mecab, false);
assert.equal(config.startupWarmups.yomitanExtension, true);
assert.equal(config.startupWarmups.subtitleDictionaries, false);
assert.equal(config.startupWarmups.jellyfinRemoteSession, false);
});
test('invalid startup warmup values warn and keep defaults', () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, 'config.jsonc'),
`{
"startupWarmups": {
"lowPowerMode": "yes",
"mecab": 1,
"yomitanExtension": null,
"subtitleDictionaries": "no",
"jellyfinRemoteSession": []
}
}`,
'utf-8',
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.startupWarmups.lowPowerMode, DEFAULT_CONFIG.startupWarmups.lowPowerMode);
assert.equal(config.startupWarmups.mecab, DEFAULT_CONFIG.startupWarmups.mecab);
assert.equal(
config.startupWarmups.yomitanExtension,
DEFAULT_CONFIG.startupWarmups.yomitanExtension,
);
assert.equal(
config.startupWarmups.subtitleDictionaries,
DEFAULT_CONFIG.startupWarmups.subtitleDictionaries,
);
assert.equal(
config.startupWarmups.jellyfinRemoteSession,
DEFAULT_CONFIG.startupWarmups.jellyfinRemoteSession,
);
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.lowPowerMode'));
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.mecab'));
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.yomitanExtension'));
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.subtitleDictionaries'));
assert.ok(warnings.some((warning) => warning.path === 'startupWarmups.jellyfinRemoteSession'));
});
test('parses discordPresence fields and warns for invalid types', () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -1135,6 +1206,7 @@ test('template generator includes known keys', () => {
assert.match(output, /"logging":/);
assert.match(output, /"websocket":/);
assert.match(output, /"discordPresence":/);
assert.match(output, /"startupWarmups":/);
assert.match(output, /"youtubeSubgen":/);
assert.match(output, /"preserveLineBreaks": false/);
assert.match(output, /"nPlusOne"\s*:\s*\{/);

View File

@@ -27,6 +27,7 @@ const {
shortcuts,
secondarySub,
subsync,
startupWarmups,
auto_start_overlay,
} = CORE_DEFAULT_CONFIG;
const { ankiConnect, jimaku, anilist, jellyfin, discordPresence, youtubeSubgen } =
@@ -44,6 +45,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
shortcuts,
secondarySub,
subsync,
startupWarmups,
subtitleStyle,
auto_start_overlay,
jimaku,

View File

@@ -10,6 +10,7 @@ export const CORE_DEFAULT_CONFIG: Pick<
| 'shortcuts'
| 'secondarySub'
| 'subsync'
| 'startupWarmups'
| 'auto_start_overlay'
> = {
subtitlePosition: { yPercent: 10 },
@@ -50,5 +51,12 @@ export const CORE_DEFAULT_CONFIG: Pick<
ffsubsync_path: '',
ffmpeg_path: '',
},
startupWarmups: {
lowPowerMode: false,
mecab: true,
yomitanExtension: true,
subtitleDictionaries: true,
jellyfinRemoteSession: true,
},
auto_start_overlay: false,
};

View File

@@ -17,6 +17,7 @@ test('config option registry includes critical paths and has unique entries', ()
for (const requiredPath of [
'logging.level',
'startupWarmups.lowPowerMode',
'subtitleStyle.enableJlpt',
'ankiConnect.enabled',
'immersionTracking.enabled',
@@ -31,6 +32,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',
'startupWarmups',
'subtitleStyle',
'ankiConnect',
'immersionTracking',

View File

@@ -32,6 +32,36 @@ export function buildCoreConfigOptionRegistry(
defaultValue: defaultConfig.subsync.defaultMode,
description: 'Subsync default mode.',
},
{
path: 'startupWarmups.lowPowerMode',
kind: 'boolean',
defaultValue: defaultConfig.startupWarmups.lowPowerMode,
description: 'Defer startup warmups except Yomitan extension.',
},
{
path: 'startupWarmups.mecab',
kind: 'boolean',
defaultValue: defaultConfig.startupWarmups.mecab,
description: 'Warm up MeCab tokenizer at startup.',
},
{
path: 'startupWarmups.yomitanExtension',
kind: 'boolean',
defaultValue: defaultConfig.startupWarmups.yomitanExtension,
description: 'Warm up Yomitan extension at startup.',
},
{
path: 'startupWarmups.subtitleDictionaries',
kind: 'boolean',
defaultValue: defaultConfig.startupWarmups.subtitleDictionaries,
description: 'Warm up subtitle dictionaries at startup.',
},
{
path: 'startupWarmups.jellyfinRemoteSession',
kind: 'boolean',
defaultValue: defaultConfig.startupWarmups.jellyfinRemoteSession,
description: 'Warm up Jellyfin remote session at startup.',
},
{
path: 'shortcuts.multiCopyTimeoutMs',
kind: 'number',

View File

@@ -26,6 +26,15 @@ const CORE_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: ['Controls logging verbosity.', 'Set to debug for full runtime diagnostics.'],
key: 'logging',
},
{
title: 'Startup Warmups',
description: [
'Background warmup controls for MeCab, Yomitan, dictionaries, and Jellyfin session.',
'Disable individual warmups to defer load until first real usage.',
'lowPowerMode defers all warmups except Yomitan extension.',
],
key: 'startupWarmups',
},
{
title: 'Keyboard Shortcuts',
description: ['Overlay keyboard shortcuts. Set a shortcut to null to disable.'],

View File

@@ -74,6 +74,30 @@ export function applyCoreDomainConfig(context: ResolveContext): void {
);
}
if (isObject(src.startupWarmups)) {
const startupWarmupBooleanKeys = [
'lowPowerMode',
'mecab',
'yomitanExtension',
'subtitleDictionaries',
'jellyfinRemoteSession',
] as const;
for (const key of startupWarmupBooleanKeys) {
const value = asBoolean(src.startupWarmups[key]);
if (value !== undefined) {
resolved.startupWarmups[key] = value as (typeof resolved.startupWarmups)[typeof key];
} else if (src.startupWarmups[key] !== undefined) {
warn(
`startupWarmups.${key}`,
src.startupWarmups[key],
resolved.startupWarmups[key],
'Expected boolean.',
);
}
}
}
if (isObject(src.shortcuts)) {
const shortcutKeys = [
'toggleVisibleOverlayGlobal',

View File

@@ -1936,10 +1936,6 @@ const { reloadConfig: reloadConfigHandler, appReadyRuntimeRunner } = composeAppR
logInfo: (message) => appLogger.logInfo(message),
logWarning: (message) => appLogger.logWarning(message),
showDesktopNotification: (title, options) => showDesktopNotification(title, options),
showConfigWarningsDialog:
process.platform === 'darwin'
? (title, details) => dialog.showErrorBox(title, details)
: undefined,
startConfigHotReload: () => configHotReloadRuntime.start(),
refreshAnilistClientSecretState: (options) => refreshAnilistClientSecretState(options),
failHandlers: {
@@ -2250,6 +2246,7 @@ const {
getKnownWordMatchMode: () =>
appState.ankiIntegration?.getKnownWordMatchMode() ??
getResolvedConfig().ankiConnect.nPlusOne.matchMode,
getNPlusOneEnabled: () => getResolvedConfig().ankiConnect.nPlusOne.highlightEnabled,
getMinSentenceWordsForNPlusOne: () =>
getResolvedConfig().ankiConnect.nPlusOne.minSentenceWords,
getJlptLevel: (text) => appState.jlptLevelLookup(text),
@@ -2290,6 +2287,28 @@ const {
},
isTexthookerOnlyMode: () => appState.texthookerOnlyMode,
ensureYomitanExtensionLoaded: () => ensureYomitanExtensionLoaded().then(() => {}),
shouldWarmupMecab: () => {
const startupWarmups = getResolvedConfig().startupWarmups;
if (startupWarmups.lowPowerMode) {
return false;
}
return startupWarmups.mecab;
},
shouldWarmupYomitanExtension: () => getResolvedConfig().startupWarmups.yomitanExtension,
shouldWarmupSubtitleDictionaries: () => {
const startupWarmups = getResolvedConfig().startupWarmups;
if (startupWarmups.lowPowerMode) {
return false;
}
return startupWarmups.subtitleDictionaries;
},
shouldWarmupJellyfinRemoteSession: () => {
const startupWarmups = getResolvedConfig().startupWarmups;
if (startupWarmups.lowPowerMode) {
return false;
}
return startupWarmups.jellyfinRemoteSession;
},
shouldAutoConnectJellyfinRemote: () => {
const jellyfin = getResolvedConfig().jellyfin;
return (

View File

@@ -26,6 +26,7 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
const calls: string[] = [];
let started = false;
let metrics = BASE_METRICS;
let mecabTokenizer: { id: string } | null = null;
class FakeMpvClient {
connected = false;
@@ -140,9 +141,15 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
return { text };
},
createMecabTokenizerAndCheckMainDeps: {
getMecabTokenizer: () => ({ id: 'mecab' }),
setMecabTokenizer: () => {},
createMecabTokenizer: () => ({ id: 'mecab' }),
getMecabTokenizer: () => mecabTokenizer,
setMecabTokenizer: (next) => {
mecabTokenizer = next as { id: string };
calls.push('set-mecab');
},
createMecabTokenizer: () => {
calls.push('create-mecab');
return { id: 'mecab' };
},
checkAvailability: async () => {
calls.push('check-mecab');
},
@@ -176,6 +183,10 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
ensureYomitanExtensionLoaded: async () => {
calls.push('warmup-yomitan');
},
shouldWarmupMecab: () => true,
shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => true,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {
calls.push('warmup-jellyfin');
@@ -212,9 +223,12 @@ test('composeMpvRuntimeHandlers returns callable handlers and forwards to inject
assert.ok(calls.includes('broadcast-metrics'));
assert.ok(calls.includes('create-tokenizer-runtime-deps'));
assert.ok(calls.includes('tokenize:subtitle text'));
assert.ok(calls.includes('create-mecab'));
assert.ok(calls.includes('set-mecab'));
assert.ok(calls.includes('check-mecab'));
assert.ok(calls.includes('prewarm-jlpt'));
assert.ok(calls.includes('prewarm-frequency'));
assert.ok(calls.includes('set-started:true'));
assert.ok(calls.includes('warmup-yomitan'));
assert.ok(calls.indexOf('create-mecab') < calls.indexOf('set-started:true'));
});

View File

@@ -133,6 +133,10 @@ export function composeMpvRuntimeHandlers<
options.tokenizer.prewarmSubtitleDictionariesMainDeps,
);
const tokenizeSubtitle = async (text: string): Promise<TTokenizedSubtitle> => {
await options.warmups.startBackgroundWarmupsMainDeps.ensureYomitanExtensionLoaded();
if (!options.tokenizer.createMecabTokenizerAndCheckMainDeps.getMecabTokenizer()) {
await createMecabTokenizerAndCheck().catch(() => {});
}
await prewarmSubtitleDictionaries();
return options.tokenizer.tokenizeSubtitle(
text,

View File

@@ -34,6 +34,10 @@ test('startup warmups main deps builders map callbacks', async () => {
prewarmSubtitleDictionaries: async () => {
calls.push('dict');
},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => true,
shouldAutoConnectJellyfinRemote: () => true,
startJellyfinRemoteSession: async () => {
calls.push('jellyfin');
@@ -48,6 +52,10 @@ test('startup warmups main deps builders map callbacks', async () => {
await start.createMecabTokenizerAndCheck();
await start.ensureYomitanExtensionLoaded();
await start.prewarmSubtitleDictionaries();
assert.equal(start.shouldWarmupMecab(), false);
assert.equal(start.shouldWarmupYomitanExtension(), true);
assert.equal(start.shouldWarmupSubtitleDictionaries(), false);
assert.equal(start.shouldWarmupJellyfinRemoteSession(), true);
assert.equal(start.shouldAutoConnectJellyfinRemote(), true);
await start.startJellyfinRemoteSession();

View File

@@ -25,6 +25,10 @@ export function createBuildStartBackgroundWarmupsMainDepsHandler(deps: StartBack
createMecabTokenizerAndCheck: () => deps.createMecabTokenizerAndCheck(),
ensureYomitanExtensionLoaded: () => deps.ensureYomitanExtensionLoaded(),
prewarmSubtitleDictionaries: () => deps.prewarmSubtitleDictionaries(),
shouldWarmupMecab: () => deps.shouldWarmupMecab(),
shouldWarmupYomitanExtension: () => deps.shouldWarmupYomitanExtension(),
shouldWarmupSubtitleDictionaries: () => deps.shouldWarmupSubtitleDictionaries(),
shouldWarmupJellyfinRemoteSession: () => deps.shouldWarmupJellyfinRemoteSession(),
shouldAutoConnectJellyfinRemote: () => deps.shouldAutoConnectJellyfinRemote(),
startJellyfinRemoteSession: () => deps.startJellyfinRemoteSession(),
});

View File

@@ -41,6 +41,10 @@ test('startBackgroundWarmups no-ops when already started', () => {
createMecabTokenizerAndCheck: async () => {},
ensureYomitanExtensionLoaded: async () => {},
prewarmSubtitleDictionaries: async () => {},
shouldWarmupMecab: () => true,
shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => true,
shouldAutoConnectJellyfinRemote: () => false,
startJellyfinRemoteSession: async () => {},
});
@@ -49,7 +53,7 @@ test('startBackgroundWarmups no-ops when already started', () => {
assert.equal(launches, 0);
});
test('startBackgroundWarmups does not schedule jellyfin warmup when jellyfin.enabled is false', () => {
test('startBackgroundWarmups respects per-integration warmup toggles', () => {
const labels: string[] = [];
let started = false;
const startWarmups = createStartBackgroundWarmupsHandler({
@@ -64,9 +68,13 @@ test('startBackgroundWarmups does not schedule jellyfin warmup when jellyfin.ena
createMecabTokenizerAndCheck: async () => {},
ensureYomitanExtensionLoaded: async () => {},
prewarmSubtitleDictionaries: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () =>
shouldAutoConnectJellyfinRemote({
enabled: false,
enabled: true,
remoteControlEnabled: true,
remoteControlAutoConnect: true,
}),
@@ -75,7 +83,7 @@ test('startBackgroundWarmups does not schedule jellyfin warmup when jellyfin.ena
startWarmups();
assert.equal(started, true);
assert.deepEqual(labels, ['mecab', 'yomitan-extension', 'subtitle-dictionaries']);
assert.deepEqual(labels, ['yomitan-extension']);
});
test('startBackgroundWarmups schedules jellyfin warmup when all jellyfin flags are enabled', () => {
@@ -93,6 +101,10 @@ test('startBackgroundWarmups schedules jellyfin warmup when all jellyfin flags a
createMecabTokenizerAndCheck: async () => {},
ensureYomitanExtensionLoaded: async () => {},
prewarmSubtitleDictionaries: async () => {},
shouldWarmupMecab: () => true,
shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => true,
shouldWarmupJellyfinRemoteSession: () => true,
shouldAutoConnectJellyfinRemote: () =>
shouldAutoConnectJellyfinRemote({
enabled: true,
@@ -111,3 +123,36 @@ test('startBackgroundWarmups schedules jellyfin warmup when all jellyfin flags a
'jellyfin-remote-session',
]);
});
test('startBackgroundWarmups skips jellyfin warmup when warmup is deferred', () => {
const labels: string[] = [];
let started = false;
const startWarmups = createStartBackgroundWarmupsHandler({
getStarted: () => started,
setStarted: (value) => {
started = value;
},
isTexthookerOnlyMode: () => false,
launchTask: (label) => {
labels.push(label);
},
createMecabTokenizerAndCheck: async () => {},
ensureYomitanExtensionLoaded: async () => {},
prewarmSubtitleDictionaries: async () => {},
shouldWarmupMecab: () => false,
shouldWarmupYomitanExtension: () => true,
shouldWarmupSubtitleDictionaries: () => false,
shouldWarmupJellyfinRemoteSession: () => false,
shouldAutoConnectJellyfinRemote: () =>
shouldAutoConnectJellyfinRemote({
enabled: true,
remoteControlEnabled: true,
remoteControlAutoConnect: true,
}),
startJellyfinRemoteSession: async () => {},
});
startWarmups();
assert.equal(started, true);
assert.deepEqual(labels, ['yomitan-extension']);
});

View File

@@ -24,6 +24,10 @@ export function createStartBackgroundWarmupsHandler(deps: {
createMecabTokenizerAndCheck: () => Promise<void>;
ensureYomitanExtensionLoaded: () => Promise<void>;
prewarmSubtitleDictionaries: () => Promise<void>;
shouldWarmupMecab: () => boolean;
shouldWarmupYomitanExtension: () => boolean;
shouldWarmupSubtitleDictionaries: () => boolean;
shouldWarmupJellyfinRemoteSession: () => boolean;
shouldAutoConnectJellyfinRemote: () => boolean;
startJellyfinRemoteSession: () => Promise<void>;
}) {
@@ -32,16 +36,22 @@ export function createStartBackgroundWarmupsHandler(deps: {
if (deps.isTexthookerOnlyMode()) return;
deps.setStarted(true);
deps.launchTask('mecab', async () => {
await deps.createMecabTokenizerAndCheck();
});
deps.launchTask('yomitan-extension', async () => {
await deps.ensureYomitanExtensionLoaded();
});
deps.launchTask('subtitle-dictionaries', async () => {
await deps.prewarmSubtitleDictionaries();
});
if (deps.shouldAutoConnectJellyfinRemote()) {
if (deps.shouldWarmupMecab()) {
deps.launchTask('mecab', async () => {
await deps.createMecabTokenizerAndCheck();
});
}
if (deps.shouldWarmupYomitanExtension()) {
deps.launchTask('yomitan-extension', async () => {
await deps.ensureYomitanExtensionLoaded();
});
}
if (deps.shouldWarmupSubtitleDictionaries()) {
deps.launchTask('subtitle-dictionaries', async () => {
await deps.prewarmSubtitleDictionaries();
});
}
if (deps.shouldWarmupJellyfinRemoteSession() && deps.shouldAutoConnectJellyfinRemote()) {
deps.launchTask('jellyfin-remote-session', async () => {
await deps.startJellyfinRemoteSession();
});

View File

@@ -99,6 +99,14 @@ export interface SubsyncConfig {
ffmpeg_path?: string;
}
export interface StartupWarmupsConfig {
lowPowerMode?: boolean;
mecab?: boolean;
yomitanExtension?: boolean;
subtitleDictionaries?: boolean;
jellyfinRemoteSession?: boolean;
}
export interface WebSocketConfig {
enabled?: boolean | 'auto';
port?: number;
@@ -417,6 +425,7 @@ export interface Config {
shortcuts?: ShortcutsConfig;
secondarySub?: SecondarySubConfig;
subsync?: SubsyncConfig;
startupWarmups?: StartupWarmupsConfig;
subtitleStyle?: SubtitleStyleConfig;
auto_start_overlay?: boolean;
jimaku?: JimakuConfig;
@@ -513,6 +522,13 @@ export interface ResolvedConfig {
shortcuts: Required<ShortcutsConfig>;
secondarySub: Required<SecondarySubConfig>;
subsync: Required<SubsyncConfig>;
startupWarmups: {
lowPowerMode: boolean;
mecab: boolean;
yomitanExtension: boolean;
subtitleDictionaries: boolean;
jellyfinRemoteSession: boolean;
};
subtitleStyle: Required<Omit<SubtitleStyleConfig, 'secondary' | 'frequencyDictionary'>> & {
secondary: Required<NonNullable<SubtitleStyleConfig['secondary']>>;
frequencyDictionary: {