From 8f33dc9f28d19198d8ca15ac3be319848caca82c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 24 Jul 2026 21:29:00 -0400 Subject: [PATCH] POC scrobble source validation at runtime --- .../infrastructure/config/source/sources.ts | 130 ++++++++++++++---- src/backend/sources/ScrobbleSources.ts | 15 +- src/backend/tests/config/config.test.ts | 27 +++- 3 files changed, 137 insertions(+), 35 deletions(-) diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index ebc98a93..85185856 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -1,34 +1,36 @@ import * as z from "zod"; -import {azuracastSourceAIOConfigSchema, azuracastSourceConfigSchema} from "./azuracast.ts"; -import {chromecastSourceAIOConfigSchema, chromecastSourceConfigSchema} from "./chromecast.ts"; -import {listenbrainzEndpointSourceAIOConfigSchema, listenbrainzEndpointSourceConfigSchema} from "./endpointlz.ts"; -import {lastFmEndpointSourceAIOConfigSchema, lastFmEndpointSourceConfigSchema} from "./endpointlfm.ts"; -import {deezerInternalSourceConfigSchema, deezerSourceConfigSchema, deezerCompatConfigSchema, deezerAIOCompatConfigSchema} from "./deezer.ts"; -import {jellyApiSourceAIOConfigSchema, jellyApiSourceConfigSchema} from "./jellyfin.ts"; -import {jRiverSourceAIOConfigSchema, jRiverSourceConfigSchema} from "./jriver.ts"; -import {kodiSourceAIOConfigSchema, kodiSourceConfigSchema} from "./kodi.ts"; -import {lastFmSouceAIOConfigSchema, lastfmSourceConfigSchema} from "./lastfm.ts"; -import {listenBrainzSourceAIOConfigSchema, listenBrainzSourceConfigSchema} from "./listenbrainz.ts"; -import {mopidySourceAIOConfigSchema, mopidySourceConfigSchema} from "./mopidy.ts"; -import {mpdSourceAIOConfigSchema, mpdSourceConfigSchema} from "./mpd.ts"; -import {mprisSourceAIOConfigSchema, mprisSourceConfigSchema} from "./mpris.ts"; -import {musikcubeSourceAIOConfigSchema, musikcubeSourceConfigSchema} from "./musikcube.ts"; -import {musicCastSourceConfigSchema, musicCastSourceAIOConfigSchema} from "./musiccast.ts"; -import {plexApiSourceConfigSchema, plexApiSourceAIOConfigSchema} from "./plex.ts"; -import {spotifySourceAIOConfigSchema, spotifySourceConfigSchema} from "./spotify.ts"; -import {subsonicSourceAIOConfigSchema, subSonicSourceConfigSchema} from "./subsonic.ts"; -import {vlcSourceAIOConfigSchema, vlcSourceConfigSchema} from "./vlc.ts"; -import {webScrobblerSourceAIOConfigSchema, webScrobblerSourceConfigSchema} from "./webscrobbler.ts"; -import {ytMusicSourceAIOConfigSchema, ytMusicSourceConfigSchema} from "./ytmusic.ts"; -import {yandexMusicBridgeSourceAIOConfigSchema, yandexMusicBridgeSourceConfigSchema} from "./ymbridge.ts"; -import {icecastSourceAIOConfigSchema, icecastSourceConfigSchema} from "./icecast.ts"; -import {koitoSourceAIOConfigSchema, koitoSourceConfigSchema} from "./koito.ts"; -import {malojaSourceAIOConfigSchema, malojaSourceConfigSchema} from "./maloja.ts"; -import {tealSourceAIOConfigSchema, tealSourceConfigSchema} from "./tealfm.ts"; -import {rockskySourceAIOConfigSchema, rockskySourceConfigSchema} from "./rocksky.ts"; -import {librefmSouceAIOConfigSchema, librefmSourceConfigSchema} from "./librefm.ts"; -import {sonosSourceAIOConfigSchema, sonosSourceConfigSchema} from "./sonos.ts"; -import {appleMusicSourceAIOConfigSchema, appleMusicSourceConfigSchema} from "./applemusic.ts"; +import {azuracastSourceAIOConfigSchema, azuracastSourceConfigSchema, type AzuracastSourceConfig} from "./azuracast.ts"; +import {chromecastSourceAIOConfigSchema, chromecastSourceConfigSchema, type ChromecastSourceConfig} from "./chromecast.ts"; +import {listenbrainzEndpointSourceAIOConfigSchema, listenbrainzEndpointSourceConfigSchema, type ListenbrainzEndpointSourceConfig} from "./endpointlz.ts"; +import {lastFmEndpointSourceAIOConfigSchema, lastFmEndpointSourceConfigSchema, type LastFMEndpointSourceConfig} from "./endpointlfm.ts"; +import {deezerInternalSourceConfigSchema, deezerSourceConfigSchema, deezerCompatConfigSchema, deezerAIOCompatConfigSchema, type DeezerCompatConfig} from "./deezer.ts"; +import {jellyApiSourceAIOConfigSchema, jellyApiSourceConfigSchema, type JellyApiSourceConfig} from "./jellyfin.ts"; +import {jRiverSourceAIOConfigSchema, jRiverSourceConfigSchema, type JRiverSourceConfig} from "./jriver.ts"; +import {kodiSourceAIOConfigSchema, kodiSourceConfigSchema, type KodiSourceConfig} from "./kodi.ts"; +import {lastFmSouceAIOConfigSchema, lastfmSourceConfigSchema, type LastfmSourceConfig} from "./lastfm.ts"; +import {listenBrainzSourceAIOConfigSchema, listenBrainzSourceConfigSchema, type ListenBrainzSourceConfig} from "./listenbrainz.ts"; +import {mopidySourceAIOConfigSchema, mopidySourceConfigSchema, type MopidySourceConfig} from "./mopidy.ts"; +import {mpdSourceAIOConfigSchema, mpdSourceConfigSchema, type MPDSourceConfig} from "./mpd.ts"; +import {mprisSourceAIOConfigSchema, mprisSourceConfigSchema, type MPRISSourceConfig} from "./mpris.ts"; +import {musikcubeSourceAIOConfigSchema, musikcubeSourceConfigSchema, type MusikcubeSourceConfig} from "./musikcube.ts"; +import {musicCastSourceConfigSchema, musicCastSourceAIOConfigSchema, type MusicCastSourceConfig} from "./musiccast.ts"; +import {plexApiSourceConfigSchema, plexApiSourceAIOConfigSchema, type PlexApiSourceConfig} from "./plex.ts"; +import {spotifySourceAIOConfigSchema, spotifySourceConfigSchema, type SpotifySourceConfig} from "./spotify.ts"; +import {subsonicSourceAIOConfigSchema, subSonicSourceConfigSchema, type SubSonicSourceConfig} from "./subsonic.ts"; +import {vlcSourceAIOConfigSchema, vlcSourceConfigSchema, type VLCSourceConfig} from "./vlc.ts"; +import {webScrobblerSourceAIOConfigSchema, webScrobblerSourceConfigSchema, type WebScrobblerSourceConfig} from "./webscrobbler.ts"; +import {ytMusicSourceAIOConfigSchema, ytMusicSourceConfigSchema, type YTMusicSourceConfig} from "./ytmusic.ts"; +import {yandexMusicBridgeSourceAIOConfigSchema, yandexMusicBridgeSourceConfigSchema, type YandexMusicBridgeSourceConfig} from "./ymbridge.ts"; +import {icecastSourceAIOConfigSchema, icecastSourceConfigSchema, type IcecastSourceConfig} from "./icecast.ts"; +import {koitoSourceAIOConfigSchema, koitoSourceConfigSchema, type KoitoSourceConfig} from "./koito.ts"; +import {malojaSourceAIOConfigSchema, malojaSourceConfigSchema, type MalojaSourceConfig} from "./maloja.ts"; +import {tealSourceAIOConfigSchema, tealSourceConfigSchema, type TealSourceConfig} from "./tealfm.ts"; +import {rockskySourceAIOConfigSchema, rockskySourceConfigSchema, type RockskySourceConfig} from "./rocksky.ts"; +import {librefmSouceAIOConfigSchema, librefmSourceConfigSchema, type LibrefmSourceConfig} from "./librefm.ts"; +import {sonosSourceAIOConfigSchema, sonosSourceConfigSchema, type SonosSourceConfig} from "./sonos.ts"; +import {appleMusicSourceAIOConfigSchema, appleMusicSourceConfigSchema, type AppleMusicSourceConfig} from "./applemusic.ts"; +import type { SourceType } from "../../../../../core/Atomic.ts"; +import type { CommonSourceConfig } from "./index.ts"; export const sourceConfigSchema = z.union([ spotifySourceConfigSchema, @@ -267,6 +269,74 @@ export const atomicSourceInterfaces = [ 'AppleMusicSourceConfig' ]; +export interface SourceTypeConfigMap extends Record { + spotify: SpotifySourceConfig, + plex: PlexApiSourceConfig, + deezer: DeezerCompatConfig, + endpointlz: ListenbrainzEndpointSourceConfig, + endpointlfm: LastFMEndpointSourceConfig, + icecast: IcecastSourceConfig, + subsonic: SubSonicSourceConfig, + jellyfin: JellyApiSourceConfig, + lastfm: LastfmSourceConfig, + librefm: LibrefmSourceConfig, + ytmusic: YTMusicSourceConfig, + ymbridge: YandexMusicBridgeSourceConfig, + maloja: MalojaSourceConfig, + mpris: MPRISSourceConfig, + mopidy: MopidySourceConfig, + listenbrainz: ListenBrainzSourceConfig, + jriver: JRiverSourceConfig, + kodi: KodiSourceConfig, + chromecast: ChromecastSourceConfig, + webscrobbler: WebScrobblerSourceConfig, + musikcube: MusikcubeSourceConfig, + musiccast: MusicCastSourceConfig, + mpd: MPDSourceConfig, + vlc: VLCSourceConfig, + azuracast: AzuracastSourceConfig, + koito: KoitoSourceConfig, + tealfm: TealSourceConfig, + rocksky: RockskySourceConfig, + sonos: SonosSourceConfig, + applemusic: AppleMusicSourceConfig +} + +const sourceConfigSchemaMap: { [K in keyof SourceTypeConfigMap]: z.ZodType } = { + spotify: spotifySourceConfigSchema, + plex: plexApiSourceConfigSchema, + deezer: deezerCompatConfigSchema, + endpointlz: listenbrainzEndpointSourceConfigSchema, + endpointlfm: lastFmEndpointSourceConfigSchema, + icecast: icecastSourceConfigSchema, + subsonic: subSonicSourceConfigSchema, + jellyfin: jellyApiSourceConfigSchema, + lastfm: lastfmSourceConfigSchema, + librefm: librefmSourceConfigSchema, + ytmusic: ytMusicSourceConfigSchema, + ymbridge: yandexMusicBridgeSourceConfigSchema, + maloja: malojaSourceConfigSchema, + mpris: mprisSourceConfigSchema, + mopidy: mopidySourceConfigSchema, + listenbrainz: listenBrainzSourceConfigSchema, + jriver: jRiverSourceConfigSchema, + kodi: kodiSourceConfigSchema, + chromecast: chromecastSourceConfigSchema, + webscrobbler: webScrobblerSourceConfigSchema, + musikcube: musikcubeSourceConfigSchema, + musiccast: musicCastSourceConfigSchema, + mpd: mpdSourceConfigSchema, + vlc: vlcSourceConfigSchema, + azuracast: azuracastSourceConfigSchema, + koito: koitoSourceConfigSchema, + tealfm: tealSourceConfigSchema, + rocksky: rockskySourceConfigSchema, + sonos: sonosSourceConfigSchema, + applemusic: appleMusicSourceConfigSchema +}; + +export const validateSourceJson = (sourceType: T, json: object): SourceTypeConfigMap[T] => sourceConfigSchemaMap[sourceType].parse(json); + export const sourceInterfaces = [ 'AIOSourceRelaxedConfig', ...atomicSourceInterfaces diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 932a75eb..58dc8d43 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -24,7 +24,7 @@ import type {MPRISData, MPRISSourceConfig} from "../common/infrastructure/config import type {MusikcubeData, MusikcubeSourceConfig} from "../common/infrastructure/config/source/musikcube.ts"; import type {PlexApiData, PlexApiSourceConfig} from "../common/infrastructure/config/source/plex.ts"; import type {MalojaSourceConfig} from "../common/infrastructure/config/source/maloja.ts"; -import type {SourceAIOConfig, SourceConfig} from "../common/infrastructure/config/source/sources.ts"; +import {validateSourceJson, type SourceAIOConfig, type SourceConfig} from "../common/infrastructure/config/source/sources.ts"; import type {SpotifySourceConfig, SpotifySourceData} from "../common/infrastructure/config/source/spotify.ts"; import type {SubsonicData, SubSonicSourceConfig} from "../common/infrastructure/config/source/subsonic.ts"; import type {VLCData, VLCSourceConfig} from "../common/infrastructure/config/source/vlc.ts"; @@ -53,6 +53,7 @@ import type {ListenBrainzData} from '../common/infrastructure/config/client/list import type {KoitoData} from '../common/infrastructure/config/client/koito.ts'; import type {TealData} from '../common/infrastructure/config/client/tealfm.ts'; import type {RockSkyData} from '../common/infrastructure/config/client/rocksky.ts'; +import { prettifyError, ZodError } from 'zod'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -256,11 +257,17 @@ export default class ScrobbleSources { continue; } try { - await validateJson('source', c, this.getSchemaByType(c.type.toLocaleLowerCase() as SourceType), this.logger); + validateSourceJson(c.type.toLocaleLowerCase() as SourceType, c); // validateJson('source', c, this.getSchemaByType(c.type.toLocaleLowerCase() as SourceType), this.logger); } catch (e) { - const err = new Error(`Source config ${index + 1} (${c.type} - ${name}) in config.json is invalid and will not be used.`, {cause: e}); + const msg = `Source config ${index + 1} (${c.type} - ${name}) in config.json is invalid and will not be used.`; + const err = new Error(msg, {cause: e}); this.emitter.emit('error', err); - this.logger.error(err); + // pretty print error if its a zod error + if(e instanceof ZodError) { + this.logger.error(`${msg}:\n${prettifyError(e)}`); + } else { + this.logger.error(err); + } continue; } configs.push({...c, diff --git a/src/backend/tests/config/config.test.ts b/src/backend/tests/config/config.test.ts index 343c9d8d..52e03194 100644 --- a/src/backend/tests/config/config.test.ts +++ b/src/backend/tests/config/config.test.ts @@ -13,6 +13,9 @@ import { projectRootDir } from "../../common/infrastructure/Atomic.ts"; import { sourceTypes } from "../../../core/Atomic.ts"; import { Notifiers } from '../../notifier/Notifiers.ts'; import { difference } from '../../utils.ts'; +import { validateSourceJson } from '../../common/infrastructure/config/source/sources.ts'; +import { readJson } from '../../utils/DataUtils.ts'; +import { prettifyError, ZodError } from 'zod'; chai.use(asPromised); @@ -51,10 +54,32 @@ describe('Sample Configs', function () { await reset(); }); + for(const componentType of sourceTypes) { //trueName = componentType; - it(`Sample ${componentType}.json parses and validates`, async function () { + it(`Sample ${componentType}.json parses and validates in isolation`, async function () { + this.timeout(5000); + + const emitter = new EventEmitter(); + await copyFile(samplePath(componentType), `${componentType}.json`); + + let fileContents = await readJson(`${componentType}.json`); + fileContents = fileContents.filter(x => x.configureAs === undefined || x.configureAs === 'source'); + for (const [i,rawConf] of fileContents.entries()) { + try { + validateSourceJson(componentType, rawConf); + } catch (e) { + if(e instanceof ZodError) { + expect.fail(`Validation failed for config entry ${i}:\n${prettifyError(e)}`); + } else { + throw e; + } + } + } + }); + + it(`Sample ${componentType}.json parses and validates in ScrobbleSources`, async function () { this.timeout(5000); const emitter = new EventEmitter(); -- 2.51.2