feat(jellyfin): add remote playback and config plumbing

This commit is contained in:
2026-02-17 19:00:18 -08:00
parent a6a28f52f3
commit e38a1c945e
42 changed files with 5608 additions and 1013 deletions

View File

@@ -18,8 +18,22 @@ test("loads defaults when config is missing", () => {
assert.equal(config.websocket.port, DEFAULT_CONFIG.websocket.port);
assert.equal(config.ankiConnect.behavior.autoUpdateNewCards, true);
assert.equal(config.anilist.enabled, false);
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, false);
assert.equal(config.jellyfin.remoteControlDeviceName, "SubMiner");
assert.equal(config.immersionTracking.enabled, true);
assert.equal(config.immersionTracking.dbPath, undefined);
assert.equal(config.immersionTracking.dbPath, "");
assert.equal(config.immersionTracking.batchSize, 25);
assert.equal(config.immersionTracking.flushIntervalMs, 500);
assert.equal(config.immersionTracking.queueCap, 1000);
assert.equal(config.immersionTracking.payloadCapBytes, 256);
assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000);
assert.equal(config.immersionTracking.retention.eventsDays, 7);
assert.equal(config.immersionTracking.retention.telemetryDays, 30);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7);
});
test("parses anilist.enabled and warns for invalid value", () => {
@@ -45,6 +59,90 @@ test("parses anilist.enabled and warns for invalid value", () => {
assert.equal(service.getConfig().anilist.enabled, true);
});
test("parses jellyfin remote control fields", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"jellyfin": {
"enabled": true,
"serverUrl": "http://127.0.0.1:8096",
"remoteControlEnabled": true,
"remoteControlAutoConnect": true,
"autoAnnounce": true,
"remoteControlDeviceName": "SubMiner"
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
assert.equal(config.jellyfin.enabled, true);
assert.equal(config.jellyfin.serverUrl, "http://127.0.0.1:8096");
assert.equal(config.jellyfin.remoteControlEnabled, true);
assert.equal(config.jellyfin.remoteControlAutoConnect, true);
assert.equal(config.jellyfin.autoAnnounce, true);
assert.equal(config.jellyfin.remoteControlDeviceName, "SubMiner");
});
test("parses jellyfin.enabled and remoteControlEnabled disabled combinations", () => {
const disabledDir = makeTempDir();
fs.writeFileSync(
path.join(disabledDir, "config.jsonc"),
`{
"jellyfin": {
"enabled": false,
"remoteControlEnabled": false
}
}`,
"utf-8",
);
const disabledService = new ConfigService(disabledDir);
const disabledConfig = disabledService.getConfig();
assert.equal(disabledConfig.jellyfin.enabled, false);
assert.equal(disabledConfig.jellyfin.remoteControlEnabled, false);
assert.equal(
disabledService
.getWarnings()
.some(
(warning) =>
warning.path === "jellyfin.enabled" ||
warning.path === "jellyfin.remoteControlEnabled",
),
false,
);
const mixedDir = makeTempDir();
fs.writeFileSync(
path.join(mixedDir, "config.jsonc"),
`{
"jellyfin": {
"enabled": true,
"remoteControlEnabled": false
}
}`,
"utf-8",
);
const mixedService = new ConfigService(mixedDir);
const mixedConfig = mixedService.getConfig();
assert.equal(mixedConfig.jellyfin.enabled, true);
assert.equal(mixedConfig.jellyfin.remoteControlEnabled, false);
assert.equal(
mixedService
.getWarnings()
.some(
(warning) =>
warning.path === "jellyfin.enabled" ||
warning.path === "jellyfin.remoteControlEnabled",
),
false,
);
});
test("accepts immersion tracking config values", () => {
const dir = makeTempDir();
fs.writeFileSync(
@@ -52,7 +150,19 @@ test("accepts immersion tracking config values", () => {
`{
"immersionTracking": {
"enabled": false,
"dbPath": "/tmp/immersions/custom.sqlite"
"dbPath": "/tmp/immersions/custom.sqlite",
"batchSize": 50,
"flushIntervalMs": 750,
"queueCap": 2000,
"payloadCapBytes": 512,
"maintenanceIntervalMs": 3600000,
"retention": {
"eventsDays": 14,
"telemetryDays": 45,
"dailyRollupsDays": 730,
"monthlyRollupsDays": 3650,
"vacuumIntervalDays": 14
}
}
}`,
"utf-8",
@@ -62,7 +172,109 @@ test("accepts immersion tracking config values", () => {
const config = service.getConfig();
assert.equal(config.immersionTracking.enabled, false);
assert.equal(config.immersionTracking.dbPath, "/tmp/immersions/custom.sqlite");
assert.equal(
config.immersionTracking.dbPath,
"/tmp/immersions/custom.sqlite",
);
assert.equal(config.immersionTracking.batchSize, 50);
assert.equal(config.immersionTracking.flushIntervalMs, 750);
assert.equal(config.immersionTracking.queueCap, 2000);
assert.equal(config.immersionTracking.payloadCapBytes, 512);
assert.equal(config.immersionTracking.maintenanceIntervalMs, 3_600_000);
assert.equal(config.immersionTracking.retention.eventsDays, 14);
assert.equal(config.immersionTracking.retention.telemetryDays, 45);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 730);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 3650);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 14);
});
test("falls back for invalid immersion tracking tuning values", () => {
const dir = makeTempDir();
fs.writeFileSync(
path.join(dir, "config.jsonc"),
`{
"immersionTracking": {
"batchSize": 0,
"flushIntervalMs": 1,
"queueCap": 5,
"payloadCapBytes": 16,
"maintenanceIntervalMs": 1000,
"retention": {
"eventsDays": 0,
"telemetryDays": 99999,
"dailyRollupsDays": 0,
"monthlyRollupsDays": 999999,
"vacuumIntervalDays": 0
}
}
}`,
"utf-8",
);
const service = new ConfigService(dir);
const config = service.getConfig();
const warnings = service.getWarnings();
assert.equal(config.immersionTracking.batchSize, 25);
assert.equal(config.immersionTracking.flushIntervalMs, 500);
assert.equal(config.immersionTracking.queueCap, 1000);
assert.equal(config.immersionTracking.payloadCapBytes, 256);
assert.equal(config.immersionTracking.maintenanceIntervalMs, 86_400_000);
assert.equal(config.immersionTracking.retention.eventsDays, 7);
assert.equal(config.immersionTracking.retention.telemetryDays, 30);
assert.equal(config.immersionTracking.retention.dailyRollupsDays, 365);
assert.equal(config.immersionTracking.retention.monthlyRollupsDays, 1825);
assert.equal(config.immersionTracking.retention.vacuumIntervalDays, 7);
assert.ok(
warnings.some((warning) => warning.path === "immersionTracking.batchSize"),
);
assert.ok(
warnings.some(
(warning) => warning.path === "immersionTracking.flushIntervalMs",
),
);
assert.ok(
warnings.some((warning) => warning.path === "immersionTracking.queueCap"),
);
assert.ok(
warnings.some(
(warning) => warning.path === "immersionTracking.payloadCapBytes",
),
);
assert.ok(
warnings.some(
(warning) => warning.path === "immersionTracking.maintenanceIntervalMs",
),
);
assert.ok(
warnings.some(
(warning) => warning.path === "immersionTracking.retention.eventsDays",
),
);
assert.ok(
warnings.some(
(warning) => warning.path === "immersionTracking.retention.telemetryDays",
),
);
assert.ok(
warnings.some(
(warning) =>
warning.path === "immersionTracking.retention.dailyRollupsDays",
),
);
assert.ok(
warnings.some(
(warning) =>
warning.path === "immersionTracking.retention.monthlyRollupsDays",
),
);
assert.ok(
warnings.some(
(warning) =>
warning.path === "immersionTracking.retention.vacuumIntervalDays",
),
);
});
test("parses jsonc and warns/falls back on invalid value", () => {
@@ -117,9 +329,7 @@ test("falls back for invalid logging.level and reports warning", () => {
const warnings = service.getWarnings();
assert.equal(config.logging.level, DEFAULT_CONFIG.logging.level);
assert.ok(
warnings.some((warning) => warning.path === "logging.level"),
);
assert.ok(warnings.some((warning) => warning.path === "logging.level"));
});
test("parses invisible overlay config and new global shortcuts", () => {
@@ -150,7 +360,11 @@ test("parses invisible overlay config and new global shortcuts", () => {
assert.equal(config.shortcuts.openJimaku, "Ctrl+Alt+J");
assert.equal(config.invisibleOverlay.startupVisibility, "hidden");
assert.equal(config.bind_visible_overlay_to_mpv_sub_visibility, false);
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, ["ja", "jpn", "jp"]);
assert.deepEqual(config.youtubeSubgen.primarySubLanguages, [
"ja",
"jpn",
"jp",
]);
});
test("runtime options registry is centralized", () => {
@@ -295,8 +509,8 @@ test("validates ankiConnect n+1 match mode values", () => {
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode,
);
assert.ok(
warnings.some((warning) =>
warning.path === "ankiConnect.nPlusOne.matchMode",
warnings.some(
(warning) => warning.path === "ankiConnect.nPlusOne.matchMode",
),
);
});
@@ -349,10 +563,14 @@ test("validates ankiConnect n+1 color values", () => {
DEFAULT_CONFIG.ankiConnect.nPlusOne.knownWord,
);
assert.ok(
warnings.some((warning) => warning.path === "ankiConnect.nPlusOne.nPlusOne"),
warnings.some(
(warning) => warning.path === "ankiConnect.nPlusOne.nPlusOne",
),
);
assert.ok(
warnings.some((warning) => warning.path === "ankiConnect.nPlusOne.knownWord"),
warnings.some(
(warning) => warning.path === "ankiConnect.nPlusOne.knownWord",
),
);
});

View File

@@ -201,13 +201,7 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
topX: 1000,
mode: "single",
singleColor: "#f5a97f",
bandedColors: [
"#ed8796",
"#f5a97f",
"#f9e2af",
"#a6e3a1",
"#8aadf4",
],
bandedColors: ["#ed8796", "#f5a97f", "#f9e2af", "#a6e3a1", "#8aadf4"],
},
secondary: {
fontSize: 24,
@@ -230,6 +224,26 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
enabled: false,
accessToken: "",
},
jellyfin: {
enabled: false,
serverUrl: "",
username: "",
accessToken: "",
userId: "",
deviceId: "subminer",
clientName: "SubMiner",
clientVersion: "0.1.0",
defaultLibraryId: "",
remoteControlEnabled: true,
remoteControlAutoConnect: true,
autoAnnounce: false,
remoteControlDeviceName: "SubMiner",
pullPictures: false,
iconCacheDir: "/tmp/subminer-jellyfin-icons",
directPlayPreferred: true,
directPlayContainers: ["mkv", "mp4", "webm", "mov", "flac", "mp3", "aac"],
transcodeVideoCodec: "h264",
},
youtubeSubgen: {
mode: "automatic",
whisperBin: "",
@@ -241,6 +255,19 @@ export const DEFAULT_CONFIG: ResolvedConfig = {
},
immersionTracking: {
enabled: true,
dbPath: "",
batchSize: 25,
flushIntervalMs: 500,
queueCap: 1000,
payloadCapBytes: 256,
maintenanceIntervalMs: 24 * 60 * 60 * 1000,
retention: {
eventsDays: 7,
telemetryDays: 30,
dailyRollupsDays: 365,
monthlyRollupsDays: 5 * 365,
vacuumIntervalDays: 7,
},
},
};
@@ -324,8 +351,9 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
path: "subtitleStyle.enableJlpt",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.subtitleStyle.enableJlpt,
description: "Enable JLPT vocabulary level underlines. "
+ "When disabled, JLPT tagging lookup and underlines are skipped.",
description:
"Enable JLPT vocabulary level underlines. " +
"When disabled, JLPT tagging lookup and underlines are skipped.",
},
{
path: "subtitleStyle.frequencyDictionary.enabled",
@@ -339,14 +367,15 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
kind: "string",
defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.sourcePath,
description:
"Optional absolute path to a frequency dictionary directory."
+ " If empty, built-in discovery search paths are used.",
"Optional absolute path to a frequency dictionary directory." +
" If empty, built-in discovery search paths are used.",
},
{
path: "subtitleStyle.frequencyDictionary.topX",
kind: "number",
defaultValue: DEFAULT_CONFIG.subtitleStyle.frequencyDictionary.topX,
description: "Only color tokens with frequency rank <= topX (default: 1000).",
description:
"Only color tokens with frequency rank <= topX (default: 1000).",
},
{
path: "subtitleStyle.frequencyDictionary.mode",
@@ -399,7 +428,8 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
path: "ankiConnect.nPlusOne.highlightEnabled",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.ankiConnect.nPlusOne.highlightEnabled,
description: "Enable fast local highlighting for words already known in Anki.",
description:
"Enable fast local highlighting for words already known in Anki.",
},
{
path: "ankiConnect.nPlusOne.refreshMinutes",
@@ -486,6 +516,89 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
defaultValue: DEFAULT_CONFIG.anilist.accessToken,
description: "AniList access token used for post-watch updates.",
},
{
path: "jellyfin.enabled",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.enabled,
description:
"Enable optional Jellyfin integration and CLI control commands.",
},
{
path: "jellyfin.serverUrl",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.serverUrl,
description:
"Base Jellyfin server URL (for example: http://localhost:8096).",
},
{
path: "jellyfin.username",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.username,
description: "Default Jellyfin username used during CLI login.",
},
{
path: "jellyfin.defaultLibraryId",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.defaultLibraryId,
description: "Optional default Jellyfin library ID for item listing.",
},
{
path: "jellyfin.remoteControlEnabled",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.remoteControlEnabled,
description: "Enable Jellyfin remote cast control mode.",
},
{
path: "jellyfin.remoteControlAutoConnect",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.remoteControlAutoConnect,
description: "Auto-connect to the configured remote control target.",
},
{
path: "jellyfin.autoAnnounce",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.autoAnnounce,
description:
"When enabled, automatically trigger remote announce/visibility check on websocket connect.",
},
{
path: "jellyfin.remoteControlDeviceName",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.remoteControlDeviceName,
description: "Device name reported for Jellyfin remote control sessions.",
},
{
path: "jellyfin.pullPictures",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.pullPictures,
description: "Enable Jellyfin poster/icon fetching for launcher menus.",
},
{
path: "jellyfin.iconCacheDir",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.iconCacheDir,
description: "Directory used by launcher for cached Jellyfin poster icons.",
},
{
path: "jellyfin.directPlayPreferred",
kind: "boolean",
defaultValue: DEFAULT_CONFIG.jellyfin.directPlayPreferred,
description:
"Try direct play before server-managed transcoding when possible.",
},
{
path: "jellyfin.directPlayContainers",
kind: "array",
defaultValue: DEFAULT_CONFIG.jellyfin.directPlayContainers,
description: "Container allowlist for direct play decisions.",
},
{
path: "jellyfin.transcodeVideoCodec",
kind: "string",
defaultValue: DEFAULT_CONFIG.jellyfin.transcodeVideoCodec,
description:
"Preferred transcode video codec when direct play is unavailable.",
},
{
path: "youtubeSubgen.mode",
kind: "enum",
@@ -497,7 +610,8 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
path: "youtubeSubgen.whisperBin",
kind: "string",
defaultValue: DEFAULT_CONFIG.youtubeSubgen.whisperBin,
description: "Path to whisper.cpp CLI used as fallback transcription engine.",
description:
"Path to whisper.cpp CLI used as fallback transcription engine.",
},
{
path: "youtubeSubgen.whisperModel",
@@ -525,6 +639,66 @@ export const CONFIG_OPTION_REGISTRY: ConfigOptionRegistryEntry[] = [
description:
"Optional SQLite database path for immersion tracking. Empty value uses the default app data path.",
},
{
path: "immersionTracking.batchSize",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.batchSize,
description: "Buffered telemetry/event writes per SQLite transaction.",
},
{
path: "immersionTracking.flushIntervalMs",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.flushIntervalMs,
description: "Max delay before queue flush in milliseconds.",
},
{
path: "immersionTracking.queueCap",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.queueCap,
description: "In-memory write queue cap before overflow policy applies.",
},
{
path: "immersionTracking.payloadCapBytes",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.payloadCapBytes,
description: "Max JSON payload size per event before truncation.",
},
{
path: "immersionTracking.maintenanceIntervalMs",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.maintenanceIntervalMs,
description: "Maintenance cadence (prune + rollup + vacuum checks).",
},
{
path: "immersionTracking.retention.eventsDays",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.retention.eventsDays,
description: "Raw event retention window in days.",
},
{
path: "immersionTracking.retention.telemetryDays",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.retention.telemetryDays,
description: "Telemetry retention window in days.",
},
{
path: "immersionTracking.retention.dailyRollupsDays",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.retention.dailyRollupsDays,
description: "Daily rollup retention window in days.",
},
{
path: "immersionTracking.retention.monthlyRollupsDays",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.retention.monthlyRollupsDays,
description: "Monthly rollup retention window in days.",
},
{
path: "immersionTracking.retention.vacuumIntervalDays",
kind: "number",
defaultValue: DEFAULT_CONFIG.immersionTracking.retention.vacuumIntervalDays,
description: "Minimum days between VACUUM runs.",
},
];
export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
@@ -637,11 +811,20 @@ export const CONFIG_TEMPLATE_SECTIONS: ConfigTemplateSection[] = [
description: ["Anilist API credentials and update behavior."],
key: "anilist",
},
{
title: "Jellyfin",
description: [
"Optional Jellyfin integration for auth, browsing, and playback launch.",
"Access token is stored in config and should be treated as a secret.",
],
key: "jellyfin",
},
{
title: "Immersion Tracking",
description: [
"Enable/disable immersion tracking.",
"Set dbPath to override the default sqlite database location.",
"Policy tuning is available for queue, flush, and retention values.",
],
key: "immersionTracking",
},

View File

@@ -213,7 +213,12 @@ export class ConfigService {
if (isObject(src.logging)) {
const logLevel = asString(src.logging.level);
if (logLevel === "debug" || logLevel === "info" || logLevel === "warn" || logLevel === "error") {
if (
logLevel === "debug" ||
logLevel === "info" ||
logLevel === "warn" ||
logLevel === "error"
) {
resolved.logging.level = logLevel;
} else if (src.logging.level !== undefined) {
warn(
@@ -469,11 +474,90 @@ export class ConfigService {
}
}
if (isObject(src.jellyfin)) {
const enabled = asBoolean(src.jellyfin.enabled);
if (enabled !== undefined) {
resolved.jellyfin.enabled = enabled;
} else if (src.jellyfin.enabled !== undefined) {
warn(
"jellyfin.enabled",
src.jellyfin.enabled,
resolved.jellyfin.enabled,
"Expected boolean.",
);
}
const stringKeys = [
"serverUrl",
"username",
"accessToken",
"userId",
"deviceId",
"clientName",
"clientVersion",
"defaultLibraryId",
"iconCacheDir",
"transcodeVideoCodec",
] as const;
for (const key of stringKeys) {
const value = asString(src.jellyfin[key]);
if (value !== undefined) {
resolved.jellyfin[key] =
value as (typeof resolved.jellyfin)[typeof key];
} else if (src.jellyfin[key] !== undefined) {
warn(
`jellyfin.${key}`,
src.jellyfin[key],
resolved.jellyfin[key],
"Expected string.",
);
}
}
const booleanKeys = [
"remoteControlEnabled",
"remoteControlAutoConnect",
"autoAnnounce",
"directPlayPreferred",
"pullPictures",
] as const;
for (const key of booleanKeys) {
const value = asBoolean(src.jellyfin[key]);
if (value !== undefined) {
resolved.jellyfin[key] = value as (typeof resolved.jellyfin)[typeof key];
} else if (src.jellyfin[key] !== undefined) {
warn(
`jellyfin.${key}`,
src.jellyfin[key],
resolved.jellyfin[key],
"Expected boolean.",
);
}
}
if (Array.isArray(src.jellyfin.directPlayContainers)) {
resolved.jellyfin.directPlayContainers =
src.jellyfin.directPlayContainers
.filter((item): item is string => typeof item === "string")
.map((item) => item.trim().toLowerCase())
.filter((item) => item.length > 0);
} else if (src.jellyfin.directPlayContainers !== undefined) {
warn(
"jellyfin.directPlayContainers",
src.jellyfin.directPlayContainers,
resolved.jellyfin.directPlayContainers,
"Expected string array.",
);
}
}
if (asBoolean(src.auto_start_overlay) !== undefined) {
resolved.auto_start_overlay = src.auto_start_overlay as boolean;
}
if (asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined) {
if (
asBoolean(src.bind_visible_overlay_to_mpv_sub_visibility) !== undefined
) {
resolved.bind_visible_overlay_to_mpv_sub_visibility =
src.bind_visible_overlay_to_mpv_sub_visibility as boolean;
} else if (src.bind_visible_overlay_to_mpv_sub_visibility !== undefined) {
@@ -509,6 +593,191 @@ export class ConfigService {
"Expected string.",
);
}
const batchSize = asNumber(src.immersionTracking.batchSize);
if (batchSize !== undefined && batchSize >= 1 && batchSize <= 10_000) {
resolved.immersionTracking.batchSize = Math.floor(batchSize);
} else if (src.immersionTracking.batchSize !== undefined) {
warn(
"immersionTracking.batchSize",
src.immersionTracking.batchSize,
resolved.immersionTracking.batchSize,
"Expected integer between 1 and 10000.",
);
}
const flushIntervalMs = asNumber(src.immersionTracking.flushIntervalMs);
if (
flushIntervalMs !== undefined &&
flushIntervalMs >= 50 &&
flushIntervalMs <= 60_000
) {
resolved.immersionTracking.flushIntervalMs = Math.floor(flushIntervalMs);
} else if (src.immersionTracking.flushIntervalMs !== undefined) {
warn(
"immersionTracking.flushIntervalMs",
src.immersionTracking.flushIntervalMs,
resolved.immersionTracking.flushIntervalMs,
"Expected integer between 50 and 60000.",
);
}
const queueCap = asNumber(src.immersionTracking.queueCap);
if (queueCap !== undefined && queueCap >= 100 && queueCap <= 100_000) {
resolved.immersionTracking.queueCap = Math.floor(queueCap);
} else if (src.immersionTracking.queueCap !== undefined) {
warn(
"immersionTracking.queueCap",
src.immersionTracking.queueCap,
resolved.immersionTracking.queueCap,
"Expected integer between 100 and 100000.",
);
}
const payloadCapBytes = asNumber(src.immersionTracking.payloadCapBytes);
if (
payloadCapBytes !== undefined &&
payloadCapBytes >= 64 &&
payloadCapBytes <= 8192
) {
resolved.immersionTracking.payloadCapBytes = Math.floor(payloadCapBytes);
} else if (src.immersionTracking.payloadCapBytes !== undefined) {
warn(
"immersionTracking.payloadCapBytes",
src.immersionTracking.payloadCapBytes,
resolved.immersionTracking.payloadCapBytes,
"Expected integer between 64 and 8192.",
);
}
const maintenanceIntervalMs = asNumber(
src.immersionTracking.maintenanceIntervalMs,
);
if (
maintenanceIntervalMs !== undefined &&
maintenanceIntervalMs >= 60_000 &&
maintenanceIntervalMs <= 7 * 24 * 60 * 60 * 1000
) {
resolved.immersionTracking.maintenanceIntervalMs = Math.floor(
maintenanceIntervalMs,
);
} else if (src.immersionTracking.maintenanceIntervalMs !== undefined) {
warn(
"immersionTracking.maintenanceIntervalMs",
src.immersionTracking.maintenanceIntervalMs,
resolved.immersionTracking.maintenanceIntervalMs,
"Expected integer between 60000 and 604800000.",
);
}
if (isObject(src.immersionTracking.retention)) {
const eventsDays = asNumber(src.immersionTracking.retention.eventsDays);
if (eventsDays !== undefined && eventsDays >= 1 && eventsDays <= 3650) {
resolved.immersionTracking.retention.eventsDays =
Math.floor(eventsDays);
} else if (src.immersionTracking.retention.eventsDays !== undefined) {
warn(
"immersionTracking.retention.eventsDays",
src.immersionTracking.retention.eventsDays,
resolved.immersionTracking.retention.eventsDays,
"Expected integer between 1 and 3650.",
);
}
const telemetryDays = asNumber(
src.immersionTracking.retention.telemetryDays,
);
if (
telemetryDays !== undefined &&
telemetryDays >= 1 &&
telemetryDays <= 3650
) {
resolved.immersionTracking.retention.telemetryDays =
Math.floor(telemetryDays);
} else if (
src.immersionTracking.retention.telemetryDays !== undefined
) {
warn(
"immersionTracking.retention.telemetryDays",
src.immersionTracking.retention.telemetryDays,
resolved.immersionTracking.retention.telemetryDays,
"Expected integer between 1 and 3650.",
);
}
const dailyRollupsDays = asNumber(
src.immersionTracking.retention.dailyRollupsDays,
);
if (
dailyRollupsDays !== undefined &&
dailyRollupsDays >= 1 &&
dailyRollupsDays <= 36500
) {
resolved.immersionTracking.retention.dailyRollupsDays = Math.floor(
dailyRollupsDays,
);
} else if (
src.immersionTracking.retention.dailyRollupsDays !== undefined
) {
warn(
"immersionTracking.retention.dailyRollupsDays",
src.immersionTracking.retention.dailyRollupsDays,
resolved.immersionTracking.retention.dailyRollupsDays,
"Expected integer between 1 and 36500.",
);
}
const monthlyRollupsDays = asNumber(
src.immersionTracking.retention.monthlyRollupsDays,
);
if (
monthlyRollupsDays !== undefined &&
monthlyRollupsDays >= 1 &&
monthlyRollupsDays <= 36500
) {
resolved.immersionTracking.retention.monthlyRollupsDays = Math.floor(
monthlyRollupsDays,
);
} else if (
src.immersionTracking.retention.monthlyRollupsDays !== undefined
) {
warn(
"immersionTracking.retention.monthlyRollupsDays",
src.immersionTracking.retention.monthlyRollupsDays,
resolved.immersionTracking.retention.monthlyRollupsDays,
"Expected integer between 1 and 36500.",
);
}
const vacuumIntervalDays = asNumber(
src.immersionTracking.retention.vacuumIntervalDays,
);
if (
vacuumIntervalDays !== undefined &&
vacuumIntervalDays >= 1 &&
vacuumIntervalDays <= 3650
) {
resolved.immersionTracking.retention.vacuumIntervalDays = Math.floor(
vacuumIntervalDays,
);
} else if (
src.immersionTracking.retention.vacuumIntervalDays !== undefined
) {
warn(
"immersionTracking.retention.vacuumIntervalDays",
src.immersionTracking.retention.vacuumIntervalDays,
resolved.immersionTracking.retention.vacuumIntervalDays,
"Expected integer between 1 and 3650.",
);
}
} else if (src.immersionTracking.retention !== undefined) {
warn(
"immersionTracking.retention",
src.immersionTracking.retention,
resolved.immersionTracking.retention,
"Expected object.",
);
}
}
if (isObject(src.subtitleStyle)) {
@@ -524,10 +793,14 @@ export class ConfigService {
},
};
const enableJlpt = asBoolean((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt);
const enableJlpt = asBoolean(
(src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt,
);
if (enableJlpt !== undefined) {
resolved.subtitleStyle.enableJlpt = enableJlpt;
} else if ((src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt !== undefined) {
} else if (
(src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt !== undefined
) {
warn(
"subtitleStyle.enableJlpt",
(src.subtitleStyle as { enableJlpt?: unknown }).enableJlpt,
@@ -565,7 +838,8 @@ export class ConfigService {
if (sourcePath !== undefined) {
resolved.subtitleStyle.frequencyDictionary.sourcePath = sourcePath;
} else if (
(frequencyDictionary as { sourcePath?: unknown }).sourcePath !== undefined
(frequencyDictionary as { sourcePath?: unknown }).sourcePath !==
undefined
) {
warn(
"subtitleStyle.frequencyDictionary.sourcePath",
@@ -576,13 +850,11 @@ export class ConfigService {
}
const topX = asNumber((frequencyDictionary as { topX?: unknown }).topX);
if (
topX !== undefined &&
Number.isInteger(topX) &&
topX > 0
) {
if (topX !== undefined && Number.isInteger(topX) && topX > 0) {
resolved.subtitleStyle.frequencyDictionary.topX = Math.floor(topX);
} else if ((frequencyDictionary as { topX?: unknown }).topX !== undefined) {
} else if (
(frequencyDictionary as { topX?: unknown }).topX !== undefined
) {
warn(
"subtitleStyle.frequencyDictionary.topX",
(frequencyDictionary as { topX?: unknown }).topX,
@@ -592,10 +864,7 @@ export class ConfigService {
}
const frequencyMode = frequencyDictionary.mode;
if (
frequencyMode === "single" ||
frequencyMode === "banded"
) {
if (frequencyMode === "single" || frequencyMode === "banded") {
resolved.subtitleStyle.frequencyDictionary.mode = frequencyMode;
} else if (frequencyMode !== undefined) {
warn(
@@ -612,7 +881,8 @@ export class ConfigService {
if (singleColor !== undefined) {
resolved.subtitleStyle.frequencyDictionary.singleColor = singleColor;
} else if (
(frequencyDictionary as { singleColor?: unknown }).singleColor !== undefined
(frequencyDictionary as { singleColor?: unknown }).singleColor !==
undefined
) {
warn(
"subtitleStyle.frequencyDictionary.singleColor",
@@ -628,7 +898,8 @@ export class ConfigService {
if (bandedColors !== undefined) {
resolved.subtitleStyle.frequencyDictionary.bandedColors = bandedColors;
} else if (
(frequencyDictionary as { bandedColors?: unknown }).bandedColors !== undefined
(frequencyDictionary as { bandedColors?: unknown }).bandedColors !==
undefined
) {
warn(
"subtitleStyle.frequencyDictionary.bandedColors",
@@ -649,13 +920,17 @@ export class ConfigService {
: isObject(ac.openRouter)
? ac.openRouter
: {};
const { nPlusOne: _nPlusOneConfigFromAnkiConnect, ...ankiConnectWithoutNPlusOne } =
ac as Record<string, unknown>;
const {
nPlusOne: _nPlusOneConfigFromAnkiConnect,
...ankiConnectWithoutNPlusOne
} = ac as Record<string, unknown>;
resolved.ankiConnect = {
...resolved.ankiConnect,
...(isObject(ankiConnectWithoutNPlusOne)
? (ankiConnectWithoutNPlusOne as Partial<ResolvedConfig["ankiConnect"]>)
? (ankiConnectWithoutNPlusOne as Partial<
ResolvedConfig["ankiConnect"]
>)
: {}),
fields: {
...resolved.ankiConnect.fields,
@@ -837,8 +1112,7 @@ export class ConfigService {
nPlusOneRefreshMinutes > 0;
if (nPlusOneRefreshMinutes !== undefined) {
if (hasValidNPlusOneRefreshMinutes) {
resolved.ankiConnect.nPlusOne.refreshMinutes =
nPlusOneRefreshMinutes;
resolved.ankiConnect.nPlusOne.refreshMinutes = nPlusOneRefreshMinutes;
} else {
warn(
"ankiConnect.nPlusOne.refreshMinutes",
@@ -927,8 +1201,7 @@ export class ConfigService {
DEFAULT_CONFIG.ankiConnect.nPlusOne.matchMode;
} else if (legacyNPlusOneMatchMode !== undefined) {
if (hasValidLegacyMatchMode) {
resolved.ankiConnect.nPlusOne.matchMode =
legacyNPlusOneMatchMode;
resolved.ankiConnect.nPlusOne.matchMode = legacyNPlusOneMatchMode;
warn(
"ankiConnect.behavior.nPlusOneMatchMode",
behavior.nPlusOneMatchMode,
@@ -958,9 +1231,7 @@ export class ConfigService {
.filter((entry) => entry.length > 0);
if (normalizedDecks.length === nPlusOneDecks.length) {
resolved.ankiConnect.nPlusOne.decks = [
...new Set(normalizedDecks),
];
resolved.ankiConnect.nPlusOne.decks = [...new Set(normalizedDecks)];
} else if (nPlusOneDecks.length > 0) {
warn(
"ankiConnect.nPlusOne.decks",

View File

@@ -11,11 +11,14 @@ function renderValue(value: unknown, indent = 0): string {
if (value === null) return "null";
if (typeof value === "string") return JSON.stringify(value);
if (typeof value === "number" || typeof value === "boolean") return String(value);
if (typeof value === "number" || typeof value === "boolean")
return String(value);
if (Array.isArray(value)) {
if (value.length === 0) return "[]";
const items = value.map((item) => `${nextPad}${renderValue(item, indent + 2)}`);
const items = value.map(
(item) => `${nextPad}${renderValue(item, indent + 2)}`,
);
return `\n${items.join(",\n")}\n${pad}`.replace(/^/, "[").concat("]");
}
@@ -25,7 +28,8 @@ function renderValue(value: unknown, indent = 0): string {
);
if (entries.length === 0) return "{}";
const lines = entries.map(
([key, child]) => `${nextPad}${JSON.stringify(key)}: ${renderValue(child, indent + 2)}`,
([key, child]) =>
`${nextPad}${JSON.stringify(key)}: ${renderValue(child, indent + 2)}`,
);
return `\n${lines.join(",\n")}\n${pad}`.replace(/^/, "{").concat("}");
}
@@ -45,23 +49,33 @@ function renderSection(
lines.push(` // ${comment}`);
}
lines.push(" // ==========================================");
lines.push(` ${JSON.stringify(key)}: ${renderValue(value, 2)}${isLast ? "" : ","}`);
lines.push(
` ${JSON.stringify(key)}: ${renderValue(value, 2)}${isLast ? "" : ","}`,
);
return lines.join("\n");
}
export function generateConfigTemplate(config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG)): string {
export function generateConfigTemplate(
config: ResolvedConfig = deepCloneConfig(DEFAULT_CONFIG),
): string {
const lines: string[] = [];
lines.push("/**");
lines.push(" * SubMiner Example Configuration File");
lines.push(" *");
lines.push(" * This file is auto-generated from src/config/definitions.ts.");
lines.push(" * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.");
lines.push(
" * Copy to $XDG_CONFIG_HOME/SubMiner/config.jsonc (or ~/.config/SubMiner/config.jsonc) and edit as needed.",
);
lines.push(" */");
lines.push("{");
CONFIG_TEMPLATE_SECTIONS.forEach((section, index) => {
lines.push("");
const comments = [section.title, ...section.description, ...(section.notes ?? [])];
const comments = [
section.title,
...section.description,
...(section.notes ?? []),
];
lines.push(
renderSection(
section.key,