diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 308c7f66..7f6264ea 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -278,20 +278,26 @@ export interface CacheConfigOptions { regex?: number } -export interface CacheConfigUser { - auth?: { - provider: 'valkey' | 'file', - [key: string]: any - }; - valkey?: string +export const cacheConfigUserAuthSchema = z.object({ + provider: z.union([z.literal('valkey'), z.literal('file')]), +}).catchall(z.any()); + +// The top-level (and `auth`) catchalls allow deprecated `scrobble`/`metadata` config keys to still be read off +// this type at runtime (see Cache.ts#parseUserConfig) without having them show up in schema docs. +export const cacheConfigUserSchema = z.object({ + auth: cacheConfigUserAuthSchema.optional(), + valkey: z.string().optional(), /** Number of regex functions to cache (LRU) - * + * * @default 200 */ - regex?: number - // to allow deprecated scrobble config without having it show up in schema docs - [key: string]: any -} + regex: z.number().optional().meta({ + description: "Number of regex functions to cache (LRU)", + default: 200 + }), +}).catchall(z.any()); + +export type CacheConfigUser = z.infer; export interface MusicbrainzApiConfigData { url?: string diff --git a/src/backend/common/infrastructure/config/aioConfig.ts b/src/backend/common/infrastructure/config/aioConfig.ts index e5f35901..0dd748e7 100644 --- a/src/backend/common/infrastructure/config/aioConfig.ts +++ b/src/backend/common/infrastructure/config/aioConfig.ts @@ -1,28 +1,54 @@ -import type { LogOptions } from "@foxxmd/logging"; -import type { ClientAIOConfig } from "./client/clients.ts"; -import type { CommonClientOptions } from "./client/index.ts"; -import type { RequestRetryOptions } from "./common.ts"; -import type { WebhookConfig } from "./health/webhooks.ts"; -import type { CommonSourceOptions, SourceRetryOptions } from "./source/index.ts"; -import type { SourceAIOConfig } from "./source/sources.ts"; -import type { CacheConfigUser } from "../Atomic.ts"; -import type { TransformerCommonConfig } from "../../../../core/Atomic.ts"; -import type { RetentionConfigDurationValue } from "./database.ts"; - - -export interface SourceDefaults extends CommonSourceOptions { -} - -export interface ClientDefaults extends CommonClientOptions { -} - -export interface AIOConfig { - sourceDefaults?: SourceDefaults - clientDefaults?: ClientDefaults - sources?: SourceAIOConfig[] - clients?: ClientAIOConfig[] - - webhooks?: WebhookConfig[] +import * as z from "zod"; +import {clientAIOConfigSchema} from "./client/clients.ts"; +import {commonClientOptionsSchema} from "./client/index.ts"; +import {requestRetryOptionsSchema} from "./common.ts"; +import {webhookConfigSchema} from "./health/webhooks.ts"; +import {commonSourceOptionsSchema, fileLogOptionsSchema, logLevelSchema, sourceRetryOptionsSchema} from "./source/index.ts"; +import {sourceAIOConfigSchema} from "./source/sources.ts"; +import {cacheConfigUserSchema} from "../Atomic.ts"; +import {retentionConfigDurationValueSchema} from "./database.ts"; + +export const sourceDefaultsSchema = z.object({ + ...commonSourceOptionsSchema.shape, +}); + +export type SourceDefaults = z.infer; + +export const clientDefaultsSchema = z.object({ + ...commonClientOptionsSchema.shape, +}); + +export type ClientDefaults = z.infer; + +// `TransformOptions`/`TransformerCommonConfig` (from `../../../../core/Atomic.ts`) are only ever used at +// their default type params (`Record`) everywhere in the codebase, but `TransformerCommonConfig` +// stays generic there and `TransformerCommon extends TransformerCommonConfig` - converting the +// exported interface itself would break that extends clause. Reconstructed locally here instead, scoped to +// this file's `transformers` field only. +const transformOptionsSchema = z.object({ + failOnFetch: z.boolean().optional(), + throwOnFailure: z.union([ + z.boolean(), + z.array(z.union([z.literal('artists'), z.literal('title'), z.literal('albumArtists'), z.literal('album'), z.literal('duration'), z.literal('meta'), z.literal('art')])), + ]).optional(), + ttl: z.string().optional(), +}); + +const transformerCommonConfigSchema = z.object({ + defaults: z.record(z.string(), z.any()).optional(), + data: z.record(z.string(), z.any()).optional(), + type: z.string(), + name: z.string().optional(), + options: transformOptionsSchema.optional(), +}); + +export const aioConfigSchema = z.object({ + sourceDefaults: sourceDefaultsSchema.optional(), + clientDefaults: clientDefaultsSchema.optional(), + sources: z.array(sourceAIOConfigSchema).optional(), + clients: z.array(clientAIOConfigSchema).optional(), + + webhooks: z.array(webhookConfigSchema).optional(), /** * Set the port the multi-scrobbler UI will be served from @@ -30,7 +56,11 @@ export interface AIOConfig { * @default 9078 * @examples [9078] * */ - port?: number + port: z.number().optional().meta({ + description: "Set the port the multi-scrobbler UI will be served from", + default: 9078, + examples: [9078] + }), /** * Set the Base URL the application should assume the UI is served from. @@ -44,16 +74,26 @@ export interface AIOConfig { * @default "http://localhost" * @examples ["http://localhost", "http://192.168.0.101", "https://ms.myDomain.tld"] * */ - baseUrl?: string - - logging?: LogOptions + baseUrl: z.string().optional().meta({ + description: "Set the Base URL the application should assume the UI is served from.", + default: "http://localhost", + examples: ["http://localhost", "http://192.168.0.101", "https://ms.myDomain.tld"] + }), + + logging: z.object({ + level: logLevelSchema.optional(), + file: z.union([logLevelSchema, z.literal(false), fileLogOptionsSchema]).optional(), + console: logLevelSchema.optional(), + }).optional(), /** * Disable web server from running/listening on port. * * This will also make any ingress sources (Plex, Jellyfin, Tautulli, etc...) unusable * */ - disableWeb?: boolean + disableWeb: z.boolean().optional().meta({ + description: "Disable web server from running/listening on port." + }), /** * Enables ALL relevant logging and debug options for all sources/clients, when none are defined. @@ -65,38 +105,53 @@ export interface AIOConfig { * @default false * @examples [false] * */ - debugMode?: boolean + debugMode: z.boolean().optional().meta({ + description: "Enables ALL relevant logging and debug options for all sources/clients, when none are defined.", + default: false, + examples: [false] + }), + + cache: cacheConfigUserSchema.optional(), + + transformers: z.array(transformerCommonConfigSchema).optional(), + + database: z.object({ + retention: retentionConfigDurationValueSchema.optional(), + }).optional(), +}); + +export type AIOConfig = z.infer; + +export const aioClientConfigSchema = z.object({ + clientDefaults: requestRetryOptionsSchema.optional(), + clients: z.array(clientAIOConfigSchema).optional(), +}); + +export type AIOClientConfig = z.infer; - cache?: CacheConfigUser +export const aioClientRelaxedConfigSchema = z.object({ + clientDefaults: requestRetryOptionsSchema.optional(), + clients: z.array(z.looseObject({})).optional(), +}); - transformers?: TransformerCommonConfig[] +export type AIOClientRelaxedConfig = z.infer; - database?: { - retention?: RetentionConfigDurationValue - } -} +export const aioSourceConfigSchema = z.object({ + sourceDefaults: sourceRetryOptionsSchema.optional(), + sources: z.array(sourceAIOConfigSchema).optional(), +}); -export interface AIOClientConfig { - clientDefaults?: RequestRetryOptions - clients?: ClientAIOConfig[] -} +export type AIOSourceConfig = z.infer; -export interface AIOClientRelaxedConfig { - clientDefaults?: RequestRetryOptions - clients?: object[] -} +export const aioSourceRelaxedConfigSchema = z.object({ + sourceDefaults: sourceRetryOptionsSchema.optional(), + sources: z.array(z.looseObject({})).optional(), +}); -export interface AIOSourceConfig { - sourceDefaults?: SourceRetryOptions - sources?: SourceAIOConfig[] -} +export type AIOSourceRelaxedConfig = z.infer; -export interface AIOSourceRelaxedConfig { - sourceDefaults?: SourceRetryOptions - sources?: object[] -} +export const typedConfigSchema = z.object({ + type: z.string(), +}); -export interface TypedConfig { - type: T - // [key: string]: any -} \ No newline at end of file +export type TypedConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/index.ts b/src/backend/common/infrastructure/config/source/index.ts index ba660415..b932c40d 100644 --- a/src/backend/common/infrastructure/config/source/index.ts +++ b/src/backend/common/infrastructure/config/source/index.ts @@ -60,12 +60,12 @@ export const scrobbleThresholdsSchema = z.object({ export type ScrobbleThresholds = z.infer; // `LogLevel` (from `@foxxmd/logging`) is a simple string-literal union, reconstructed directly. -const logLevelSchema = z.enum(["silent", "fatal", "error", "warn", "info", "log", "verbose", "debug", "trace"]); +export const logLevelSchema = z.enum(["silent", "fatal", "error", "warn", "info", "log", "verbose", "debug", "trace"]); // `FileLogOptions` (from `@foxxmd/logging`) extends `FileOptions`, which itself extends `PinoRollOptions` and // `RollOptions` - two levels deep, but all plain data fields, so it's reconstructed in full here rather than // stubbed. -const fileLogOptionsSchema = z.object({ +export const fileLogOptionsSchema = z.object({ size: z.union([z.number(), z.string()]).optional(), frequency: z.union([z.literal('daily'), z.literal('hourly'), z.number()]).optional(), timestamp: z.union([z.literal('unix'), z.literal('iso'), z.literal('auto')]).optional(),