From 742025feccf506db6c0cacd6009d26971b3056b6 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 24 Jul 2026 12:56:53 -0400 Subject: [PATCH] feat: Convert source and client configs to zod --- .vscode/launch.json | 18 + package.json | 3 +- src/backend/common/database/Database.ts | 16 +- src/backend/common/infrastructure/Atomic.ts | 5 +- .../common/infrastructure/config/aioConfig.ts | 6 +- .../infrastructure/config/client/atproto.ts | 29 +- .../infrastructure/config/client/discord.ts | 104 ++++-- .../infrastructure/config/client/index.ts | 140 ++++++-- .../infrastructure/config/client/koito.ts | 57 ++- .../infrastructure/config/client/lastfm.ts | 68 +++- .../infrastructure/config/client/librefm.ts | 146 +++++--- .../config/client/listenbrainz.ts | 58 ++- .../infrastructure/config/client/maloja.ts | 52 ++- .../infrastructure/config/client/rocksky.ts | 88 +++-- .../infrastructure/config/client/tealfm.ts | 67 ++-- .../common/infrastructure/config/common.ts | 99 ++++-- .../common/infrastructure/config/database.ts | 158 +++++++-- .../infrastructure/config/source/azuracast.ts | 174 +++++---- .../infrastructure/config/source/index.ts | 152 ++++++-- src/core/Atomic.ts | 12 +- src/core/Transform.ts | 333 +++++++++++++++--- ts-zod.ts | 12 + 22 files changed, 1305 insertions(+), 492 deletions(-) create mode 100644 ts-zod.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 1eeaa40c..0fc9eb19 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,6 +1,24 @@ { "version": "0.2.0", "configurations": [ + { + "name": "Debug Zod Conversion", + "type": "node", + "request": "launch", + // Debug app in VSCode + "program": "${workspaceFolder}/ts-zod.ts", + "args": [ + "${file}" + ], + "runtimeExecutable": "node", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "skipFiles": [ + "/**", + // Ignore all dependencies (optional) + "${workspaceFolder}/node_modules/**", + ], + }, { "name": "Run Canonical Replace", "program": "${workspaceFolder}/docsite/canonical-replace.ts", diff --git a/package.json b/package.json index 732c2444..d13654be 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "dev": "node src/backend/index.ts", "start": "NODE_ENV=production node src/backend/index.ts", "build:frontend": "vite build", - "build:backend": "tsc -p src/backend && npm run -s schema:app", + "build:backend:ts": "tsc -p src/backend", + "build:backend": "npm run -s build:backend:ts && npm run -s schema:app", "build": "npm run -s build:backend && npm run -s build:frontend && npm run -s docs:build", "build:parallel": "concurrently --kill-others-on-fail --names backend,frontend,docs \"npm run -s build:backend\" \"npm run -s build:frontend\" \"npm run docs:build\"", "docs:install": "cd docsite && npm install --no-audit", diff --git a/src/backend/common/database/Database.ts b/src/backend/common/database/Database.ts index 22bb19da..eeb63e57 100644 --- a/src/backend/common/database/Database.ts +++ b/src/backend/common/database/Database.ts @@ -2,9 +2,7 @@ import { getDataDir } from '../index.ts'; import * as path from 'path'; import { childLogger, type Logger } from '@foxxmd/logging'; import { loggerNoop } from '../MaybeLogger.ts'; -import { COMPACTABLE, type CompactableProperty, DEFAULT_RETENTION_COMPACT_AFTER, DEFAULT_RETENTION_DELETE_AFTER, type RetentionConfigValue, type RetentionOption, type RetentionValue, type RetentionValueUnparsed } from '../infrastructure/config/database.ts'; -import type {DurationValue} from '../infrastructure/Atomic.ts'; -import type {Duration} from 'dayjs/plugin/duration.js'; +import { COMPACTABLE, type CompactableProperty, DEFAULT_RETENTION_COMPACT_AFTER, DEFAULT_RETENTION_DELETE_AFTER, type RetentionConfigValueDurationValue, type RetentionConfigValueDuration, type RetentionOptionDuration, type RetentionOptionRetentionValue, type RetentionValue, type RetentionValueUnparsed } from '../infrastructure/config/database.ts'; import dayjs from 'dayjs'; import { parseDurationFromDurationValue } from '../../utils/TimeUtils.ts'; import assert from 'node:assert'; @@ -69,7 +67,7 @@ const parseRetentionValue = (val: RetentionValueUnparsed): RetentionValue => { throw new SimpleError('retention value be of one: false, number, or string'); } -const parseRetentionFromEnv = (type: string, defaultVal: number = DEFAULT_RETENTION_DELETE_AFTER): RetentionOption => { +const parseRetentionFromEnv = (type: string, defaultVal: number = DEFAULT_RETENTION_DELETE_AFTER): RetentionOptionRetentionValue => { const deleteAfterEnv = process.env[`RETENTION_${type}_AFTER`] ?? defaultVal, deleteCompletedEnv = process.env[`RETENTION_${type}_COMPLETED_AFTER`] ?? deleteAfterEnv, deleteFailedEnv = process.env[`RETENTION_${type}_FAILED_AFTER`] ?? deleteAfterEnv, @@ -82,14 +80,14 @@ const parseRetentionFromEnv = (type: string, defaultVal: number = DEFAULT_RETENT } } -const isRetentionOptionDurations = (val: RetentionOption): val is RetentionOption => { +const isRetentionOptionDurations = (val: RetentionOptionRetentionValue): val is RetentionOptionDuration => { return dayjs.isDuration(val.completed) && dayjs.isDuration(val.duped) && dayjs.isDuration(val.failed); } -let retentionDeleteAfterFromEnv: RetentionOption, -retentionCompactAfterFromEnv: RetentionOption; +let retentionDeleteAfterFromEnv: RetentionOptionDuration, +retentionCompactAfterFromEnv: RetentionOptionRetentionValue; export const getRetentionDeleteAfterFromEnv = () => { if (retentionDeleteAfterFromEnv === undefined) { @@ -110,7 +108,7 @@ export const getRetentionCompactAfterFromEnv = () => { return retentionCompactAfterFromEnv; } -export const parseRetentionOptions = (opts: RetentionConfigValue = {}, defaults: RetentionOption): RetentionOption => { +export const parseRetentionOptions = (opts: RetentionConfigValueDurationValue = {}, defaults: RetentionOptionRetentionValue): RetentionOptionRetentionValue => { if (typeof opts === 'number' || typeof opts === 'string') { const dur = parseDurationFromDurationValue(opts); return { @@ -145,7 +143,7 @@ export const parseRetentionOptions = (opts: RetentionConfigValue } } -export const parseRetentionOptionsDurations = (opts: RetentionConfigValue> = {}, defaults: RetentionOption): RetentionOption => { +export const parseRetentionOptionsDurations = (opts: RetentionConfigValueDurationValue | RetentionConfigValueDuration = {}, defaults: RetentionOptionDuration): RetentionOptionDuration => { if (typeof opts === 'number' || typeof opts === 'string') { const dur = parseDurationFromDurationValue(opts); return { diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 35b5107f..308c7f66 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -11,6 +11,7 @@ import type { ReportedPlayerStatus, SourceType } from "../../../core/Atomic.ts"; import type { ClientType } from "../../../core/Atomic.ts"; import assert from 'assert'; import * as path from 'path'; +import * as z from 'zod'; export const __filename = import.meta.filename; export const projectRootDir = path.resolve(__filename, '../../../../../'); @@ -409,6 +410,7 @@ export interface ScrobbleRangeResult { export const REFRESH_STALE_DEFAULT = 60; +export const durationValueSchema = z.union([z.number(), z.string()]); /** * A duration of time * @@ -419,7 +421,8 @@ export const REFRESH_STALE_DEFAULT = 60; * * @example [60, 3600, "1 hour", "4 days"] */ -export type DurationValue = number | string; +export type DurationValue = z.infer; // number | string; + export type MigrationStatus = { backupRequired: boolean, diff --git a/src/backend/common/infrastructure/config/aioConfig.ts b/src/backend/common/infrastructure/config/aioConfig.ts index fec1e277..e5f35901 100644 --- a/src/backend/common/infrastructure/config/aioConfig.ts +++ b/src/backend/common/infrastructure/config/aioConfig.ts @@ -5,9 +5,9 @@ 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, DurationValue } from "../Atomic.ts"; +import type { CacheConfigUser } from "../Atomic.ts"; import type { TransformerCommonConfig } from "../../../../core/Atomic.ts"; -import type { RetentionConfig } from "./database.ts"; +import type { RetentionConfigDurationValue } from "./database.ts"; export interface SourceDefaults extends CommonSourceOptions { @@ -72,7 +72,7 @@ export interface AIOConfig { transformers?: TransformerCommonConfig[] database?: { - retention?: RetentionConfig + retention?: RetentionConfigDurationValue } } diff --git a/src/backend/common/infrastructure/config/client/atproto.ts b/src/backend/common/infrastructure/config/client/atproto.ts index cd36258a..2e063409 100644 --- a/src/backend/common/infrastructure/config/client/atproto.ts +++ b/src/backend/common/infrastructure/config/client/atproto.ts @@ -1,26 +1,35 @@ +import * as z from "zod"; import type {AtprotoDid} from "@atcute/lexicons/syntax"; -export interface ATProtoUserIdentifierData { +export const atProtoUserIdentifierDataSchema = z.object({ /** * Identify the account to login as - * + * * * For **App Password** Auth - your email * * For **Oauth** - your handle minus the @ */ - identifier: string - did?: string -} + identifier: z.string().meta({ + description: "Identify the account to login as" + }), + did: z.string().optional(), +}); -export interface ATProtoAppData { +export type ATProtoUserIdentifierData = z.infer; + +export const atProtoAppDataSchema = z.object({ /** * The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account - * + * * This is created under https://bsky.app/settings/app-passwords - * + * * **Use this if you are self-hosting Multi-Scrobbler on localhost or accessed like http://IP:PORT** */ - appPassword: string -} + appPassword: z.string().meta({ + description: "The [App Password](https://atproto.com/specs/xrpc#app-passwords) you created for your account" + }), +}); + +export type ATProtoAppData = z.infer; export interface HandleData { did: AtprotoDid diff --git a/src/backend/common/infrastructure/config/client/discord.ts b/src/backend/common/infrastructure/config/client/discord.ts index f93f918b..a743ea7e 100644 --- a/src/backend/common/infrastructure/config/client/discord.ts +++ b/src/backend/common/infrastructure/config/client/discord.ts @@ -1,57 +1,89 @@ -import type {CommonClientConfig, CommonClientData} from "./index.ts"; -import type {ComponentType} from "../../../../../core/Atomic.ts"; - -export interface DiscordData { - token?: string - applicationId?: string - artwork?: boolean | string | string[] - artworkDefaultUrl?: string - statusOverrideAllow?: string | StatusType[] - listeningActivityAllow?: string | string[] - ipcLocations?: string | (string | [number, string])[] -} +import * as z from "zod"; +import {commonClientConfigSchema, commonClientDataSchema} from "./index.ts"; +import {componentTypeSchema} from "../../../../../core/Atomic.ts"; + +export const statusTypeSchema = z.union([z.literal("online"), z.literal("idle"), z.literal("dnd"), z.literal("invisible")]); + +export type StatusType = z.infer; + +// `z.tuple([z.number(), z.string()])` in the installed zod version infers as `[number?, string?, ...unknown[]]` +// rather than `[number, string]`, which breaks real consumers (e.g. DiscordIPCClient.ts) expecting a strict +// 2-tuple. `z.custom` sidesteps the bug while still checking shape at runtime. +const ipcLocationTupleSchema = z.custom<[number, string]>((val) => Array.isArray(val) && val.length === 2 && typeof val[0] === 'number' && typeof val[1] === 'string'); + +export const discordDataSchema = z.object({ + token: z.string().optional(), + applicationId: z.string().optional(), + artwork: z.union([z.boolean(), z.string(), z.array(z.string())]).optional(), + artworkDefaultUrl: z.string().optional(), + statusOverrideAllow: z.union([z.string(), z.array(statusTypeSchema)]).optional(), + listeningActivityAllow: z.union([z.string(), z.array(z.string())]).optional(), + ipcLocations: z.union([z.string(), z.array(z.union([z.string(), ipcLocationTupleSchema]))]).optional() +}); -export interface DiscordClientData extends DiscordData, CommonClientData {} +export type DiscordData = z.infer; -export interface DiscordClientConfig extends CommonClientConfig { +export const discordClientDataSchema = discordDataSchema.extend(commonClientDataSchema.shape); + +export type DiscordClientData = z.infer; + +export const discordClientConfigSchema = z.object({ + ...commonClientConfigSchema.shape, /** * Should always be `client` when using Koito as a client * * @default client * @examples ["client"] * */ - configureAs?: ComponentType - data: DiscordClientData -} + configureAs: componentTypeSchema.optional().meta({ + description: "Should always be `client` when using Koito as a client", + default: "client", + examples: ["client"] + }), + data: discordClientDataSchema, +}); -export interface DiscordClientAIOConfig extends DiscordClientConfig { - type: 'discord' -} +export type DiscordClientConfig = z.infer; + +export const discordClientAIOConfigSchema = z.object({ + ...discordClientConfigSchema.shape, + type: z.literal('discord'), +}); + +export type DiscordClientAIOConfig = z.infer; + +export const activityTypeStringSchema = z.union([z.literal("playing"), z.literal("streaming"), z.literal("listening"), z.literal("watching"), z.literal("custom"), z.literal("competing"), z.literal("hanging")]); + +export type ActivityTypeString = z.infer; -export type ActivityTypeString = 'playing' | 'streaming' | 'listening' | 'watching' | 'custom' | 'competing' | 'hanging'; export const ActivityTypes: ActivityTypeString[] = ['playing','streaming','listening','watching','custom','competing', 'hanging']; -export type StatusType = 'online' | 'idle' | 'dnd' | 'invisible'; -export interface DiscordStrongData extends DiscordData { - artwork?: boolean | string[] - statusOverrideAllow?: StatusType[] - listeningActivityAllow?: string[] - ipcLocations?: (string | [number, string])[] -} +export const discordStrongDataSchema = discordDataSchema.extend({ + artwork: z.union([z.boolean(), z.array(z.string())]).optional(), + statusOverrideAllow: z.array(statusTypeSchema).optional(), + listeningActivityAllow: z.array(z.string()).optional(), + ipcLocations: z.array(z.union([z.string(), ipcLocationTupleSchema])).optional() +}); -export interface DiscordWSData extends DiscordStrongData { - token: string -} +export type DiscordStrongData = z.infer; -export interface DiscordIPCData extends DiscordStrongData { - applicationId: string +export const discordWSDataSchema = discordStrongDataSchema.extend({ + token: z.string(), +}); + +export type DiscordWSData = z.infer; + +export const discordIPCDataSchema = discordStrongDataSchema.extend({ + applicationId: z.string(), //ipcLocations: (string | [number, string])[] -} +}); + +export type DiscordIPCData = z.infer; export interface ActivityAssets { largeImage?: string largeText?: string - largeUrl?: string + largeUrl?: string smallImage?: string smallText?: string smallUrl?: string @@ -98,4 +130,4 @@ export const ACTIVITY_TYPE = { } as const satisfies Record export const ARTWORK_PLACEHOLDER = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/default-artwork.png'; -export const MS_ART = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png'; \ No newline at end of file +export const MS_ART = 'https://raw.githubusercontent.com/FoxxMD/multi-scrobbler/master/assets/icon.png'; diff --git a/src/backend/common/infrastructure/config/client/index.ts b/src/backend/common/infrastructure/config/client/index.ts index d6df33ba..e5c69058 100644 --- a/src/backend/common/infrastructure/config/client/index.ts +++ b/src/backend/common/infrastructure/config/client/index.ts @@ -1,45 +1,69 @@ -import type {DurationValue} from "../../Atomic.ts"; -import type {PlayTransformOptions} from "../../../../../core/Transform.ts"; -import type {CommonConfig, MonitorOptions, RequestRetryOptions} from "../common.ts"; -import type {RetentionConfig} from "../database.ts"; +import * as z from "zod"; +import {playTransformOptionsSchema} from "../../../../../core/Transform.ts"; +import {commonConfigSchema, requestRetryOptionsSchema, MonitorOptions} from "../common.ts"; +import {retentionConfigDurationValueSchema} from "../database.ts"; /** * Scrobble matching (between new source track and existing client scrobbles) logging options. Used for debugging. * */ -export interface MatchLoggingOptions { +export const matchLoggingOptionsSchema = z.object({ /** * Log to DEBUG when a new track does NOT match an existing scrobble * * @default false * @examples [false] * */ - onNoMatch?: boolean + onNoMatch: z.boolean().optional().meta({ + description: "Log to DEBUG when a new track does NOT match an existing scrobble", + default: false, + examples: [false] + }), /** * Log to DEBUG when a new track DOES match an existing scrobble * * @default false * @examples [false] * */ - onMatch?: boolean + onMatch: z.boolean().optional().meta({ + description: "Log to DEBUG when a new track DOES match an existing scrobble", + default: false, + examples: [false] + }), /** * Include confidence breakdowns in track match logging, if applicable * * @default false * @examples [false] * */ - confidenceBreakdown?: boolean -} + confidenceBreakdown: z.boolean().optional().meta({ + description: "Include confidence breakdowns in track match logging, if applicable", + default: false, + examples: [false] + }), +}); -export interface CommonClientData { -} +export type MatchLoggingOptions = z.infer; -export interface UpstreamRefreshOptions { +export const commonClientDataSchema = z.looseObject({}); + +// `z.infer` of an empty object schema (strict or loose) picks up a `never`/`unknown` index signature that a +// plain empty TS interface never had, which breaks the many `interface FooData extends CommonClientData, ...` +// declarations elsewhere (see the same fix applied to `CommonSourceData` in `../source/index.ts`). The +// original `interface CommonClientData {}` is structurally identical to `{}` itself, so the type is declared +// directly rather than derived from the schema for this one empty-shape case. +export type CommonClientData = {}; + +export const upstreamRefreshOptionsSchema = z.object({ /** * Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history * @default true * @examples [true] * */ - refreshEnabled?: boolean + refreshEnabled: z.boolean().optional().meta({ + description: "Try to get fresh scrobble history from client when tracks to be scrobbled are newer than the last scrobble found in client history", + default: true, + examples: [true] + }), /** * Refresh scrobbled plays from upstream service if last refresh was at least X seconds ago * @@ -50,7 +74,11 @@ export interface UpstreamRefreshOptions { * @examples [60] * @default 60 * */ - refreshStaleAfter?: number + refreshStaleAfter: z.number().optional().meta({ + description: "Refresh scrobbled plays from upstream service if last refresh was at least X seconds ago", + default: 60, + examples: [60] + }), /** * Minimum time (milliseconds) required to pass before upstream scrobbles can be refreshed. @@ -60,67 +88,103 @@ export interface UpstreamRefreshOptions { * @default 5000 * @examples [5000] * */ - refreshMinInterval?: number + refreshMinInterval: z.number().optional().meta({ + description: "Minimum time (milliseconds) required to pass before upstream scrobbles can be refreshed.", + default: 5000, + examples: [5000] + }), /** * The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount). If not specified this is the maximum supported by the client in 1 API call. * */ - refreshInitialCount?: number -} + refreshInitialCount: z.number().optional().meta({ + description: "The number of tracks to retrieve on initial refresh (related to scrobbleBacklogCount)." + }), +}); + +export type UpstreamRefreshOptions = z.infer; -export interface NowPlayingOptions { +export const nowPlayingOptionsSchema = z.object({ /** * Configure if this Client should report Now Playing from Sources that can scrobble to it - * - * * `true` (default) => Report Now Playing from any eligible Source. + * + * * `true` (default) => Report Now Playing from any eligible Source. * * If multiple Sources are Playing then reported Play is based on alphabetical order of Source names * * `false` => Do not report Now Playing * * `string` list => list of Source `names` that should be allowed to report Now Playing. Order of list determine priority of Play to Report. - * + * * @default true * */ - nowPlaying?: boolean | string[] -} + nowPlaying: z.union([z.boolean(), z.array(z.string())]).optional().meta({ + description: "Configure if this Client should report Now Playing from Sources that can scrobble to it", + default: true + }), +}); -export interface CommonClientOptions extends RequestRetryOptions, UpstreamRefreshOptions, MonitorOptions { +export type NowPlayingOptions = z.infer; + +export const commonClientOptionsSchema = z.object({ + ...requestRetryOptionsSchema.shape, + ...upstreamRefreshOptionsSchema.shape, /** * Check client for an existing scrobble at the same recorded time as the "new" track to be scrobbled. If an existing scrobble is found this track is not track scrobbled. * @default true * @examples [true] * */ - checkExistingScrobbles?: boolean + checkExistingScrobbles: z.boolean().optional().meta({ + description: "Check client for an existing scrobble at the same recorded time as the \"new\" track to be scrobbled.", + default: true, + examples: [true] + }), /** * Options used for increasing verbosity of logging in MS (used for debugging) * */ - verbose?: { - - match?: MatchLoggingOptions - } + verbose: z.object({ + match: matchLoggingOptionsSchema.optional() + }).optional().meta({ + description: "Options used for increasing verbosity of logging in MS (used for debugging)" + }), /** * Number of times MS should automatically retry scrobbles in dead letter queue * * @default 3 * @examples [3] * */ - deadLetterRetries?: number + deadLetterRetries: z.number().optional().meta({ + description: "Number of times MS should automatically retry scrobbles in dead letter queue", + default: 3, + examples: [3] + }), - playTransform?: PlayTransformOptions + playTransform: playTransformOptionsSchema.optional(), - retention?: RetentionConfig -} + retention: retentionConfigDurationValueSchema.optional(), +}); -export interface CommonClientConfig extends CommonConfig { +export type CommonClientOptions = z.infer; + +export const commonClientConfigSchema = z.object({ + ...commonConfigSchema.shape, /** * Unique identifier for this client. Used with sources to restrict where scrobbles are sent. * * @examples ["MyConfig"] * */ - name: string + name: z.string().meta({ + description: "Unique identifier for this client.", + examples: ["MyConfig"] + }), /** * Specific data required to configure this client * */ - data?: CommonClientData - options?: CommonClientOptions -} + data: commonClientDataSchema.optional().meta({ + description: "Specific data required to configure this client" + }), + options: commonClientOptionsSchema.optional(), +}); + +// `data`'s type is overridden here for the same reason `CommonClientData` is declared directly above rather +// than derived from `commonClientDataSchema`. +export type CommonClientConfig = Omit, 'data'> & { data?: CommonClientData }; diff --git a/src/backend/common/infrastructure/config/client/koito.ts b/src/backend/common/infrastructure/config/client/koito.ts index 01a80172..a43be41e 100644 --- a/src/backend/common/infrastructure/config/client/koito.ts +++ b/src/backend/common/infrastructure/config/client/koito.ts @@ -1,6 +1,8 @@ -import type {ComponentType, UnixTimestamp} from "../../../../../core/Atomic.ts"; -import type {RequestRetryOptions} from "../common.ts"; -import type {CommonClientConfig, CommonClientData} from "./index.ts"; +import * as z from "zod"; +import type {UnixTimestamp} from "../../../../../core/Atomic.ts"; +import {componentTypeSchema} from "../../../../../core/Atomic.ts"; +import {requestRetryOptionsSchema} from "../common.ts"; +import {commonClientConfigSchema, commonClientDataSchema} from "./index.ts"; export interface ListensResponse { items: ListenObjectResponse[] @@ -45,39 +47,62 @@ export interface ArtistResponse { name: string } -export interface KoitoData extends RequestRetryOptions { +export const koitoDataSchema = z.object({ + ...requestRetryOptionsSchema.shape, /** * URL for the Koito server * * @examples ["http://192.168.0.100:4110"] * */ - url: string + url: z.string().meta({ + description: "URL for the Koito server", + examples: ["http://192.168.0.100:4110"] + }), /** * User token for the user to scrobble for * * @examples ["pM195xPV98CDpk0QW47FIIOR8AKATAX5DblBF-Jq0t1MbbKL"] * */ - token: string + token: z.string().meta({ + description: "User token for the user to scrobble for", + examples: ["pM195xPV98CDpk0QW47FIIOR8AKATAX5DblBF-Jq0t1MbbKL"] + }), /** * Username of the user to scrobble for * */ - username: string -} + username: z.string().meta({ + description: "Username of the user to scrobble for" + }), +}); + +export type KoitoData = z.infer; + +export const koitoClientDataSchema = koitoDataSchema.extend(commonClientDataSchema.shape); -export interface KoitoClientData extends KoitoData, CommonClientData {} +export type KoitoClientData = z.infer; -export interface KoitoClientConfig extends CommonClientConfig { +export const koitoClientConfigSchema = z.object({ + ...commonClientConfigSchema.shape, /** * Should always be `client` when using Koito as a client * * @default client * @examples ["client"] * */ - configureAs?: ComponentType - data: KoitoClientData -} + configureAs: componentTypeSchema.optional().meta({ + description: "Should always be `client` when using Koito as a client", + default: "client", + examples: ["client"] + }), + data: koitoClientDataSchema, +}); + +export type KoitoClientConfig = z.infer; + +export const koitoClientAIOConfigSchema = z.object({ + ...koitoClientConfigSchema.shape, + type: z.literal('koito'), +}); -export interface KoitoClientAIOConfig extends KoitoClientConfig { - type: 'koito' -} \ No newline at end of file +export type KoitoClientAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/client/lastfm.ts b/src/backend/common/infrastructure/config/client/lastfm.ts index 25c67e94..4fc4112d 100644 --- a/src/backend/common/infrastructure/config/client/lastfm.ts +++ b/src/backend/common/infrastructure/config/client/lastfm.ts @@ -1,48 +1,78 @@ -import type {RequestRetryOptions} from "../common.ts"; -import type {CommonClientConfig, CommonClientData, CommonClientOptions, NowPlayingOptions} from "./index.ts"; +import * as z from "zod"; +import {requestRetryOptionsSchema} from "../common.ts"; +import {commonClientConfigSchema, commonClientDataSchema, commonClientOptionsSchema, nowPlayingOptionsSchema} from "./index.ts"; -export interface LastfmData extends CommonClientData, RequestRetryOptions { +export const lastfmDataSchema = z.object({ + ...commonClientDataSchema.shape, + ...requestRetryOptionsSchema.shape, /** * API Key generated from Last.fm/Libre.fm account * * @examples ["787c921a2a2ab42320831aba0c8f2fc2"] * */ - apiKey: string + apiKey: z.string().meta({ + description: "API Key generated from Last.fm/Libre.fm account", + examples: ["787c921a2a2ab42320831aba0c8f2fc2"] + }), /** * Secret generated from Last.fm/Libre.fm account * * @examples ["ec42e09d5ae0ee0f0816ca151008412a"] * */ - secret: string + secret: z.string().meta({ + description: "Secret generated from Last.fm/Libre.fm account", + examples: ["ec42e09d5ae0ee0f0816ca151008412a"] + }), /** * Optional session id returned from a completed auth flow * */ - session?: string + session: z.string().optional().meta({ + description: "Optional session id returned from a completed auth flow" + }), /** * Optional URI to use for callback. Specify this if callback should be different than the default. MUST have "lastfm/callback" in the URL somewhere. * * @default "http://localhost:9078/lastfm/callback" * @examples ["http://localhost:9078/lastfm/callback"] * */ - redirectUri?: string -} + redirectUri: z.string().optional().meta({ + description: "Optional URI to use for callback.", + default: "http://localhost:9078/lastfm/callback", + examples: ["http://localhost:9078/lastfm/callback"] + }), +}); -export interface LastfmClientOptions extends CommonClientOptions, NowPlayingOptions { +export type LastfmData = z.infer; -} +export const lastfmClientOptionsSchema = z.object({ + ...commonClientOptionsSchema.shape, + ...nowPlayingOptionsSchema.shape, +}); -export interface LastfmClientConfig extends CommonClientConfig { +export type LastfmClientOptions = z.infer; + +export const lastfmClientConfigSchema = z.object({ + ...commonClientConfigSchema.shape, /** * Should always be `client` when using LastFM as a client * * @default client * @examples ["client"] * */ - configureAs?: 'client' | 'source' - data: LastfmData - options?: LastfmClientOptions -} - -export interface LastfmClientAIOConfig extends LastfmClientConfig { - type: 'lastfm' -} + configureAs: z.union([z.literal('client'), z.literal('source')]).optional().meta({ + description: "Should always be `client` when using LastFM as a client", + default: "client", + examples: ["client"] + }), + data: lastfmDataSchema, + options: lastfmClientOptionsSchema.optional(), +}); + +export type LastfmClientConfig = z.infer; + +export const lastfmClientAIOConfigSchema = z.object({ + ...lastfmClientConfigSchema.shape, + type: z.literal('lastfm'), +}); + +export type LastfmClientAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/client/librefm.ts b/src/backend/common/infrastructure/config/client/librefm.ts index 2353ded3..efc8243c 100644 --- a/src/backend/common/infrastructure/config/client/librefm.ts +++ b/src/backend/common/infrastructure/config/client/librefm.ts @@ -1,63 +1,95 @@ -import type {RequestRetryOptions} from "../common.ts"; -import type {CommonClientConfig, CommonClientData} from "./index.ts"; -import type {LastfmClientOptions} from "./lastfm.ts"; - -export interface LibrefmData extends CommonClientData, RequestRetryOptions { - /** - * Optional Secret for Libre.fm account - * - * This can be anything, or it will be generated by multi-scrobbler - * - * @examples ["anyValue"] - * */ - apiKey?: string - /** - * Optional Secret for Libre.fm account - * - * This can be anything, or it will be generated by multi-scrobbler - * - * @examples ["anyValue"] - * */ - secret?: string - - /** - * Optional session id returned from a completed auth flow - * */ - session?: string - - path?: string - - /** - * (Optional) The host and path prefix for your Libre.fm instance - * - * @default 'https://libre.fm/2.0/' - */ - urlBase?: string - /** - * Optional URI to use for callback. Specify this if callback should be different than the default. MUST have "librefm/callback" in the URL somewhere. - * - * @default "http://localhost:9078/librefm/callback" - * @examples ["http://localhost:9078/librefm/callback"] - * */ - redirectUri?: string -} - -export interface LibrefmClientOptions extends LastfmClientOptions { - -} - -export interface LibrefmClientConfig extends CommonClientConfig { +import * as z from "zod"; +import {requestRetryOptionsSchema} from "../common.ts"; +import {commonClientConfigSchema, commonClientDataSchema} from "./index.ts"; +import {lastfmClientOptionsSchema} from "./lastfm.ts"; + +export const librefmDataSchema = z.object({ + ...commonClientDataSchema.shape, + ...requestRetryOptionsSchema.shape, + /** + * Optional Secret for Libre.fm account + * + * This can be anything, or it will be generated by multi-scrobbler + * + * @examples ["anyValue"] + * */ + apiKey: z.string().optional().meta({ + description: "Optional Secret for Libre.fm account", + examples: ["anyValue"] + }), + /** + * Optional Secret for Libre.fm account + * + * This can be anything, or it will be generated by multi-scrobbler + * + * @examples ["anyValue"] + * */ + secret: z.string().optional().meta({ + description: "Optional Secret for Libre.fm account", + examples: ["anyValue"] + }), + + /** + * Optional session id returned from a completed auth flow + * */ + session: z.string().optional().meta({ + description: "Optional session id returned from a completed auth flow" + }), + + path: z.string().optional(), + + /** + * (Optional) The host and path prefix for your Libre.fm instance + * + * @default 'https://libre.fm/2.0/' + */ + urlBase: z.string().optional().meta({ + description: "(Optional) The host and path prefix for your Libre.fm instance", + default: "https://libre.fm/2.0/" + }), + /** + * Optional URI to use for callback. Specify this if callback should be different than the default. MUST have "librefm/callback" in the URL somewhere. + * + * @default "http://localhost:9078/librefm/callback" + * @examples ["http://localhost:9078/librefm/callback"] + * */ + redirectUri: z.string().optional().meta({ + description: "Optional URI to use for callback.", + default: "http://localhost:9078/librefm/callback", + examples: ["http://localhost:9078/librefm/callback"] + }), +}); + +export type LibrefmData = z.infer; + +export const librefmClientOptionsSchema = z.object({ + ...lastfmClientOptionsSchema.shape, +}); + +export type LibrefmClientOptions = z.infer; + +export const librefmClientConfigSchema = z.object({ + ...commonClientConfigSchema.shape, /** * Should always be `client` when using Librefm as a client * * @default client * @examples ["client"] * */ - configureAs?: 'client' | 'source' - data: LibrefmData - options?: LibrefmClientOptions -} - -export interface LibrefmClientAIOConfig extends LibrefmClientConfig { - type: 'librefm' -} \ No newline at end of file + configureAs: z.union([z.literal('client'), z.literal('source')]).optional().meta({ + description: "Should always be `client` when using Librefm as a client", + default: "client", + examples: ["client"] + }), + data: librefmDataSchema, + options: librefmClientOptionsSchema.optional(), +}); + +export type LibrefmClientConfig = z.infer; + +export const librefmClientAIOConfigSchema = z.object({ + ...librefmClientConfigSchema.shape, + type: z.literal('librefm'), +}); + +export type LibrefmClientAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/client/listenbrainz.ts b/src/backend/common/infrastructure/config/client/listenbrainz.ts index 802b2651..ba3f7fd2 100644 --- a/src/backend/common/infrastructure/config/client/listenbrainz.ts +++ b/src/backend/common/infrastructure/config/client/listenbrainz.ts @@ -1,49 +1,73 @@ -import type {ComponentType} from "../../../../../core/Atomic.ts"; -import type {RequestRetryOptions} from "../common.ts"; -import type {CommonClientConfig, CommonClientData} from "./index.ts"; +import * as z from "zod"; +import {componentTypeSchema} from "../../../../../core/Atomic.ts"; +import {requestRetryOptionsSchema} from "../common.ts"; +import {commonClientConfigSchema, commonClientDataSchema} from "./index.ts"; -export interface ListenBrainzData extends RequestRetryOptions{ +export const listenBrainzDataSchema = z.object({ + ...requestRetryOptionsSchema.shape, /** * URL for the ListenBrainz server, if not using the default * * @examples ["https://api.listenbrainz.org/"] * @default "https://api.listenbrainz.org/" * */ - url?: string + url: z.string().optional().meta({ + description: "URL for the ListenBrainz server, if not using the default", + default: "https://api.listenbrainz.org/", + examples: ["https://api.listenbrainz.org/"] + }), /** * User token for the user to scrobble for * * @examples ["6794186bf-1157-4de6-80e5-uvb411f3ea2b"] * */ - token: string + token: z.string().meta({ + description: "User token for the user to scrobble for", + examples: ["6794186bf-1157-4de6-80e5-uvb411f3ea2b"] + }), /** * Username of the user to scrobble for * */ - username: string -} + username: z.string().meta({ + description: "Username of the user to scrobble for" + }), +}); -export interface ListenBrainzClientData extends ListenBrainzData, CommonClientData {} +export type ListenBrainzData = z.infer; -export interface ListenBrainzClientConfig extends CommonClientConfig { +export const listenBrainzClientDataSchema = listenBrainzDataSchema.extend(commonClientDataSchema.shape); + +export type ListenBrainzClientData = z.infer; + +export const listenBrainzClientConfigSchema = z.object({ + ...commonClientConfigSchema.shape, /** * Should always be `client` when using Listenbrainz as a client * * @default client * @examples ["client"] * */ - configureAs?: ComponentType - data: ListenBrainzClientData -} + configureAs: componentTypeSchema.optional().meta({ + description: "Should always be `client` when using Listenbrainz as a client", + default: "client", + examples: ["client"] + }), + data: listenBrainzClientDataSchema, +}); + +export type ListenBrainzClientConfig = z.infer; -export interface ListenBrainzClientAIOConfig extends ListenBrainzClientConfig { - type: 'listenbrainz' -} +export const listenBrainzClientAIOConfigSchema = z.object({ + ...listenBrainzClientConfigSchema.shape, + type: z.literal('listenbrainz'), +}); +export type ListenBrainzClientAIOConfig = z.infer; /** https://github.com/metabrainz/listenbrainz-server/pull/2572 * https://github.com/metabrainz/listenbrainz-server/blob/master/listenbrainz/webserver/views/api_tools.py#L48 */ export const MAX_ITEMS_PER_GET_LZ = 1000; export const DEFAULT_ITEMS_PER_GET_LZ = 25; -export const DEFAULT_MS_ITEMS_PER_GET_LZ = 100; \ No newline at end of file +export const DEFAULT_MS_ITEMS_PER_GET_LZ = 100; diff --git a/src/backend/common/infrastructure/config/client/maloja.ts b/src/backend/common/infrastructure/config/client/maloja.ts index 18cbe6ed..b26aaf7f 100644 --- a/src/backend/common/infrastructure/config/client/maloja.ts +++ b/src/backend/common/infrastructure/config/client/maloja.ts @@ -1,37 +1,57 @@ -import type {ComponentType} from "../../../../../core/Atomic.ts"; -import type {RequestRetryOptions} from "../common.ts"; -import type {CommonClientConfig, CommonClientData} from "./index.ts"; +import * as z from "zod"; +import {componentTypeSchema} from "../../../../../core/Atomic.ts"; +import {requestRetryOptionsSchema} from "../common.ts"; +import {commonClientConfigSchema, commonClientDataSchema} from "./index.ts"; -export interface MalojaData extends RequestRetryOptions { +export const malojaDataSchema = z.object({ + ...requestRetryOptionsSchema.shape, /** * URL for maloja server * * @examples ["http://localhost:42010"] * */ - url: string + url: z.string().meta({ + description: "URL for maloja server", + examples: ["http://localhost:42010"] + }), /** * API Key for Maloja server * * @examples ["myApiKey"] * */ - apiKey: string -} + apiKey: z.string().meta({ + description: "API Key for Maloja server", + examples: ["myApiKey"] + }), +}); -export interface MalojaClientData extends MalojaData, CommonClientData { +export type MalojaData = z.infer; -} +export const malojaClientDataSchema = malojaDataSchema.extend(commonClientDataSchema.shape); -export interface MalojaClientConfig extends CommonClientConfig { +export type MalojaClientData = z.infer; + +export const malojaClientConfigSchema = z.object({ + ...commonClientConfigSchema.shape, /** * Should always be `client` when using Maloja as a client * * @default client * @examples ["client"] * */ - configureAs?: ComponentType - data: MalojaClientData -} + configureAs: componentTypeSchema.optional().meta({ + description: "Should always be `client` when using Maloja as a client", + default: "client", + examples: ["client"] + }), + data: malojaClientDataSchema, +}); + +export type MalojaClientConfig = z.infer; + +export const malojaClientAIOConfigSchema = z.object({ + ...malojaClientConfigSchema.shape, + type: z.literal('maloja'), +}); -export interface MalojaClientAIOConfig extends MalojaClientConfig { - type: 'maloja' -} +export type MalojaClientAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/client/rocksky.ts b/src/backend/common/infrastructure/config/client/rocksky.ts index 9fa76126..fd13bb99 100644 --- a/src/backend/common/infrastructure/config/client/rocksky.ts +++ b/src/backend/common/infrastructure/config/client/rocksky.ts @@ -1,43 +1,61 @@ -import type {RequestRetryOptions} from "../common.ts"; -import type {CommonClientConfig, CommonClientData, CommonClientOptions, NowPlayingOptions} from "./index.ts"; +import * as z from "zod"; +import {requestRetryOptionsSchema} from "../common.ts"; +import {commonClientConfigSchema, commonClientDataSchema, commonClientOptionsSchema, nowPlayingOptionsSchema} from "./index.ts"; -export interface RockSkyData extends RequestRetryOptions{ +export const rockSkyDataSchema = z.object({ + ...requestRetryOptionsSchema.shape, /** * API Key generated from [API Applications](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) in Rocksky for your account * * @examples ["6794186bf-1157-4de6-80e5-uvb411f3ea2b"] * */ - key?: string + key: z.string().optional().meta({ + description: "API Key generated from [API Applications](https://docs.rocksky.app/migrating-from-listenbrainz-to-rocksky-1040189m0) in Rocksky for your account", + examples: ["6794186bf-1157-4de6-80e5-uvb411f3ea2b"] + }), /** * Access Token generated from https://rocksky.app/access-tokens in Rocksky for your account * * @examples ["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkaWQ....."] * */ - token?: string + token: z.string().optional().meta({ + description: "Access Token generated from https://rocksky.app/access-tokens in Rocksky for your account", + examples: ["eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJkaWQ....."] + }), /** * The **fully-qualified** handle for your ATPRoto/Bluesky account, like: - * + * * * alice.bsky.social * * foxxmd.com * * mysuer.blacksky.app - * + * * */ - handle: string -} + handle: z.string().meta({ + description: "The **fully-qualified** handle for your ATPRoto/Bluesky account, like:" + }), +}); + +export type RockSkyData = z.infer; + +export const rockSkyClientDataSchema = rockSkyDataSchema.extend(commonClientDataSchema.shape); -export interface RockSkyClientData extends RockSkyData, CommonClientData {} +export type RockSkyClientData = z.infer; -export interface RockSkyOptions { +export const rockSkyOptionsSchema = z.object({ /** * URL for the Rocksky *Listenbrainz* endpoint, if not using the default * * @examples ["https://audioscrobbler.rocksky.app"] * @default "https://audioscrobbler.rocksky.app" * */ - audioScrobblerUrl?: string + audioScrobblerUrl: z.string().optional().meta({ + description: "URL for the Rocksky *Listenbrainz* endpoint, if not using the default", + default: "https://audioscrobbler.rocksky.app", + examples: ["https://audioscrobbler.rocksky.app"] + }), /** * URL for the Rocksky *API* endpoint, if not using the default @@ -45,25 +63,45 @@ export interface RockSkyOptions { * @examples ["https://api.rocksky.app"] * @default "https://api.rocksky.app" * */ - apiUrl?: string -} + apiUrl: z.string().optional().meta({ + description: "URL for the Rocksky *API* endpoint, if not using the default", + default: "https://api.rocksky.app", + examples: ["https://api.rocksky.app"] + }), +}); -export interface RockSkyClientOptions extends RockSkyOptions, CommonClientOptions, NowPlayingOptions { +export type RockSkyOptions = z.infer; -} +export const rockSkyClientOptionsSchema = z.object({ + ...rockSkyOptionsSchema.shape, + ...commonClientOptionsSchema.shape, + ...nowPlayingOptionsSchema.shape, +}); -export interface RockSkyClientConfig extends CommonClientConfig { +export type RockSkyClientOptions = z.infer; + +export const rockSkyClientConfigSchema = z.object({ + ...commonClientConfigSchema.shape, /** * Should always be `client` when using RockSky as a client * * @default client * @examples ["client"] * */ - configureAs?: 'client' | 'source' - data: RockSkyClientData - options?: RockSkyClientOptions -} - -export interface RockSkyClientAIOConfig extends RockSkyClientConfig { - type: 'rocksky' -} + configureAs: z.union([z.literal('client'), z.literal('source')]).optional().meta({ + description: "Should always be `client` when using RockSky as a client", + default: "client", + examples: ["client"] + }), + data: rockSkyClientDataSchema, + options: rockSkyClientOptionsSchema.optional(), +}); + +export type RockSkyClientConfig = z.infer; + +export const rockSkyClientAIOConfigSchema = z.object({ + ...rockSkyClientConfigSchema.shape, + type: z.literal('rocksky'), +}); + +export type RockSkyClientAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/client/tealfm.ts b/src/backend/common/infrastructure/config/client/tealfm.ts index 71faaaba..b637663a 100644 --- a/src/backend/common/infrastructure/config/client/tealfm.ts +++ b/src/backend/common/infrastructure/config/client/tealfm.ts @@ -1,43 +1,60 @@ -import type {ComponentType} from "../../../../../core/Atomic.ts"; -import type {RequestRetryOptions} from "../common.ts"; -import type {ATProtoAppData, ATProtoUserIdentifierData} from "./atproto.ts"; -import type {CommonClientConfig, CommonClientData, CommonClientOptions} from "./index.ts"; +import * as z from "zod"; +import {componentTypeSchema} from "../../../../../core/Atomic.ts"; +import {requestRetryOptionsSchema} from "../common.ts"; +import {atProtoAppDataSchema, atProtoUserIdentifierDataSchema} from "./atproto.ts"; +import {commonClientConfigSchema, commonClientDataSchema, commonClientOptionsSchema} from "./index.ts"; -export type TealData = RequestRetryOptions & ATProtoUserIdentifierData & Partial & { - /** +export const tealDataSchema = z.object({ + ...requestRetryOptionsSchema.shape, + ...atProtoUserIdentifierDataSchema.shape, + ...atProtoAppDataSchema.partial().shape, + /** * The base URI of the Multi-Scrobbler to use for ATProto OAuth - * + * * Only include this if you want to use OAuth. The URI must be a non-IP/non-local domain using https: protocol. */ - baseUri?: string -} + baseUri: z.string().optional().meta({ + description: "The base URI of the Multi-Scrobbler to use for ATProto OAuth" + }), +}); -export interface TealClientData extends TealData, CommonClientData { +export type TealData = z.infer; -} -export interface TealClientConfig extends CommonClientConfig { +export const tealClientDataSchema = tealDataSchema.extend(commonClientDataSchema.shape); + +export type TealClientData = z.infer; + +export const tealClientOptionsSchema = z.object({ + ...commonClientOptionsSchema.shape, +}); + +export type TealClientOptions = z.infer; + +export const tealClientConfigSchema = z.object({ + ...commonClientConfigSchema.shape, /** * Should always be `client` when using Tealfm as a client * * @default client * @examples ["client"] * */ - configureAs?: ComponentType - data: TealClientData - options?: TealClientOptions -} - -export interface TealOptions { -} + configureAs: componentTypeSchema.optional().meta({ + description: "Should always be `client` when using Tealfm as a client", + default: "client", + examples: ["client"] + }), + data: tealClientDataSchema, + options: tealClientOptionsSchema.optional(), +}); +export type TealClientConfig = z.infer; -export interface TealClientOptions extends TealOptions,CommonClientOptions { +export const tealClientAIOConfigSchema = z.object({ + ...tealClientConfigSchema.shape, + type: z.literal('tealfm'), +}); -} - -export interface TealClientAIOConfig extends TealClientConfig { - type: 'tealfm' -} +export type TealClientAIOConfig = z.infer; export interface ListRecord { uri: string; diff --git a/src/backend/common/infrastructure/config/common.ts b/src/backend/common/infrastructure/config/common.ts index fe6af15f..350afa34 100644 --- a/src/backend/common/infrastructure/config/common.ts +++ b/src/backend/common/infrastructure/config/common.ts @@ -1,48 +1,72 @@ -export interface CommonConfigPrimitives { - name?: string - id?: string - enable?: boolean -} +import * as z from "zod"; + +export const commonConfigPrimitivesSchema = z.object({ + name: z.string().optional(), + id: z.string().optional(), + enable: z.boolean().optional() +}); + +export type CommonConfigPrimitives = z.infer; + +export const commonDataSchema = z.record(z.string(), z.any()); // keyOmit<{ [key: string]: any }, "options"> -export interface CommonConfig { - name?: string +export type CommonData = z.infer; + +export const commonConfigSchema = z.object({ + name: z.string().optional(), /** A UNIQUE identifier for this Source/Client - * + * * It should be unique for the given Source/Client type. No other Source/Client of the same type should have this ID. This ID will be used to register this Source/Client in the database so that it can be identified even if you change the name of the component. - * + * * If no id is given the name of this component will be used. */ - id?: string - data?: CommonData + id: z.string().optional().meta({ + description: "A UNIQUE identifier for this Source/Client" + }), + data: commonDataSchema.optional(), /** * Should MS use this client/source? Defaults to true * * @default true * @examples [true] * */ - enable?: boolean -} + enable: z.boolean().optional().meta({ + description: "Should MS use this client/source?", + default: true, + examples: [true] + }) +}); -export type CommonData = Record; // keyOmit<{ [key: string]: any }, "options"> +export type CommonConfig = z.infer; -export interface RequestRetryOptions { +export const requestRetryOptionsSchema = z.object({ /** * default # of http request retries a source/client can make before error is thrown * * @default 1 * @examples [1] * */ - maxRequestRetries?: number + maxRequestRetries: z.number().optional().meta({ + description: "default # of http request retries a source/client can make before error is thrown", + default: 1, + examples: [1] + }), /** * default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying) * * @default 1.5 * @examples [1.5] * */ - retryMultiplier?: number -} + retryMultiplier: z.number().optional().meta({ + description: "default retry delay multiplier (retry attempt * multiplier = # of seconds to wait before retrying)", + default: 1.5, + examples: [1.5] + }) +}); -export interface PollingOptions { +export type RequestRetryOptions = z.infer; + +export const pollingOptionsSchema = z.object({ /** * How long to wait before polling the source API for new tracks (in seconds) @@ -50,7 +74,11 @@ export interface PollingOptions { * @default 10 * @examples [10] * */ - interval?: number + interval: z.number().optional().meta({ + description: "How long to wait before polling the source API for new tracks (in seconds)", + default: 10, + examples: [10] + }), /** * When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds) @@ -58,36 +86,45 @@ export interface PollingOptions { * @default 30 * @examples [30] * */ - maxInterval?: number + maxInterval: z.number().optional().meta({ + description: "When there has been no new activity from the Source API multi-scrobbler will gradually increase the wait time between polling up to this value (in seconds)", + default: 30, + examples: [30] + }), /** * Number of seconds after which A Player is considered Stale - * + * * When Polling the source does not recieve data about a specific Player after X seconds it becomes Stale. When the Player becomes Stale: - * + * * * The current listening session is ended. If the Player becomes active again a new listening session is started (Player will miss `interval` seconds of listening) * * If the player has an existing session w/ track then MS attempts to scrobble it - * + * * This option DOES NOT need to be set. It is automatically calculated as (`interval` * 3) when not defined. */ - staleAfter?: number + staleAfter: z.number().optional().meta({ + description: "Number of seconds after which A Player is considered Stale" + }), /** * Number of seconds after which A Player is considered Orphaned - * + * * When Polling the source does not recieve data about a specific Player after X seconds it becomes Orphaned. When the Player becomes Orphaned: - * + * * * The current Player session is ended and the Player is removed from MS * * MS attempts to scrobble, if the Player has an existing session w/ track - * + * * A Player should become Orphaned EQUAL TO OR AFTER it becomes Stale. - * + * * * This option DOES NOT need to be set. It is automatically calculated as (`interval` * 5) when not defined. * * If it is set it must be equal to or larger than `staleAfter` or (`interval * 3`) */ - orphanedAfter?: number -} + orphanedAfter: z.number().optional().meta({ + description: "Number of seconds after which A Player is considered Orphaned" + }) +}); +export type PollingOptions = z.infer; export interface MonitorOptions { /** * Set the default behavior for wether this component should automatically monitor any activity, or scrobble, it encounters diff --git a/src/backend/common/infrastructure/config/database.ts b/src/backend/common/infrastructure/config/database.ts index 01cf2808..d38787d6 100644 --- a/src/backend/common/infrastructure/config/database.ts +++ b/src/backend/common/infrastructure/config/database.ts @@ -1,37 +1,147 @@ +import * as z from "zod"; import type {Duration} from "dayjs/plugin/duration.js"; -import type {DurationValue} from "../Atomic.ts"; +import {durationValueSchema} from "../Atomic.ts"; + +export const retentionPlayTypeSchema = z.enum(['failed', 'completed', 'duped']); + +export type RetentionPlayType = z.infer; -export type RetentionPlayType = 'failed' | 'completed' | 'duped'; export const retentionPlayTypes: RetentionPlayType[] = ['failed','completed','duped']; -export type RetentionValueUnparsed = DurationValue | Duration | false; -export type RetentionValue = Duration | false; -export interface RententionGranular { - failed?: T - completed?: T - duped?: T -} +// `Duration` (from `dayjs/plugin/duration.js`) is a class instance with dozens of methods (`asSeconds`, +// `humanize`, `add`, `clone`, etc.), not a plain data shape - reconstructing its full interface as a zod +// object wouldn't provide any real validation value. This checks for a Duration-shaped object via one of its +// signature methods and relies on the imported type for full static typing. +const durationSchema = z.custom( + (val) => val !== null && typeof val === 'object' && typeof (val as Duration).asMilliseconds === 'function', + {message: 'Expected a dayjs Duration instance'} +); + +export const retentionValueUnparsedSchema = z.union([durationValueSchema, durationSchema, z.literal(false)]); + +export type RetentionValueUnparsed = z.infer; + +export const retentionValueSchema = z.union([durationSchema, z.literal(false)]); + +export type RetentionValue = z.infer; + +// `RententionGranular`, `RetentionConfigValue`, `RetentionOption`, and `RetentionConfig` were +// previously left as plain generics since zod can't represent a generic object schema the way a TS interface +// can. In practice though each is only ever instantiated with one of three known terms - `DurationValue`, +// `Duration`, and `RetentionValue` - so below builds one concrete schema per term actually valid for each +// family (per each type's original generic constraint) and unifies them with `z.union` into a single +// non-generic replacement. Call sites elsewhere now use the specific per-term type where the term is +// statically known. + +export const rententionGranularDurationValueSchema = z.object({ + failed: durationValueSchema.optional(), + completed: durationValueSchema.optional(), + duped: durationValueSchema.optional(), +}); +export type RententionGranularDurationValue = z.infer; + +export const rententionGranularDurationSchema = z.object({ + failed: durationSchema.optional(), + completed: durationSchema.optional(), + duped: durationSchema.optional(), +}); +export type RententionGranularDuration = z.infer; + +export const rententionGranularRetentionValueSchema = z.object({ + failed: retentionValueSchema.optional(), + completed: retentionValueSchema.optional(), + duped: retentionValueSchema.optional(), +}); +export type RententionGranularRetentionValue = z.infer; + +export const rententionGranularSchema = z.union([ + rententionGranularDurationValueSchema, + rententionGranularDurationSchema, + rententionGranularRetentionValueSchema, +]); +export type RententionGranular = z.infer; + +export const retentionConfigValueDurationValueSchema = z.union([durationValueSchema, rententionGranularDurationValueSchema]); +export type RetentionConfigValueDurationValue = z.infer; + +export const retentionConfigValueDurationSchema = z.union([durationSchema, rententionGranularDurationSchema]); +export type RetentionConfigValueDuration = z.infer; -export type RetentionConfigValue = T | RententionGranular; -export type RetentionOption = Required>; +export const retentionConfigValueRetentionValueSchema = z.union([retentionValueSchema, rententionGranularRetentionValueSchema]); +export type RetentionConfigValueRetentionValue = z.infer; + +export const retentionConfigValueSchema = z.union([ + retentionConfigValueDurationValueSchema, + retentionConfigValueDurationSchema, + retentionConfigValueRetentionValueSchema, +]); +export type RetentionConfigValue = z.infer; + +export const compactablePropertySchema = z.enum(['transform', 'input']); + +export type CompactableProperty = z.infer; -export type CompactableProperty = 'transform' | 'input'; export const COMPACTABLE = { transform: 'transform', input: 'input' } as const satisfies Record; + export const compactableProperties: CompactableProperty[] = [COMPACTABLE.transform, COMPACTABLE.input]; -export interface RetentionConfig { - deleteAfter?: RetentionConfigValue - compactAfter?: RetentionConfigValue - compact?: CompactableProperty[] -} - -export interface RetentionOptions { - deleteAfter: RetentionOption - compactAfter: RetentionOption - compact: CompactableProperty[] -} + +// `RetentionOption`'s original constraint (`T extends RetentionValue`) only ever admits `Duration` and +// `RetentionValue` itself - not `DurationValue` - so there are only two valid terms here. +export const retentionOptionDurationSchema = z.object({ + failed: durationSchema, + completed: durationSchema, + duped: durationSchema, +}); +export type RetentionOptionDuration = z.infer; + +export const retentionOptionRetentionValueSchema = z.object({ + failed: retentionValueSchema, + completed: retentionValueSchema, + duped: retentionValueSchema, +}); +export type RetentionOptionRetentionValue = z.infer; + +export const retentionOptionSchema = z.union([retentionOptionDurationSchema, retentionOptionRetentionValueSchema]); +export type RetentionOption = z.infer; + +export const retentionConfigDurationValueSchema = z.object({ + deleteAfter: retentionConfigValueDurationValueSchema.optional(), + compactAfter: retentionConfigValueDurationValueSchema.optional(), + compact: z.array(compactablePropertySchema).optional(), +}); +export type RetentionConfigDurationValue = z.infer; + +export const retentionConfigDurationSchema = z.object({ + deleteAfter: retentionConfigValueDurationSchema.optional(), + compactAfter: retentionConfigValueDurationSchema.optional(), + compact: z.array(compactablePropertySchema).optional(), +}); +export type RetentionConfigDuration = z.infer; + +export const retentionConfigRetentionValueSchema = z.object({ + deleteAfter: retentionConfigValueRetentionValueSchema.optional(), + compactAfter: retentionConfigValueRetentionValueSchema.optional(), + compact: z.array(compactablePropertySchema).optional(), +}); +export type RetentionConfigRetentionValue = z.infer; + +export const retentionConfigSchema = z.union([ + retentionConfigDurationValueSchema, + retentionConfigDurationSchema, + retentionConfigRetentionValueSchema, +]); +export type RetentionConfig = z.infer; + +export const retentionOptionsSchema = z.object({ + deleteAfter: retentionOptionDurationSchema, + compactAfter: retentionOptionRetentionValueSchema, + compact: z.array(compactablePropertySchema), +}); + +export type RetentionOptions = z.infer; export const DEFAULT_RETENTION_DELETE_AFTER = 604800; // 7 days -export const DEFAULT_RETENTION_COMPACT_AFTER = 259200; // 3 days \ No newline at end of file +export const DEFAULT_RETENTION_COMPACT_AFTER = 259200; // 3 days diff --git a/src/backend/common/infrastructure/config/source/azuracast.ts b/src/backend/common/infrastructure/config/source/azuracast.ts index 0abc45c1..299270fd 100644 --- a/src/backend/common/infrastructure/config/source/azuracast.ts +++ b/src/backend/common/infrastructure/config/source/azuracast.ts @@ -1,53 +1,66 @@ +import * as z from "zod"; import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; -export interface AzuraStationInfoResponse { - id: string - name: string - shortcode: string - is_public: boolean -} - -export interface AzuraListenersResponse { - total: number - unique: number - current: number -} - -export interface AzuraSongResponse { - id: string - text: string - artist: string - title: string - album: string - genre: string - isrc: string -} - -export interface AzuraNowPlayingResponse { - sh_id: number - played_at: number - duration: number - streamer: string - elapsed: number - remaining: number - song: AzuraSongResponse -} - -export interface AzuraLiveResponse { - is_live: boolean - streamer_name: string - broadcast_start: number | null -} - -export interface AzuraStationResponse { - is_online: boolean - station: AzuraStationInfoResponse - listeners: AzuraListenersResponse - now_playing: AzuraNowPlayingResponse -} - - -export interface AzuracastData extends CommonSourceData { +export const azuraStationInfoResponseSchema = z.object({ + id: z.string(), + name: z.string(), + shortcode: z.string(), + is_public: z.boolean() +}); + +export type AzuraStationInfoResponse = z.infer; + +export const azuraListenersResponseSchema = z.object({ + total: z.number(), + unique: z.number(), + current: z.number() +}); + +export type AzuraListenersResponse = z.infer; + +export const azuraSongResponseSchema = z.object({ + id: z.string(), + text: z.string(), + artist: z.string(), + title: z.string(), + album: z.string(), + genre: z.string(), + isrc: z.string() +}); + +export type AzuraSongResponse = z.infer; + +export const azuraNowPlayingResponseSchema = z.object({ + sh_id: z.number(), + played_at: z.number(), + duration: z.number(), + streamer: z.string(), + elapsed: z.number(), + remaining: z.number(), + song: azuraSongResponseSchema +}); + +export type AzuraNowPlayingResponse = z.infer; + +export const azuraLiveResponseSchema = z.object({ + is_live: z.boolean(), + streamer_name: z.string(), + broadcast_start: z.number().nullable() +}); + +export type AzuraLiveResponse = z.infer; + +export const azuraStationResponseSchema = z.object({ + is_online: z.boolean(), + station: azuraStationInfoResponseSchema, + listeners: azuraListenersResponseSchema, + now_playing: azuraNowPlayingResponseSchema +}); + +export type AzuraStationResponse = z.infer; + + +export const azuracastDataSchema = z.object({ /** * Base URL of the Azuracast instance * @@ -56,51 +69,76 @@ export interface AzuracastData extends CommonSourceData { * * @examples ["https://radio.mydomain.tld", "http://localhost:80"] * */ - url: string + url: z.string().meta({ + description: "Base URL of the Azuracast instance", + examples: ["https://radio.mydomain.tld", "http://localhost:80"] + }), /** * The specific station to monitor - * + * * Scrobbling will only occur if any of the monitor conditions are met AND the station is ONLINE. - * + * * To monitor multiple stations create a Source for each station. * * @examples ["my-station-1"] * */ - station: string + station: z.string().meta({ + description: "The specific station to monitor", + examples: ["my-station-1"] + }), /** * Only activate scrobble monitoring if station - * + * * * `true` => has any current listeners * * `number` => has EQUAL TO or MORE THAN X number of listeners - * + * */ - monitorWhenListeners?: boolean | number + monitorWhenListeners: z.union([z.boolean(), z.number()]).optional().meta({ + description: "Only activate scrobble monitoring if station" + }), /** * Only activate scrobble monitoring if station has a live DJ/Streamer - * + * * @default true */ - monitorWhenLive?: boolean + monitorWhenLive: z.boolean().optional().meta({ + description: "Only activate scrobble monitoring if station has a live DJ/Streamer", + default: true + }), /** * API Key used to access data about private streams * * https://www.azuracast.com/docs/developers/apis/#api-authentication * */ - apiKey?: string -} + apiKey: z.string().optional().meta({ + description: "API Key used to access data about private streams" + }) +}); + +export type AzuracastData = z.infer & CommonSourceData; + +// `AzuracastSourceoptions` has no properties of its own - it is purely a merge of `CommonSourceOptions` and +// `ManualListeningOptions`, neither of which has a zod schema yet (they live in ./index.ts and haven't been +// converted). There is nothing to validate here beyond those parent shapes, so this is a plain intersection type. +export const azuracastSourceoptionsSchema = z.object({}); + +export type AzuracastSourceoptions = z.infer & CommonSourceOptions & ManualListeningOptions; + +// `CommonSourceConfig` (./index.ts) doesn't have a zod schema yet, so only this interface's own `data` field +// is represented here; the parent's fields are restored via intersection on the exported type. +export const azuracastSourceConfigSchema = z.object({ + data: azuracastDataSchema +}); -export interface AzuracastSourceoptions extends CommonSourceOptions { - -} +export type AzuracastSourceConfig = z.infer & CommonSourceConfig; -export interface AzuracastSourceConfig extends CommonSourceConfig { - data: AzuracastData -} +export const azuracastSourceAIOConfigSchema = z.object({ + ...azuracastSourceConfigSchema.shape, + type: z.literal('azuracast') +}); -export interface AzuracastSourceAIOConfig extends AzuracastSourceConfig { - type: 'azuracast' -} +export type AzuracastSourceAIOConfig = z.infer & CommonSourceConfig; diff --git a/src/backend/common/infrastructure/config/source/index.ts b/src/backend/common/infrastructure/config/source/index.ts index f290368f..ba660415 100644 --- a/src/backend/common/infrastructure/config/source/index.ts +++ b/src/backend/common/infrastructure/config/source/index.ts @@ -1,21 +1,26 @@ -import type { FileLogOptions, LogLevel } from "@foxxmd/logging"; +import * as z from "zod"; +import {requestRetryOptionsSchema, commonConfigSchema, MonitorOptions} from "../common.ts"; +import {retentionConfigDurationValueSchema} from "../database.ts"; +import {playTransformOptionsSchema} from "../../../../../core/Transform.ts"; -import type { PlayTransformOptions } from "../../../../../core/Transform.ts"; -import type { CommonConfig, MonitorOptions, RequestRetryOptions } from "../common.ts"; -import type { RetentionConfig } from "../database.ts"; -import type { DurationValue } from "../../Atomic.ts"; - -export interface SourceRetryOptions extends RequestRetryOptions { +export const sourceRetryOptionsSchema = z.object({ + ...requestRetryOptionsSchema.shape, /** * default # of automatic polling restarts on error * * @default 5 * @examples [5] * */ - maxPollRetries?: number -} + maxPollRetries: z.number().optional().meta({ + description: "default # of automatic polling restarts on error", + default: 5, + examples: [5] + }), +}); + +export type SourceRetryOptions = z.infer; -export interface ScrobbleThresholds { +export const scrobbleThresholdsSchema = z.object({ /** * The number of seconds a track has been listened to before it should be considered scrobbled. * @@ -27,7 +32,11 @@ export interface ScrobbleThresholds { * @default 240 * @examples [240] * */ - duration?: number | null + duration: z.union([z.number(), z.null()]).optional().meta({ + description: "The number of seconds a track has been listened to before it should be considered scrobbled.", + default: 240, + examples: [240] + }), /** * The percentage (as an integer) of a track that should have been seen played before it should be scrobbled. Only used if the Source provides information about how long the track is. * @@ -41,10 +50,31 @@ export interface ScrobbleThresholds { * @default 50 * @examples [50] * */ - percent?: number | null -} - -export interface CommonSourceOptions extends SourceRetryOptions, MonitorOptions { + percent: z.union([z.number(), z.null()]).optional().meta({ + description: "The percentage (as an integer) of a track that should have been seen played before it should be scrobbled.", + default: 50, + examples: [50] + }), +}); + +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"]); + +// `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({ + 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(), + path: z.union([z.string(), z.custom<() => string>((val) => typeof val === 'function')]).optional(), + level: z.union([logLevelSchema, z.literal(false)]).optional(), +}); + +export const commonSourceOptionsSchema = z.object({ + ...sourceRetryOptionsSchema.shape, /** * * If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload) then setting this option to true will make MS log the payload JSON to DEBUG output * * If this source is POLLING then it will log the raw data for each unique track/response the first time it is seen @@ -52,7 +82,11 @@ export interface CommonSourceOptions extends SourceRetryOptions, MonitorOptions * @default false * @examples [false] * */ - logPayload?: boolean + logPayload: z.boolean().optional().meta({ + description: "If this source has INGRESS to MS (sends a payload, rather than MS GETTING requesting a payload) then setting this option to true will make MS log the payload JSON to DEBUG output", + default: false, + examples: [false] + }), /** * If this source has INGRESS to MS and has filters this determines how MS logs when a payload (event) fails a defined filter (IE users/servers/library filters) @@ -66,7 +100,11 @@ export interface CommonSourceOptions extends SourceRetryOptions, MonitorOptions * @default warn * @examples ["warn"] * */ - logFilterFailure?: false | 'debug' | 'warn' + logFilterFailure: z.union([z.literal(false), z.literal('debug'), z.literal('warn')]).optional().meta({ + description: "If this source has INGRESS to MS and has filters this determines how MS logs when a payload (event) fails a defined filter (IE users/servers/library filters)", + default: "warn", + examples: ["warn"] + }), /** * For Sources that track Player State (currently playing) this logs a simple player state/summary to DEBUG output @@ -74,14 +112,20 @@ export interface CommonSourceOptions extends SourceRetryOptions, MonitorOptions * @default false * @examples [false] * */ - logPlayerState?: boolean + logPlayerState: z.boolean().optional().meta({ + description: "For Sources that track Player State (currently playing) this logs a simple player state/summary to DEBUG output", + default: false, + examples: [false] + }), /** * **Exprimental:** Log to a separate file for this Source. - * + * * Useful for debugging long-running Sources */ - logToFile?: true | LogLevel | FileLogOptions + logToFile: z.union([z.literal(true), logLevelSchema, fileLogOptionsSchema]).optional().meta({ + description: "**Exprimental:** Log to a separate file for this Source." + }), /** * If this source @@ -94,12 +138,18 @@ export interface CommonSourceOptions extends SourceRetryOptions, MonitorOptions * @default true * @examples [true, false] * */ - scrobbleBacklog?: boolean + scrobbleBacklog: z.boolean().optional().meta({ + description: "If this source", + default: true, + examples: [true, false] + }), /** * Set thresholds for when multi-scrobbler should consider a tracked play to be "scrobbable". If both duration and percent are defined then if either condition is met the track is scrobbled. * */ - scrobbleThresholds?: ScrobbleThresholds + scrobbleThresholds: scrobbleThresholdsSchema.optional().meta({ + description: "Set thresholds for when multi-scrobbler should consider a tracked play to be \"scrobbable\"." + }), /** * The number of listens to fetch when scrobbling from backlog @@ -107,29 +157,61 @@ export interface CommonSourceOptions extends SourceRetryOptions, MonitorOptions * * Only applies if this source supports fetching a listen history * * If not specified it defaults to the maximum number of listens the source API supports * */ - scrobbleBacklogCount?: number + scrobbleBacklogCount: z.number().optional().meta({ + description: "The number of listens to fetch when scrobbling from backlog" + }), - playTransform?: PlayTransformOptions + playTransform: playTransformOptionsSchema.optional(), - retention?: RetentionConfig -} + retention: retentionConfigDurationValueSchema.optional(), +}); -export interface CommonSourceData { +export type CommonSourceOptions = z.infer; -} +export const manualListeningOptionsSchema = z.object({ + /** + * For Sources that support manual listening, should MS default to scrobbling when no user interaction has occurred? + * + * If not specified MS will use a Source's specific behavior, see Source's documentation. + */ + systemScrobble: z.boolean().optional().meta({ + description: "For Sources that support manual listening, should MS default to scrobbling when no user interaction has occurred?" + }), +}); + +export type ManualListeningOptions = z.infer; + +export const commonSourceDataSchema = z.looseObject({}); -export interface CommonSourceConfig extends CommonConfig { +// `z.infer` of an empty object schema (strict or loose) picks up a `never`/`unknown` index signature that a +// plain empty TS interface never had, which breaks the many `interface FooData extends CommonSourceData, ...` +// declarations elsewhere (some in the "extends" direction, some in the "assign a plain object" direction). +// The original `interface CommonSourceData {}` is structurally identical to `{}` itself, so the type is +// declared directly rather than derived from the schema for this one empty-shape case. +export type CommonSourceData = {}; + +export const commonSourceConfigSchema = z.object({ + ...commonConfigSchema.shape, /** * Unique identifier for this source. * */ - name?: string + name: z.string().optional().meta({ + description: "Unique identifier for this source." + }), /** * Restrict scrobbling tracks played from this source to Clients with names from this list. If list is empty is not present Source scrobbles to all configured Clients. * * @examples [["MyMalojaConfigName","MyLastFMConfigName"]] * */ - clients?: string[] - data?: CommonSourceData - options?: CommonSourceOptions -} - + clients: z.array(z.string()).optional().meta({ + description: "Restrict scrobbling tracks played from this source to Clients with names from this list.", + examples: [["MyMalojaConfigName","MyLastFMConfigName"]] + }), + data: commonSourceDataSchema.optional(), + options: commonSourceOptionsSchema.optional(), +}); + +// `data`'s type is overridden here for the same reason `CommonSourceData` is declared directly above rather +// than derived from `commonSourceDataSchema` - the schema-inferred type carries an index signature that the +// original empty `data?: CommonSourceData` field never had. +export type CommonSourceConfig = Omit, 'data'> & { data?: CommonSourceData }; diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index fcbca122..2faf06d0 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -4,13 +4,17 @@ import type { Merge, RequiredKeys, StrictOmit } from "ts-essentials"; import type {ErrorObject} from "serialize-error"; import type { FlowControlTerm, TransformHook } from "./Transform.ts"; import type {Changeset} from "json-diff-ts"; -import type {IParseBaseOptions} from 'qs'; +import type {IParseBaseOptions} from 'qs'; +import * as z from "zod"; -export type ComponentTypeClient = 'client'; +export const componentTypeClientSchema = z.literal('client'); +export type ComponentTypeClient = z.infer; export const COMPONENT_TYPE_CLIENT: ComponentTypeClient = 'client'; -export type ComponentTypeSource = 'source'; +export const componentTypeSourceSchema = z.literal('source'); +export type ComponentTypeSource = z.infer; export const COMPONENT_TYPE_SOURCE: ComponentTypeSource = 'source'; -export type ComponentType = ComponentTypeClient | ComponentTypeSource; +export const componentTypeSchema = z.union([componentTypeClientSchema, componentTypeSourceSchema]); +export type ComponentType = z.infer; export const COMPONENT_TYPES: ComponentType[] = [COMPONENT_TYPE_SOURCE, COMPONENT_TYPE_CLIENT]; export const isComponentTypeSource = (type: string): type is ComponentTypeSource => type === COMPONENT_TYPE_SOURCE; export const isComponentTypeClient = (type: string): type is ComponentTypeClient => type === COMPONENT_TYPE_CLIENT; diff --git a/src/core/Transform.ts b/src/core/Transform.ts index cf7c9c12..74a00748 100644 --- a/src/core/Transform.ts +++ b/src/core/Transform.ts @@ -1,18 +1,21 @@ +import * as z from "zod"; import type {SearchAndReplaceRegExp} from "@foxxmd/regex-buddy-core"; +import type { MarkRequired } from "ts-essentials"; -export interface ConditionalSearchAndReplaceRegExp extends SearchAndReplaceRegExp, Whennable { -} - -export type ConditionalSearchAndReplaceTerm = Exclude -export type SearchAndReplaceTerm = string | ConditionalSearchAndReplaceTerm; -export type ExternalMetadataTerm = boolean | undefined | Whennable; +// The following generic types are used elsewhere in the codebase with several different concrete type +// parameters (e.g. `PlayTransformHooks`, `PlayTransformStage`, +// `PlayTransformUserStage`). Zod schemas cannot be generic the way a +// TypeScript interface can, so these are intentionally left as plain types. `PlayTransformOptions` and +// `PlayTransformRules` below are instead built from concrete, hand-resolved zod schemas that represent one +// specific instantiation of this generic machinery. export type PlayTransformParts = Extract, Y> & Whennable; //export type PlayTransformUserParts = PlayTransformUserStage & { when?: WhenConditionsConfig }; //export type PlayTransformMetaParts = PlayTransformMetadataStage & { when?: WhenConditionsConfig }; export type PlayTransformPartsArray = PlayTransformParts[]; -/** Represents the weakly-defined user config. May be an array of parts or one parts object */ +/** Represents the weakly-defined user config. May be an array of parts or one parts object + */ export type PlayTransformPartsConfig = PlayTransformPartsArray | PlayTransformParts; export interface PlayTransformPartsAtomic { @@ -24,9 +27,6 @@ export interface PlayTransformPartsAtomic { meta?: T } -export type StageTypeMetadata = 'spotify' | 'musicbrainz' | 'native'; -export type StageTypeUser = 'user'; -export type StageType = StageTypeMetadata | StageTypeUser | string; export const STAGE_TYPES_USER: StageTypeUser[] = ['user']; export const STAGE_TYPES_METADATA: StageTypeMetadata[] = ['spotify','musicbrainz','native']; export const STAGE_TYPES: StageType[] = [...STAGE_TYPES_METADATA, ...STAGE_TYPES_USER]; @@ -38,59 +38,18 @@ export interface StageTyped { export interface NotStageTyped { //type?: never } - export type MaybeStageTyped = StageTyped | NotStageTyped; -export interface StageTypedConfig { - type: StageType -} - -export interface Whennable { - when?: WhenConditionsConfig -} - -export type FlowControlTerm = 'continue' | 'stop' -export const FLOW_CONTROL_TERM = { - continue: 'continue', - stop: 'stop' -} as const satisfies Record; - -export interface FlowControl { - onSuccess: FlowControlTerm - onFailure: FlowControlTerm - onSkip: FlowControlTerm - failureReturnPartial: boolean -} - -export interface StageConfig extends StageTypedConfig, Whennable, Partial { - name?: string - stageHash?: string -} - -export interface UntypedStageConfig extends Whennable, Partial { - name?: string -} - export interface AtomicStageConfig extends StageConfig, PlayTransformPartsAtomic {} export interface PlayTransformStageTyped extends PlayTransformPartsAtomic { type: StageType } -export interface PlayTransformMetadataStage extends StageConfig, PlayTransformPartsAtomic { - score?: number -// all?: ExternalMetadataTerm - type: StageTypeMetadata -} - export interface PlayTransformUserStage extends StageConfig, PlayTransformPartsAtomic { type: StageTypeUser } -export interface PlayTransformNativeStage extends StageConfig, PlayTransformPartsAtomic { - type: 'native' -} - export interface PlayTransformGenericStage extends StageConfig, PlayTransformPartsAtomic { type: string } @@ -101,7 +60,8 @@ export interface UntypedPlayTransformUserStage extends UntypedStageConfig, Pl export type PlayTransformStage = PlayTransformMetadataStage | PlayTransformUserStage | PlayTransformNativeStage | UntypedPlayTransformUserStage | PlayTransformGenericStage; -/** Represents the plain json user-configured structure (input) */ +/** Represents the plain json user-configured structure (input) + */ export interface PlayTransformHooksConfig { preCompare?: PlayTransformPartsConfig compare?: { @@ -111,7 +71,8 @@ export interface PlayTransformHooksConfig { postCompare?: PlayTransformPartsConfig } -/** Represents the final, strongly-typed transform configuration used during runtime */ +/** Represents the final, strongly-typed transform configuration used during runtime + */ export interface PlayTransformHooks extends PlayTransformHooksConfig { preCompare?: PlayTransformPartsArray compare?: { @@ -121,7 +82,6 @@ export interface PlayTransformHooks extends PlayTransformHooksConfig { postCompare?: PlayTransformPartsArray } -export type PlayTransformRules = PlayTransformHooks export type TransformHook = 'preCompare' | 'compare' | 'candidate' | 'existing' | 'postCompare'; export const TRANSFORM_HOOK = { preCompare: 'preCompare' as TransformHook, @@ -129,9 +89,268 @@ export const TRANSFORM_HOOK = { existing: 'existing' as TransformHook, postCompare: 'postCompare' as TransformHook, } -export type PlayTransformConfig = PlayTransformHooksConfig; -export type PlayTransformOptions = PlayTransformConfig & { log?: boolean | 'all' } export type WhenParts = PlayTransformPartsAtomic; export type WhenConditions = WhenParts[]; -export type WhenConditionsConfig = WhenConditions; \ No newline at end of file + +// -------------------------------------------------------------------------------------------------------- +// Concrete zod schemas +// +// Everything below backs the non-generic types that the generic scaffolding above is built from/into. +// `PlayTransformOptions` (raw user JSON) and `PlayTransformRules` (the strongly-typed runtime result) are +// each a specific instantiation of `PlayTransformHooksConfig` / `PlayTransformHooks`. Rather than try +// to make those interfaces themselves generic in zod, this section hand-resolves the two concrete `T`s that +// matter (the user-facing term shape vs. the normalized rule-term shape) and builds each stage variant once +// per instantiation. +// -------------------------------------------------------------------------------------------------------- + +// Helper used to construct a concrete `PlayTransformPartsAtomic` schema for a given term schema, since +// `PlayTransformPartsAtomic` itself can't be represented generically in zod. +function buildPartsAtomicSchema(term: T) { + return z.object({ + title: term.optional(), + artists: term.optional(), + albumArtists: term.optional(), + album: term.optional(), + duration: term.optional(), + meta: term.optional(), + }); +} + +const whenPartsStringSchema = buildPartsAtomicSchema(z.string()); + +export const whenConditionsConfigSchema = z.array(whenPartsStringSchema); + +export type WhenConditionsConfig = z.infer; + +export const whennableSchema = z.object({ + when: whenConditionsConfigSchema.optional() +}); + +export type Whennable = z.infer; + +// `SearchAndReplaceRegExp` (from `@foxxmd/regex-buddy-core`) declares `test?: (obj: SearchAndReplaceRegExp) => boolean`. +// Zod can only structurally confirm this is a function, not validate its signature/behavior, so `z.custom` is +// used as a best-effort check. +export const conditionalSearchAndReplaceRegExpSchema = z.object({ + ...whennableSchema.shape, + search: z.xor([z.string(), z.instanceof(RegExp)]), + replace: z.string(), + test: z.custom<(obj: SearchAndReplaceRegExp) => boolean>((val) => typeof val === 'function').optional(), +}); + +// need to set tsconfig compiler option "strictNullChecks": true to make the inferred type not have search as optional +// but it causes too many errors at the moment +// so workaround by explicitly marking it as required for this type +// https://stackoverflow.com/a/77256318 +export type ConditionalSearchAndReplaceRegExp = MarkRequired, 'search'>; + +// `Exclude` is a no-op in the original type: `Exclude` only +// removes union members, and `ConditionalSearchAndReplaceRegExp` is an object type, not a union containing +// the literal `'test'`. So this type is identical to `ConditionalSearchAndReplaceRegExp`. +const { + test, + ...restConditionalRegSchema +} = conditionalSearchAndReplaceRegExpSchema.shape; +export const conditionalSearchAndReplaceTermSchema = z.object(restConditionalRegSchema); + +export type ConditionalSearchAndReplaceTerm = z.infer; + +export const searchAndReplaceTermSchema = z.union([z.string(), conditionalSearchAndReplaceTermSchema]); + +export type SearchAndReplaceTerm = z.infer; + +export const externalMetadataTermSchema = z.union([z.boolean(), z.undefined(), whennableSchema]); + +export type ExternalMetadataTerm = z.infer; + +export const flowControlTermSchema = z.enum(['continue', 'stop']); + +export type FlowControlTerm = z.infer; +export const FLOW_CONTROL_TERM = { + continue: 'continue', + stop: 'stop' +} as const satisfies Record; + +export const flowControlSchema = z.object({ + onSuccess: flowControlTermSchema, + onFailure: flowControlTermSchema, + onSkip: flowControlTermSchema, + failureReturnPartial: z.boolean(), +}); + +export type FlowControl = z.infer; + +export const stageTypeMetadataSchema = z.enum(['spotify', 'musicbrainz', 'native']); + +export type StageTypeMetadata = z.infer; + +export const stageTypeUserSchema = z.literal('user'); + +export type StageTypeUser = z.infer; + +// `StageTypeMetadata | StageTypeUser | string` collapses to `string` (the literal members are absorbed by +// the wider `string` member), so the schema is just `z.string()`. +export const stageTypeSchema = z.string(); + +export type StageType = z.infer; + +export const stageTypedConfigSchema = z.object({ + type: stageTypeSchema, +}); + +export type StageTypedConfig = z.infer; + +export const stageConfigSchema = z.object({ + ...stageTypedConfigSchema.shape, + ...whennableSchema.shape, + ...flowControlSchema.partial().shape, + name: z.string().optional(), + stageHash: z.string().optional(), +}); + +export type StageConfig = z.infer; + +export const untypedStageConfigSchema = z.object({ + ...whennableSchema.shape, + ...flowControlSchema.partial().shape, + name: z.string().optional(), +}); + +export type UntypedStageConfig = z.infer; + +const metadataAtomicSchema = buildPartsAtomicSchema(externalMetadataTermSchema); + +export const playTransformMetadataStageSchema = z.object({ + ...stageConfigSchema.shape, + ...metadataAtomicSchema.shape, + score: z.number().optional(), + type: stageTypeMetadataSchema, +}); + +export type PlayTransformMetadataStage = z.infer; + +export const playTransformNativeStageSchema = z.object({ + ...stageConfigSchema.shape, + ...metadataAtomicSchema.shape, + type: z.literal('native'), +}); + +export type PlayTransformNativeStage = z.infer; + +// `type: any` stage, shared as-is between the Options and Rules pools below (it doesn't depend on the outer T). +const anyAtomicSchema = buildPartsAtomicSchema(z.any()); +const playTransformGenericStageSchema = z.object({ + ...stageConfigSchema.shape, + ...anyAtomicSchema.shape, + type: stageTypeSchema, +}); + +// `PlayTransformOptions` term shape: T = SearchAndReplaceTerm[] | ExternalMetadataTerm +const optionsPartsTermSchema = z.union([z.array(searchAndReplaceTermSchema), externalMetadataTermSchema]); +const optionsAtomicSchema = buildPartsAtomicSchema(optionsPartsTermSchema); + +const playTransformUserStageOptionsSchema = z.object({ + ...stageConfigSchema.shape, + ...optionsAtomicSchema.shape, + type: stageTypeUserSchema, +}); + +const untypedPlayTransformUserStageOptionsSchema = z.object({ + ...untypedStageConfigSchema.shape, + ...optionsAtomicSchema.shape, +}); + +// `PlayTransformRules` term shape: T = ConditionalSearchAndReplaceRegExp[] | ExternalMetadataTerm +const rulesPartsTermSchema = z.union([z.array(conditionalSearchAndReplaceRegExpSchema), externalMetadataTermSchema]); +const rulesAtomicSchema = buildPartsAtomicSchema(rulesPartsTermSchema); + +const playTransformUserStageRulesSchema = z.object({ + ...stageConfigSchema.shape, + ...rulesAtomicSchema.shape, + type: stageTypeUserSchema, +}); + +// zod's `discriminatedUnion` requires each branch's discriminant literal(s) to be unique across the whole +// union. `StageTypeMetadata` nominally includes `'native'`, but `'native'`-typed stages are represented by +// the dedicated, stricter `playTransformNativeStageSchema` (no `score` field) instead. This narrows the +// metadata branch to `'spotify' | 'musicbrainz'` only for the purposes of this union - the overall set of +// `type` values covered across the whole union is unchanged. +const metadataStageForUnionSchema = playTransformMetadataStageSchema.extend({ + type: z.enum(['spotify', 'musicbrainz']), +}); + +// `PlayTransformParts = Extract, Y> & Whennable` - the `& Whennable` intersection +// is redundant here since every stage schema already includes `when` via `stageConfigSchema`/`untypedStageConfigSchema`. + +// Options pool: `Extract, MaybeStageTyped>` doesn't filter anything out, since every +// member of `PlayTransformStage` already structurally satisfies `StageTyped | NotStageTyped`. +const playTransformTypedStageOptionsSchema = z.discriminatedUnion('type', [ + metadataStageForUnionSchema, + playTransformNativeStageSchema, + playTransformUserStageOptionsSchema, +]); +const playTransformStageOptionsSchema = z.union([ + playTransformTypedStageOptionsSchema, + playTransformGenericStageSchema, + untypedPlayTransformUserStageOptionsSchema, +]); + +// Rules pool: `Extract, StageTyped>` excludes `UntypedPlayTransformUserStage`, +// since it has no `type` field and so isn't assignable to `StageTyped`. +const playTransformTypedStageRulesSchema = z.discriminatedUnion('type', [ + metadataStageForUnionSchema, + playTransformNativeStageSchema, + playTransformUserStageRulesSchema, +]); +const playTransformStageRulesSchema = z.union([ + playTransformTypedStageRulesSchema, + playTransformGenericStageSchema, +]); + +const playTransformPartsConfigOptionsSchema = z.union([ + z.array(playTransformStageOptionsSchema), + playTransformStageOptionsSchema, +]); + +export const playTransformConfigSchema = z.object({ + preCompare: playTransformPartsConfigOptionsSchema.optional(), + compare: z.object({ + candidate: playTransformPartsConfigOptionsSchema.optional(), + existing: playTransformPartsConfigOptionsSchema.optional(), + }).optional(), + postCompare: playTransformPartsConfigOptionsSchema.optional(), +}); + +export type PlayTransformConfig = z.infer; + +/** Represents the plain json user-configured structure (input). Used to validate user input (json). */ +export const playTransformOptionsSchema = z.object({ + ...playTransformConfigSchema.shape, + log: z.union([z.boolean(), z.literal('all')]).optional(), +}); + +export type PlayTransformOptions = z.infer; + +const playTransformPartsArrayRulesSchema = z.array(playTransformStageRulesSchema); + +/** Represents the final, strongly-typed transform configuration used during runtime. */ +export const playTransformRulesSchema = z.object({ + preCompare: playTransformPartsArrayRulesSchema.optional(), + compare: z.object({ + candidate: playTransformPartsArrayRulesSchema.optional(), + existing: playTransformPartsArrayRulesSchema.optional(), + }).optional(), + postCompare: playTransformPartsArrayRulesSchema.optional(), +}); + +export type PlayTransformRules = z.infer; + +// Converting raw user JSON (`PlayTransformOptions`) into the normalized runtime shape (`PlayTransformRules`) +// requires real business logic - assigning default `type`s to untyped user stages, normalizing the +// single-object-or-array shorthand into arrays, and resolving bare string search/replace shorthand into full +// `ConditionalSearchAndReplaceRegExp` objects. That logic already lives in +// `AbstractComponent.transformPartToStrong` and is out of scope here, so this transform is stubbed only. +export const playTransformOptionsToRulesSchema = playTransformOptionsSchema.transform((val): PlayTransformRules => { + throw new Error('Not implemented: use AbstractComponent.transformPartToStrong for PlayTransformOptions -> PlayTransformRules normalization'); +}); diff --git a/ts-zod.ts b/ts-zod.ts new file mode 100644 index 00000000..a8ca2f92 --- /dev/null +++ b/ts-zod.ts @@ -0,0 +1,12 @@ +import {generate} from 'ts-to-zod'; +import * as fs from 'node:fs'; + +const args = process.argv; +const path = args[2]; + +const res = generate({ + sourceText: fs.readFileSync(path).toString(), + jsDocTagFilter: (tags) => tags.map((tag) => tag.name).includes("zod"), +}); + +fs.writeFileSync(path.replace('.ts','.zod.ts'), res.getZodSchemasFile(path)); \ No newline at end of file -- 2.51.2