From ecf3273eb99c3112c19f20537e7d9e39a70e64f3 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Thu, 23 Jul 2026 20:24:58 +0000 Subject: [PATCH 01/30] add zod packages --- package-lock.json | 1031 +++++++++++++++++++++++++++++++++++++++++++-- package.json | 6 +- 2 files changed, 994 insertions(+), 43 deletions(-) diff --git a/package-lock.json b/package-lock.json index 323d4628..49781c4e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -207,11 +207,13 @@ "tinyexec": "^1.1.1", "ts-essentials": "^10.2.0", "ts-json-schema-generator": "^2.3.0", - "typescript": "^6.0.3", + "ts-to-zod": "^5.1.0", + "typescript": "^5.9.2", "typescript-eslint": "^8.59.3", "usehooks-ts": "^3.1.1", "vite": "^8.0.12", - "with-local-tmp-dir": "^7.0.1" + "with-local-tmp-dir": "^7.0.1", + "zod": "^4.4.3" }, "engines": { "node": ">=24.14.0", @@ -1373,6 +1375,16 @@ } } }, + "node_modules/@chakra-ui/cli/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@chakra-ui/react": { "version": "3.36.0", "resolved": "https://registry.npmjs.org/@chakra-ui/react/-/react-3.36.0.tgz", @@ -2535,6 +2547,27 @@ "zod-validation-error": "^2.1.0" } }, + "node_modules/@foxxmd/chromecast-client/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/@foxxmd/chromecast-client/node_modules/zod-validation-error": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-2.1.0.tgz", + "integrity": "sha512-VJh93e2wb4c3tWtGgTa0OF/dTt/zoPCPzXq4V11ZjxmEAFaPi/Zss1xIZdEB5RD8GD00U0/iVXgqkF77RV7pdQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.18.0" + } + }, "node_modules/@foxxmd/get-version": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@foxxmd/get-version/-/get-version-0.0.3.tgz", @@ -3334,6 +3367,52 @@ "node": ">= 8" } }, + "node_modules/@oclif/core": { + "version": "4.13.0", + "resolved": "https://registry.npmjs.org/@oclif/core/-/core-4.13.0.tgz", + "integrity": "sha512-j3NKgXE3qd0gvSYsCqDLb3VhW5KEg030VQhMnv5c0qNzPp6jBmF7cCDIQG4ROZIOqEfGbUoDDMAAzaC30vV99g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^4.3.2", + "ansis": "^3.17.0", + "clean-stack": "^3.0.1", + "cli-spinners": "^2.9.2", + "debug": "^4.4.3", + "ejs": "^3.1.10", + "get-package-type": "^0.1.0", + "indent-string": "^4.0.0", + "is-wsl": "^2.2.0", + "lilconfig": "^3.1.3", + "minimatch": "^10.2.5", + "semver": "^7.8.1", + "string-width": "^4.2.3", + "supports-color": "^8", + "tinyglobby": "^0.2.17", + "widest-line": "^3.1.0", + "wordwrap": "^1.0.0", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@oclif/core/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/@open-draft/deferred-promise": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-3.0.0.tgz", @@ -6922,6 +7001,19 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", @@ -8644,6 +8736,35 @@ "node": ">=6" } }, + "node_modules/ansi-escapes": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.21.3" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ansi-escapes/node_modules/type-fest": { + "version": "0.21.3", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -8685,6 +8806,16 @@ "react-dom": "^16.3.2 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/ansis": { + "version": "3.17.0", + "resolved": "https://registry.npmjs.org/ansis/-/ansis-3.17.0.tgz", + "integrity": "sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, "node_modules/anymatch": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", @@ -8758,6 +8889,13 @@ "node": ">=4" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -9530,6 +9668,51 @@ "node": ">=8" } }, + "node_modules/clean-stack": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-3.0.1.tgz", + "integrity": "sha512-lR9wNiMRcVQjSB3a7xXGLuz4cr4wJuuXlaAEbRutGowQTmlp7R72/DOgN21e8jdwblMWl9UOJMJXarX94pzKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "escape-string-regexp": "4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-cursor": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", + "integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz", + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-table": { "version": "0.3.11", "resolved": "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz", @@ -9542,6 +9725,40 @@ "node": ">= 0.2.0" } }, + "node_modules/cli-truncate": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "8.2.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", + "integrity": "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.5.0", + "strip-ansi": "^7.1.2" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -11040,6 +11257,22 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.392", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.392.tgz", @@ -11124,6 +11357,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/environment": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", + "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/error-ex": { "version": "1.3.4", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", @@ -12606,6 +12852,46 @@ "json-buffer": "3.0.1" } }, + "node_modules/filelist": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.6.tgz", + "integrity": "sha512-5giy2PkLYY1cP39p17Ech+2xlpTRL9HLspOfEgm0L6CwBXBTgsK5ou0JtzYuepxkaQ/tvhCFIJ5uXo0OrM2DxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz", + "integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -12998,6 +13284,19 @@ "node": "6.* || 8.* || >= 10.*" } }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-func-name": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", @@ -13032,6 +13331,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-package-type": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", + "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/get-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", @@ -14273,6 +14582,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jake": { + "version": "10.9.4", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.4.tgz", + "integrity": "sha512-wpHYzhxiVQL+IV05BLE2Xn34zW1S223hvjtqk0+gsPrwd/8JNLXJgZZM/iPFsYc1xyphF+6M6EvdE5E9MBGkDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.6", + "filelist": "^1.0.4", + "picocolors": "^1.1.1" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/jayson": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/jayson/-/jayson-3.7.0.tgz", @@ -15002,6 +15329,19 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -15019,20 +15359,94 @@ "uc.micro": "^1.0.1" } }, - "node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "node_modules/listr2": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", + "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", "dev": true, "license": "MIT", "dependencies": { - "p-locate": "^5.0.0" + "cli-truncate": "^5.0.0", + "colorette": "^2.0.20", + "eventemitter3": "^5.0.1", + "log-update": "^6.1.0", + "rfdc": "^1.4.1", + "wrap-ansi": "^9.0.0" }, "engines": { - "node": ">=10" + "node": ">=20.0.0" + } + }, + "node_modules/listr2/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/listr2/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/listr2/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/listr2/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lodash": { @@ -15096,6 +15510,131 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/log-update": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", + "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^7.0.0", + "cli-cursor": "^5.0.0", + "slice-ansi": "^7.1.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-escapes": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", + "integrity": "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "environment": "^1.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/log-update/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/log-update/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/slice-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", + "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "is-fullwidth-code-point": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/log-update/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/log-update/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/long": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/long/-/long-4.0.0.tgz", @@ -15484,6 +16023,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/mimic-function": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", + "integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/mimic-response": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", @@ -16318,6 +16870,22 @@ "wrappy": "1" } }, + "node_modules/onetime": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", + "integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-function": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/oniguruma-parser": { "version": "0.12.2", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz", @@ -17888,6 +18456,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/restore-cursor": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", + "integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^7.0.0", + "signal-exit": "^4.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/retry": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", @@ -17915,6 +18500,13 @@ "node": ">=0.10.0" } }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "dev": true, + "license": "MIT" + }, "node_modules/rolldown": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", @@ -18569,6 +19161,52 @@ "node": ">=6" } }, + "node_modules/slice-ansi": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", + "integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-east-asian-width": "^1.3.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/sonic-boom": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz", @@ -19119,6 +19757,218 @@ "bintrees": "1.0.2" } }, + "node_modules/text-camel-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-camel-case/-/text-camel-case-1.2.11.tgz", + "integrity": "sha512-2ZsM/gOlB1tyza+8lGLvs6gtPuZ9qEYuKPa+gwo38m65wkY4k323SK4hT7ku8r5wIKyspUYIWSk1aB9/Jjxr7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-pascal-case": "^1.2.11" + } + }, + "node_modules/text-capital-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-capital-case/-/text-capital-case-1.2.11.tgz", + "integrity": "sha512-30A7B7+VUvevEmPE0xWK1Z2z0ncl/JTjSUBLfjpoXrkwuPpmNTVbjHShRTN3cX9GIuZn/P3jvR+TO9JiTZcl8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-no-case": "^1.2.11", + "text-upper-case-first": "^1.2.11" + } + }, + "node_modules/text-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-case/-/text-case-1.2.11.tgz", + "integrity": "sha512-LbdWNQeuXWbfav+pxBxvaefkziffMYeSA53BHp52cgJa9rjiC0dkjum9AKrH8iQWoQJ4InGPSGexLeerGFaZ1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-camel-case": "^1.2.11", + "text-capital-case": "^1.2.11", + "text-constant-case": "^1.2.11", + "text-dot-case": "^1.2.11", + "text-header-case": "^1.2.11", + "text-is-lower-case": "^1.2.11", + "text-is-upper-case": "^1.2.11", + "text-kebab-case": "^1.2.11", + "text-lower-case": "^1.2.11", + "text-lower-case-first": "^1.2.11", + "text-no-case": "^1.2.11", + "text-param-case": "^1.2.11", + "text-pascal-case": "^1.2.11", + "text-path-case": "^1.2.11", + "text-sentence-case": "^1.2.11", + "text-snake-case": "^1.2.11", + "text-swap-case": "^1.2.11", + "text-title-case": "^1.2.11", + "text-upper-case": "^1.2.11", + "text-upper-case-first": "^1.2.11" + } + }, + "node_modules/text-constant-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-constant-case/-/text-constant-case-1.2.11.tgz", + "integrity": "sha512-XnTBILsa7UpMWncUCchqIybZlg15FUcrlyNaWIJ8ybPy54qcoN513EXFswueyizuAgyJFXPCwwSFbSji6kw/Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-no-case": "^1.2.11", + "text-upper-case": "^1.2.11" + } + }, + "node_modules/text-dot-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-dot-case/-/text-dot-case-1.2.11.tgz", + "integrity": "sha512-7SLKiT45KZO0qad0+p+GvC0+F+6pZ851HJcTcBJiSF88HsK/e1qErlGLtVBT6hkTHIaAj48WfSyQr4lZRv1xJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-no-case": "^1.2.11" + } + }, + "node_modules/text-header-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-header-case/-/text-header-case-1.2.11.tgz", + "integrity": "sha512-7OBHd2g7X+aH6rXMC3cANFh6yvhXjXkyumw2NaRwJRIk343pP2e1SQCTCfowPDmmi8wkZVqz1fdWNq5LwvcBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-capital-case": "^1.2.11" + } + }, + "node_modules/text-is-lower-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-is-lower-case/-/text-is-lower-case-1.2.11.tgz", + "integrity": "sha512-dBqPAkNmX7eTM7ZbS3D/UBCQ5i9EXt5tujF2wIGGbZ1+aN8bY7Qda4mDpxgd6Hbzf/z10uQWRNzupl99wFQ8CQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-is-upper-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-is-upper-case/-/text-is-upper-case-1.2.11.tgz", + "integrity": "sha512-MZeUIYEYfKZ2FSeg0vnHCH4mHXLgGzes+iz2K+4BYnhnkEa2svKA1nNjQAqTUiVNHOPqPCuzmUr1LsyQZ73uyA==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-kebab-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-kebab-case/-/text-kebab-case-1.2.11.tgz", + "integrity": "sha512-RIg9iN6VwH+JrX9dFdm1nd1efPGR9LjNc0CiQz496sQETeKGkDEzxES/ZzxbkerrAL2DFEMGdLXckzDz1OEDBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-no-case": "^1.2.11" + } + }, + "node_modules/text-lower-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-lower-case/-/text-lower-case-1.2.11.tgz", + "integrity": "sha512-txTy6y0y8M23Lhf0mk8WcvXTqlf4OQ3AGnDsRB6o3uMNfIa0CJDol2s1PdKNa63rt5B2277zkZCCn6Xeq//big==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-lower-case-first": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-lower-case-first/-/text-lower-case-first-1.2.11.tgz", + "integrity": "sha512-QR483XLyuyIpq8tKu1ds3Q1jfsgfaa/p9rtoQKHe6Rv5ah9ic/SUzTGN0MQ7UIS9APADd8SUPn5TTh1Z2/ACyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-no-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-no-case/-/text-no-case-1.2.11.tgz", + "integrity": "sha512-wazS7FEq0Ct3aJzeE8MEMcSs0eW4+/X/fwdotv/rG66bLS+g1T0pa0gUsbBGjjLFs191AIXVIry+bYE0uaaBBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-lower-case": "^1.2.11" + } + }, + "node_modules/text-param-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-param-case/-/text-param-case-1.2.11.tgz", + "integrity": "sha512-3EMMAMLSz/mJXOnATNnrS+dZAvghpq09VhOVYDOkUnbm5zlYc6iU5AZOKVDpiAVVllQ9P1h5IKVZzsEYrdIRGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-dot-case": "^1.2.11" + } + }, + "node_modules/text-pascal-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-pascal-case/-/text-pascal-case-1.2.11.tgz", + "integrity": "sha512-BNhQ1O/g/Q4dH5gPyLIJLDLDknl2dipBwV629ScsiZCKJaCLGXYhTXp23rp9Htg3O5OSSsiU3mqDKq+pBmwTSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-no-case": "^1.2.11" + } + }, + "node_modules/text-path-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-path-case/-/text-path-case-1.2.11.tgz", + "integrity": "sha512-FsJU4BmMdtLtmnBK/XRJPqTwLF8yFiTEClHjxlQjSAG5Xt9R4p6D1WNaM1CI2dG5Lr4rsFM4jiVC620m0AsRbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-dot-case": "^1.2.11" + } + }, + "node_modules/text-sentence-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-sentence-case/-/text-sentence-case-1.2.11.tgz", + "integrity": "sha512-ApiVsvdLy+Wb8x7mZRVuoy8VO12jJ22G2djVM3ZZbUhXVIkqGgHxmiXRwdhRPoWGojK9n53m7jviJgBVNdRn+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-no-case": "^1.2.11", + "text-upper-case-first": "^1.2.11" + } + }, + "node_modules/text-snake-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-snake-case/-/text-snake-case-1.2.11.tgz", + "integrity": "sha512-NOEQvjyyVABB41SS8dUx423Y6hWS+Z4TrAAJg1xzCkOD3q9y0JtdJjCvCA1FWI8oDu+HiIOp/N446uDM8j54XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-dot-case": "^1.2.11" + } + }, + "node_modules/text-swap-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-swap-case/-/text-swap-case-1.2.11.tgz", + "integrity": "sha512-PBmC5xvZdDZ4suikydpeXH0s4JV2XHelMj9/OEXEbA3oLpdV2A+B4BspVDWVw7C2Gi5eCareqk/7EE8I1/WwgQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-title-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-title-case/-/text-title-case-1.2.11.tgz", + "integrity": "sha512-V1GZy0XlqdkYUQm0tqm1jqtYlXJqFVMreBCTUReOaz8d/JbozTSpZrcakIeV8+1bN7LsvfhPFhA5zREiax6YIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "text-no-case": "^1.2.11", + "text-upper-case-first": "^1.2.11" + } + }, + "node_modules/text-upper-case": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-upper-case/-/text-upper-case-1.2.11.tgz", + "integrity": "sha512-BfTL7yB1YIRlVGNdZUvno013hOq2cRs07fDR2ApppOXRDuKrEmsLDEY82xXlDzQHELp0jexqkI+NeyPIl6MtMw==", + "dev": true, + "license": "MIT" + }, + "node_modules/text-upper-case-first": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/text-upper-case-first/-/text-upper-case-first-1.2.11.tgz", + "integrity": "sha512-vgfbwKo8TEJbRsapR9LWWvIJRnv8u9aXVa6cyYOAQQmurCx54Cnt59x5fKdiq+hFaBJ51AbzCgMpbP3p65/pHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/thread-stream": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.2.0.tgz", @@ -19516,20 +20366,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ts-json-schema-generator/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/ts-json-schema-generator/node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -19554,6 +20390,95 @@ "integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA==", "license": "MIT" }, + "node_modules/ts-to-zod": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ts-to-zod/-/ts-to-zod-5.1.0.tgz", + "integrity": "sha512-giqqlvRHunlJqG9tBL/KAO3wWIVZGF//mZiWLKm/fdQnKnz4EN2mtiK5cugN9slytBkdMEXQIaLvMzIScbhhFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/prompts": "1.0.0-alpha.4", + "@oclif/core": "^4.5.4", + "@typescript/vfs": "^1.5.0", + "chokidar": "^4.0.3", + "listr2": "^9.0.4", + "slash": "^5.1.0", + "text-case": "^1.2.4", + "tslib": "^2.3.1", + "tsutils": "^3.21.0", + "typescript": "^5.2.2", + "zod": "^4.1.5" + }, + "bin": { + "ts-to-zod": "bin/run" + } + }, + "node_modules/ts-to-zod/node_modules/@clack/core": { + "version": "1.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-1.0.0-alpha.4.tgz", + "integrity": "sha512-VCtU+vjyKPMSakVrB9q1bOnXN7QW/w4+YQDQCOF59GrzydW+169i0fVx/qzRRXJgt8KGj/pZZ/JxXroFZIDByg==", + "dev": true, + "license": "MIT", + "dependencies": { + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/ts-to-zod/node_modules/@clack/prompts": { + "version": "1.0.0-alpha.4", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.0.0-alpha.4.tgz", + "integrity": "sha512-KnmtDF2xQGoI5AlBme9akHtvCRV0RKAARUXHBQO2tMwnY8B08/4zPWigT7uLK25UPrMCEqnyQPkKRjNdhPbf8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@clack/core": "1.0.0-alpha.4", + "picocolors": "^1.0.0", + "sisteransi": "^1.0.5" + } + }, + "node_modules/ts-to-zod/node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ts-to-zod/node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/ts-to-zod/node_modules/slash": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz", + "integrity": "sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tsconfig-paths": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", @@ -19575,6 +20500,29 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tsutils": { + "version": "3.21.0", + "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", + "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^1.8.1" + }, + "engines": { + "node": ">= 6" + }, + "peerDependencies": { + "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" + } + }, + "node_modules/tsutils/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "dev": true, + "license": "0BSD" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -19654,9 +20602,9 @@ "license": "MIT" }, "node_modules/typescript": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", - "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -20503,6 +21451,19 @@ "node": ">= 8" } }, + "node_modules/widest-line": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-3.1.0.tgz", + "integrity": "sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/with-local-tmp-dir": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/with-local-tmp-dir/-/with-local-tmp-dir-7.0.1.tgz", @@ -20839,26 +21800,14 @@ } }, "node_modules/zod": { - "version": "3.25.76", - "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", - "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" } }, - "node_modules/zod-validation-error": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-2.1.0.tgz", - "integrity": "sha512-VJh93e2wb4c3tWtGgTa0OF/dTt/zoPCPzXq4V11ZjxmEAFaPi/Zss1xIZdEB5RD8GD00U0/iVXgqkF77RV7pdQ==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - }, - "peerDependencies": { - "zod": "^3.18.0" - } - }, "node_modules/zwitch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", diff --git a/package.json b/package.json index 105d2846..732c2444 100644 --- a/package.json +++ b/package.json @@ -247,11 +247,13 @@ "tinyexec": "^1.1.1", "ts-essentials": "^10.2.0", "ts-json-schema-generator": "^2.3.0", - "typescript": "^6.0.3", + "ts-to-zod": "^5.1.0", + "typescript": "^5.9.2", "typescript-eslint": "^8.59.3", "usehooks-ts": "^3.1.1", "vite": "^8.0.12", - "with-local-tmp-dir": "^7.0.1" + "with-local-tmp-dir": "^7.0.1", + "zod": "^4.4.3" }, "browserslist": { "production": [ -- 2.51.2 From 742025feccf506db6c0cacd6009d26971b3056b6 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 24 Jul 2026 12:56:53 -0400 Subject: [PATCH 02/30] 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 From 7c3590adf9f753f9ded556188623bf66ab809982 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 24 Jul 2026 15:31:32 -0400 Subject: [PATCH 03/30] convert remaining source configs --- .../config/source/applemusic.ts | 89 ++++++++----- .../config/source/chromecast.ts | 106 ++++++++++----- .../infrastructure/config/source/deezer.ts | 112 +++++++++++----- .../config/source/endpointlfm.ts | 32 +++-- .../config/source/endpointlz.ts | 46 ++++--- .../infrastructure/config/source/icecast.ts | 66 ++++++---- .../infrastructure/config/source/jellyfin.ts | 119 +++++++++++------ .../infrastructure/config/source/jriver.ts | 49 +++++-- .../infrastructure/config/source/kodi.ts | 49 ++++--- .../infrastructure/config/source/koito.ts | 40 ++++-- .../infrastructure/config/source/lastfm.ts | 39 ++++-- .../infrastructure/config/source/librefm.ts | 39 ++++-- .../config/source/listenbrainz.ts | 40 ++++-- .../infrastructure/config/source/maloja.ts | 40 ++++-- .../infrastructure/config/source/mopidy.ts | 64 ++++++--- .../infrastructure/config/source/mpd.ts | 57 ++++++--- .../infrastructure/config/source/mpris.ts | 38 ++++-- .../infrastructure/config/source/musiccast.ts | 33 +++-- .../infrastructure/config/source/musikcube.ts | 40 ++++-- .../infrastructure/config/source/plex.ts | 121 +++++++++++------- .../infrastructure/config/source/rocksky.ts | 51 +++++--- .../infrastructure/config/source/sonos.ts | 68 +++++++--- .../infrastructure/config/source/spotify.ts | 53 ++++++-- .../infrastructure/config/source/subsonic.ts | 72 ++++++++--- .../infrastructure/config/source/tealfm.ts | 53 +++++--- .../infrastructure/config/source/vlc.ts | 64 ++++++--- .../config/source/webscrobbler.ts | 46 +++++-- .../infrastructure/config/source/ymbridge.ts | 39 ++++-- .../infrastructure/config/source/ytmusic.ts | 119 +++++++++++------ 29 files changed, 1227 insertions(+), 557 deletions(-) diff --git a/src/backend/common/infrastructure/config/source/applemusic.ts b/src/backend/common/infrastructure/config/source/applemusic.ts index b5acba9e..6a406db3 100644 --- a/src/backend/common/infrastructure/config/source/applemusic.ts +++ b/src/backend/common/infrastructure/config/source/applemusic.ts @@ -1,30 +1,42 @@ -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; - -export interface AppleMusicKey { - id: string - teamId: string - p8: string -} - -export interface AppleMusicData extends CommonSourceData, PollingOptions { - key?: AppleMusicKey - token?: string - mediaUserToken?: string +import * as z from "zod"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema} from "./index.ts"; + +export const appleMusicKeySchema = z.object({ + id: z.string(), + teamId: z.string(), + p8: z.string() +}); + +export type AppleMusicKey = z.infer; + +export const appleMusicDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, + key: appleMusicKeySchema.optional(), + token: z.string().optional(), + mediaUserToken: z.string().optional(), /** * Origin header to include in every Apple Music API request. * Required when using a browser token (not a MusicKit key). * - * @example "https://music.apple.com" + * @examples ["https://music.apple.com"] */ - origin?: string -} - -export interface AppleMusicSourceConfig extends CommonSourceConfig { - data?: AppleMusicData - options?: CommonSourceOptions & { - logAuth?: boolean - logDiff?: boolean + origin: z.string().optional().meta({ + description: "Origin header to include in every Apple Music API request.", + examples: ["https://music.apple.com"] + }), +}); + +export type AppleMusicData = z.infer; + +export const appleMusicSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: appleMusicDataSchema.optional(), + options: z.object({ + ...commonSourceOptionsSchema.shape, + logAuth: z.boolean().optional(), + logDiff: z.boolean().optional(), /** * Fixes a quirk where Apple Music's history API hides duplicate plays. * If you listen to A → B → A, the API returns [A, B, X, Y ...] instead of [A, B, A, X, Y ...]. @@ -36,23 +48,36 @@ export interface AppleMusicSourceConfig extends CommonSourceConfig { * @default true * @examples [true, false] */ - recoverUnchangedTopHistory?: boolean + recoverUnchangedTopHistory: z.boolean().optional().meta({ + description: "Fixes a quirk where Apple Music's history API hides duplicate plays.", + default: true, + examples: [true, false] + }), /** * Removes extraneous suffixes from album data - * + * * Apple Music add ' - EP' and ' - Single' to album names for EP's and singles, respectively. * These suffixes are not part of the official names for the album and can cause issues in scrobble services * or when matching metadata (musicbrainz). - * + * * When this option is true (default), Multi-scrobbler automatically removes these suffixes. - * + * * @default true * @examples [true, false] */ - normalizeAlbum?: boolean - } -} + normalizeAlbum: z.boolean().optional().meta({ + description: "Removes extraneous suffixes from album data", + default: true, + examples: [true, false] + }), + }).optional(), +}); + +export type AppleMusicSourceConfig = z.infer; + +export const appleMusicSourceAIOConfigSchema = z.object({ + ...appleMusicSourceConfigSchema.shape, + type: z.literal('applemusic'), +}); -export interface AppleMusicSourceAIOConfig extends AppleMusicSourceConfig { - type: 'applemusic' -} +export type AppleMusicSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/chromecast.ts b/src/backend/common/infrastructure/config/source/chromecast.ts index 25b1177f..3c382ca4 100644 --- a/src/backend/common/infrastructure/config/source/chromecast.ts +++ b/src/backend/common/infrastructure/config/source/chromecast.ts @@ -1,6 +1,31 @@ -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface ChromecastData extends CommonSourceData { +export const chromecastDeviceInfoSchema = z.object({ + /** + * A friendly name to identify this device + * + * @examples ["MySmartTV"] + * */ + name: z.string().meta({ + description: "A friendly name to identify this device", + examples: ["MySmartTV"] + }), + /** + * The IP address of the device + * + * @examples ["192.168.0.115"] + * */ + address: z.string().meta({ + description: "The IP address of the device", + examples: ["192.168.0.115"] + }), +}); + +export type ChromecastDeviceInfo = z.infer; + +export const chromecastDataSchema = z.object({ + ...commonSourceDataSchema.shape, /** * DO NOT scrobble from any cast devices that START WITH these values, case-insensitive * @@ -8,7 +33,10 @@ export interface ChromecastData extends CommonSourceData { * * @examples [["home-mini","family-tv"]] * */ - blacklistDevices?: string | string[] + blacklistDevices: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "DO NOT scrobble from any cast devices that START WITH these values, case-insensitive", + examples: [["home-mini", "family-tv"]] + }), /** * ONLY scrobble from any cast device that START WITH these values, case-insensitive @@ -19,14 +47,20 @@ export interface ChromecastData extends CommonSourceData { * * @examples [["home-mini","family-tv"]] * */ - whitelistDevices?: string | string[] + whitelistDevices: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "ONLY scrobble from any cast device that START WITH these values, case-insensitive", + examples: [["home-mini", "family-tv"]] + }), /** * DO NOT scrobble from any application that START WITH these values, case-insensitive * * @examples [["spotify","pandora"]] * */ - blacklistApps?: string | string[] + blacklistApps: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "DO NOT scrobble from any application that START WITH these values, case-insensitive", + examples: [["spotify", "pandora"]] + }), /** * ONLY scrobble from any application that START WITH these values, case-insensitive @@ -35,7 +69,10 @@ export interface ChromecastData extends CommonSourceData { * * @examples [["spotify","pandora"]] * */ - whitelistApps?: string | string[] + whitelistApps: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "ONLY scrobble from any application that START WITH these values, case-insensitive", + examples: [["spotify", "pandora"]] + }), /** * Try to use Avahi and avahi-browse to resolve mDNS devices instead of native mDNS querying @@ -44,21 +81,28 @@ export interface ChromecastData extends CommonSourceData { * * @default false * */ - useAvahi?: boolean + useAvahi: z.boolean().optional().meta({ + description: "Try to use Avahi and avahi-browse to resolve mDNS devices instead of native mDNS querying", + default: false + }), /** * Use mDNS to discovery Google Cast devices on your next automatically? * * If not explicitly set then it is TRUE if `devices` is not set * */ - useAutoDiscovery?: boolean | undefined + useAutoDiscovery: z.union([z.boolean(), z.undefined()]).optional().meta({ + description: "Use mDNS to discovery Google Cast devices on your next automatically?" + }), /** * A list of Google Cast devices to monitor * * If this is used then `useAutoDiscovery` is set to FALSE, if not explicitly set * */ - devices?: ChromecastDeviceInfo[] + devices: z.array(chromecastDeviceInfoSchema).optional().meta({ + description: "A list of Google Cast devices to monitor" + }), /** * Chromecast Apps report a "media type" in the status info returned for whatever is currently playing @@ -71,35 +115,33 @@ export interface ChromecastData extends CommonSourceData { * * @default false * */ - allowUnknownMedia?: boolean | string[] + allowUnknownMedia: z.union([z.boolean(), z.array(z.string())]).optional().meta({ + description: "Chromecast Apps report a \"media type\" in the status info returned for whatever is currently playing", + default: false + }), /** * Media provided by any App whose name is listed here will ALWAYS be tracked, regardless of the "media type" reported * * Apps will be recognized if they CONTAIN any of these values, case-insensitive * */ - forceMediaRecognitionOn?: string[] -} + forceMediaRecognitionOn: z.array(z.string()).optional().meta({ + description: "Media provided by any App whose name is listed here will ALWAYS be tracked, regardless of the \"media type\" reported" + }), +}); -export interface ChromecastDeviceInfo { - /** - * A friendly name to identify this device - * - * @examples ["MySmartTV"] - * */ - name: string - /** - * The IP address of the device - * - * @examples ["192.168.0.115"] - * */ - address: string -} +export type ChromecastData = z.infer; + +export const chromecastSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: chromecastDataSchema, +}); + +export type ChromecastSourceConfig = z.infer; -export interface ChromecastSourceConfig extends CommonSourceConfig { - data: ChromecastData -} +export const chromecastSourceAIOConfigSchema = z.object({ + ...chromecastSourceConfigSchema.shape, + type: z.literal('chromecast'), +}); -export interface ChromecastSourceAIOConfig extends ChromecastSourceConfig { - type: 'chromecast' -} +export type ChromecastSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/deezer.ts b/src/backend/common/infrastructure/config/source/deezer.ts index 2c3c6ff4..250d293d 100644 --- a/src/backend/common/infrastructure/config/source/deezer.ts +++ b/src/backend/common/infrastructure/config/source/deezer.ts @@ -1,67 +1,113 @@ +import * as z from "zod"; import type {Second} from "../../../../../core/Atomic.ts"; -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema} from "./index.ts"; -export interface DeezerData extends CommonSourceData, PollingOptions { +export const deezerDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, /** * deezer client id * * @examples ["a89cba1569901a0671d5a9875fed4be1"] * */ - clientId: string + clientId: z.string().meta({ + description: "deezer client id", + examples: ["a89cba1569901a0671d5a9875fed4be1"] + }), /** * deezer client secret * * @examples ["ec42e09d5ae0ee0f0816ca151008412a"] * */ - clientSecret: string + clientSecret: z.string().meta({ + description: "deezer client secret", + examples: ["ec42e09d5ae0ee0f0816ca151008412a"] + }), /** * deezer redirect URI -- required only if not the default shown here. URI must end in "callback" * * @default "http://localhost:9078/deezer/callback" * @examples ["http://localhost:9078/deezer/callback"] * */ - redirectUri?: string + redirectUri: z.string().optional().meta({ + description: "deezer redirect URI -- required only if not the default shown here.", + default: "http://localhost:9078/deezer/callback", + examples: ["http://localhost:9078/deezer/callback"] + }), - accessToken?: string -} -export interface DeezerSourceConfig extends CommonSourceConfig { - data: DeezerData -} + accessToken: z.string().optional(), +}); -export interface DeezerSourceAIOConfig extends DeezerSourceConfig { - type: 'deezer' -} +export type DeezerData = z.infer; + +export const deezerSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: deezerDataSchema, +}); + +export type DeezerSourceConfig = z.infer; -export interface DeezerInternalData extends CommonSourceData, PollingOptions { +export const deezerSourceAIOConfigSchema = z.object({ + ...deezerSourceConfigSchema.shape, + type: z.literal('deezer'), +}); + +export type DeezerSourceAIOConfig = z.infer; + +export const deezerInternalDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, /** ARL retrieved from Deezer response header */ - arl: string + arl: z.string().meta({ + description: "ARL retrieved from Deezer response header" + }), /** User agent - * + * * @default "Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0" */ - userAgent?: string + userAgent: z.string().optional().meta({ + description: "User agent", + default: "Mozilla/5.0 (X11; Linux i686; rv:135.0) Gecko/20100101 Firefox/135.0" + }), /** The ID (USER_ID) of the linked account to monitor. If not set, monitors the main ARL account */ - accountId?: string -} + accountId: z.string().optional().meta({ + description: "The ID (USER_ID) of the linked account to monitor." + }), +}); -export interface DeezerInternalSourceConfig extends CommonSourceConfig { - data: DeezerInternalData - options?: DeezerInternalSourceOptions -} +export type DeezerInternalData = z.infer; -export interface DeezerInternalSourceOptions extends CommonSourceOptions { - fuzzyDiscoveryIgnore?: boolean | 'aggressive' -} +export const deezerInternalSourceOptionsSchema = z.object({ + ...commonSourceOptionsSchema.shape, + fuzzyDiscoveryIgnore: z.union([z.boolean(), z.literal('aggressive')]).optional(), +}); -export interface DeezerInternalAIOConfig extends DeezerInternalSourceConfig { - type: 'deezer' -} +export type DeezerInternalSourceOptions = z.infer; + +export const deezerInternalSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: deezerInternalDataSchema, + options: deezerInternalSourceOptionsSchema.optional(), +}); + +export type DeezerInternalSourceConfig = z.infer; + +export const deezerInternalAIOConfigSchema = z.object({ + ...deezerInternalSourceConfigSchema.shape, + type: z.literal('deezer'), +}); -export type DeezerCompatConfig = DeezerSourceConfig | DeezerInternalSourceConfig; +export type DeezerInternalAIOConfig = z.infer; -export type DeezerAIOCompatConfig = DeezerSourceAIOConfig | DeezerInternalAIOConfig; +export const deezerCompatConfigSchema = z.union([deezerSourceConfigSchema, deezerInternalSourceConfigSchema]); + +export type DeezerCompatConfig = z.infer; + +export const deezerAIOCompatConfigSchema = z.union([deezerSourceAIOConfigSchema, deezerInternalAIOConfigSchema]); + +export type DeezerAIOCompatConfig = z.infer; export interface DeezerInternalTrackData { /** Song Id */ @@ -84,4 +130,4 @@ export interface DeezerInternalTrackData { DURATION: Second __TYPE__: 'song' | string -} \ No newline at end of file +} diff --git a/src/backend/common/infrastructure/config/source/endpointlfm.ts b/src/backend/common/infrastructure/config/source/endpointlfm.ts index 019ae6c0..102d5c4f 100644 --- a/src/backend/common/infrastructure/config/source/endpointlfm.ts +++ b/src/backend/common/infrastructure/config/source/endpointlfm.ts @@ -1,6 +1,8 @@ -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface LastFMEndpointData extends CommonSourceData { +export const lastFmEndpointDataSchema = z.object({ + ...commonSourceDataSchema.shape, /** * The URL ending that should be used to identify scrobbles for this source * @@ -13,13 +15,23 @@ export interface LastFMEndpointData extends CommonSourceData { * * If no slug is found from an extension's incoming webhook event the first Last.fm source without a slug will be used * */ - slug?: string | null -} + slug: z.union([z.string(), z.null()]).optional().meta({ + description: "The URL ending that should be used to identify scrobbles for this source" + }), +}); -export interface LastFMEndpointSourceConfig extends CommonSourceConfig { - data?: LastFMEndpointData -} +export type LastFMEndpointData = z.infer; -export interface LastFMEndpointSourceAIOConfig extends LastFMEndpointSourceConfig { - type: 'endpointlfm' -} +export const lastFmEndpointSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: lastFmEndpointDataSchema.optional(), +}); + +export type LastFMEndpointSourceConfig = z.infer; + +export const lastFmEndpointSourceAIOConfigSchema = z.object({ + ...lastFmEndpointSourceConfigSchema.shape, + type: z.literal('endpointlfm'), +}); + +export type LastFMEndpointSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/endpointlz.ts b/src/backend/common/infrastructure/config/source/endpointlz.ts index 61427633..443e4bc3 100644 --- a/src/backend/common/infrastructure/config/source/endpointlz.ts +++ b/src/backend/common/infrastructure/config/source/endpointlz.ts @@ -1,6 +1,8 @@ -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface ListenbrainzEndpointData extends CommonSourceData { +export const listenbrainzEndpointDataSchema = z.object({ + ...commonSourceDataSchema.shape, /** * The URL ending that should be used to identify scrobbles for this source * @@ -13,29 +15,43 @@ export interface ListenbrainzEndpointData extends CommonSourceData { * * If no slug is found from an extension's incoming webhook event the first Listenbrainz source without a slug will be used * */ - slug?: string + slug: z.string().optional().meta({ + description: "The URL ending that should be used to identify scrobbles for this source" + }), /** * If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source * * See: https://listenbrainz.readthedocs.io/en/latest/users/api/index.html#add-the-user-token-to-your-requests * */ - token?: string + token: z.string().optional().meta({ + description: "If an LZ submission request contains this token in the Authorization Header it will be used to match the submission with this Source" + }), - /** + /** * The listenbrainz "username" to associate with this Source - * + * * Will be returned in validate-token responses and used to determine a response for playing-now, if your client supports it - * + * * If no value is configured then the Source's name will be used */ - username?: string -} + username: z.string().optional().meta({ + description: "The listenbrainz \"username\" to associate with this Source" + }), +}); + +export type ListenbrainzEndpointData = z.infer; + +export const listenbrainzEndpointSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: listenbrainzEndpointDataSchema.optional(), +}); + +export type ListenbrainzEndpointSourceConfig = z.infer; -export interface ListenbrainzEndpointSourceConfig extends CommonSourceConfig { - data?: ListenbrainzEndpointData -} +export const listenbrainzEndpointSourceAIOConfigSchema = z.object({ + ...listenbrainzEndpointSourceConfigSchema.shape, + type: z.literal('endpointlz'), +}); -export interface ListenbrainzEndpointSourceAIOConfig extends ListenbrainzEndpointSourceConfig { - type: 'endpointlz' -} +export type ListenbrainzEndpointSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/icecast.ts b/src/backend/common/infrastructure/config/source/icecast.ts index 61198f7a..c752b6e5 100644 --- a/src/backend/common/infrastructure/config/source/icecast.ts +++ b/src/backend/common/infrastructure/config/source/icecast.ts @@ -1,5 +1,5 @@ -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; - +import * as z from "zod"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema, manualListeningOptionsSchema} from "./index.ts"; export interface IcecastMetadata { icy?: { @@ -16,32 +16,52 @@ export interface IcecastMetadata { } } -export type IcecastSource = 'icy' | 'ogg' | 'icestats' | 'stats' | 'sevenhtml' | 'nextsongs'; +export const icecastSourceSchema = z.union([z.literal('icy'), z.literal('ogg'), z.literal('icestats'), z.literal('stats'), z.literal('sevenhtml'), z.literal('nextsongs')]); -export interface IcecastOptions { - sources?: IcecastSource[] - icestatsEndpoint?: string - statsEndpoint?: string - nextsongsEndpoint?: string - sevenhtmlEndpoint?: string - icyMetaInt?: number -} +export type IcecastSource = z.infer; + +export const icecastOptionsSchema = z.object({ + sources: z.array(icecastSourceSchema).optional(), + icestatsEndpoint: z.string().optional(), + statsEndpoint: z.string().optional(), + nextsongsEndpoint: z.string().optional(), + sevenhtmlEndpoint: z.string().optional(), + icyMetaInt: z.number().optional(), +}); -export interface IcecastData extends CommonSourceData, IcecastOptions { +export type IcecastOptions = z.infer; + +export const icecastDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...icecastOptionsSchema.shape, /** * The Icecast stream URL * */ - url: string -} + url: z.string().meta({ + description: "The Icecast stream URL" + }), +}); -export interface IcecastSourceOptions extends CommonSourceOptions { -} +export type IcecastData = z.infer; -export interface IcecastSourceConfig extends CommonSourceConfig { - data: IcecastData - options?: IcecastSourceOptions -} +export const icecastSourceOptionsSchema = z.object({ + ...commonSourceOptionsSchema.shape, + ...manualListeningOptionsSchema.shape, +}); -export interface IcecastSourceAIOConfig extends IcecastSourceConfig { - type: 'icecast' -} +export type IcecastSourceOptions = z.infer; + +export const icecastSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: icecastDataSchema, + options: icecastSourceOptionsSchema.optional(), +}); + +export type IcecastSourceConfig = z.infer; + +export const icecastSourceAIOConfigSchema = z.object({ + ...icecastSourceConfigSchema.shape, + type: z.literal('icecast'), +}); + +export type IcecastSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/jellyfin.ts b/src/backend/common/infrastructure/config/source/jellyfin.ts index 0328eec6..621e881f 100644 --- a/src/backend/common/infrastructure/config/source/jellyfin.ts +++ b/src/backend/common/infrastructure/config/source/jellyfin.ts @@ -1,110 +1,147 @@ -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; -import type { - // @ts-expect-error weird typings? - CollectionType, - // @ts-expect-error weird typings? - MediaType, -} from "@jellyfin/sdk/lib/generated-client/index.js"; - -export interface JellyApiData extends CommonSourceData { +import * as z from "zod"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema} from "./index.ts"; + +// `CollectionType`/`MediaType` (from `@jellyfin/sdk`) don't resolve cleanly - the package's typings don't +// line up with its actual exports (see the `@ts-expect-error` this replaced). Represented as `string`/`string[]` +// per instruction rather than chasing the SDK's broken typings. + +export const jellyApiDataSchema = z.object({ + ...commonSourceDataSchema.shape, /** * HOST:PORT of the Jellyfin server to connect to * */ - url: string + url: z.string().meta({ + description: "HOST:PORT of the Jellyfin server to connect to" + }), /** * The username of the user to authenticate for or track scrobbles for * */ - user: string + user: z.string().meta({ + description: "The username of the user to authenticate for or track scrobbles for" + }), /** * Password of the username to authenticate for * * Required if `apiKey` is not provided. * */ - password?: string + password: z.string().optional().meta({ + description: "Password of the username to authenticate for" + }), /** * API Key to authenticate with. * * Required if `password` is not provided. * */ - apiKey?: string + apiKey: z.string().optional().meta({ + description: "API Key to authenticate with." + }), /** * Only scrobble for specific users (case-insensitive) * * If `true` MS will scrobble activity from all users * */ - usersAllow?: string | true | string[] + usersAllow: z.union([z.string(), z.literal(true), z.array(z.string())]).optional().meta({ + description: "Only scrobble for specific users (case-insensitive)" + }), /** * Do not scrobble for these users (case-insensitive) * */ - usersBlock?: string | string[] + usersBlock: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Do not scrobble for these users (case-insensitive)" + }), /** * Only scrobble if device or application name contains strings from this list (case-insensitive) * */ - devicesAllow?: string | string[] + devicesAllow: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Only scrobble if device or application name contains strings from this list (case-insensitive)" + }), /** * Do not scrobble if device or application name contains strings from this list (case-insensitive) * */ - devicesBlock?: string | string[] + devicesBlock: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Do not scrobble if device or application name contains strings from this list (case-insensitive)" + }), /** * Only scrobble if library name contains string from this list (case-insensitive) * */ - librariesAllow?: string | string[] + librariesAllow: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Only scrobble if library name contains string from this list (case-insensitive)" + }), /** * Do not scrobble if library name contains strings from this list (case-insensitive) * */ - librariesBlock?: string | string[] + librariesBlock: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Do not scrobble if library name contains strings from this list (case-insensitive)" + }), /** * Allow MS to scrobble audio media in libraries classified other than 'music' - * + * * `librariesAllow` will achieve the same result as this but this is more convenient if you do not want to explicitly list every library name or are only using `librariesBlock` */ - additionalAllowedLibraryTypes?: CollectionType[] + additionalAllowedLibraryTypes: z.array(z.string()).optional().meta({ + description: "Allow MS to scrobble audio media in libraries classified other than 'music'" + }), /** * Force media with a type of "Unknown" to be counted as Audio - * + * * @default false * @deprecated use allowMediaTypes instead */ - allowUnknown?: boolean + allowUnknown: z.boolean().optional().meta({ + description: "Force media with a type of \"Unknown\" to be counted as Audio", + default: false + }), /** * Allow these media types to be scrobbled. - * + * * If not defined or empty, uses 'Audio' as default. If non-empty then *only* uses these types (make sure you include Audio!) - * + * * Values are case-insensitive. - * + * * See https://github.com/jellyfin/jellyfin-sdk-typescript/blob/master/src/generated-client/models/media-type.ts#L22 for possible types - * + * */ - allowMediaTypes?: MediaType[] | string + allowMediaTypes: z.union([z.array(z.string()), z.string()]).optional().meta({ + description: "Allow these media types to be scrobbled." + }), /** * HOST:PORT of the Jellyfin server that your browser will be able to access from the frontend (and thus load images and links from) * If unspecified it will use the normal server HOST and PORT from the `url` * Necessary if you are using a reverse proxy or other network configuration that prevents the frontend from accessing the server directly - * + * * ENV: JELLYFIN_FRONTEND_URL_OVERRIDE * */ - frontendUrlOverride?: string -} + frontendUrlOverride: z.string().optional().meta({ + description: "HOST:PORT of the Jellyfin server that your browser will be able to access from the frontend (and thus load images and links from)" + }), +}); + +export type JellyApiData = z.infer; + +export const jellyApiOptionsSchema = z.object({ + ...commonSourceOptionsSchema.shape, +}); -export interface JellyApiOptions extends CommonSourceOptions { -} +export type JellyApiOptions = z.infer; -export interface JellyApiSourceConfig extends CommonSourceConfig { - data: JellyApiData - options?: JellyApiOptions -} +export const jellyApiSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: jellyApiDataSchema, + options: jellyApiOptionsSchema.optional(), +}); -export interface JellyApiSourceAIOConfig extends JellyApiSourceConfig { - type: 'jellyfin' -} +export type JellyApiSourceConfig = z.infer; +export const jellyApiSourceAIOConfigSchema = z.object({ + ...jellyApiSourceConfigSchema.shape, + type: z.literal('jellyfin'), +}); -export type JellyfinCompatConfig = JellyApiSourceConfig; \ No newline at end of file +export type JellyApiSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/jriver.ts b/src/backend/common/infrastructure/config/source/jriver.ts index 1d4f5e33..efbbcf45 100644 --- a/src/backend/common/infrastructure/config/source/jriver.ts +++ b/src/backend/common/infrastructure/config/source/jriver.ts @@ -1,7 +1,11 @@ -import type {PollingOptions, RequestRetryOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {pollingOptionsSchema, requestRetryOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface JRiverData extends CommonSourceData, PollingOptions, RequestRetryOptions { +export const jRiverDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, + ...requestRetryOptionsSchema.shape, /** * URL of the JRiver HTTP server to connect to * @@ -20,22 +24,39 @@ export interface JRiverData extends CommonSourceData, PollingOptions, RequestRet * @examples ["http://localhost:52199/MCWS/v1/"] * @default "http://localhost:52199/MCWS/v1/" * */ - url: string + url: z.string().meta({ + description: "URL of the JRiver HTTP server to connect to", + default: "http://localhost:52199/MCWS/v1/", + examples: ["http://localhost:52199/MCWS/v1/"] + }), /** * If you have enabled authentication, the username you set * */ - username?: string + username: z.string().optional().meta({ + description: "If you have enabled authentication, the username you set" + }), /** * If you have enabled authentication, the password you set * */ - password?: string -} -export interface JRiverSourceConfig extends CommonSourceConfig { - data: JRiverData -} - -export interface JRiverSourceAIOConfig extends JRiverSourceConfig { - type: 'jriver' -} + password: z.string().optional().meta({ + description: "If you have enabled authentication, the password you set" + }), +}); + +export type JRiverData = z.infer; + +export const jRiverSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: jRiverDataSchema, +}); + +export type JRiverSourceConfig = z.infer; + +export const jRiverSourceAIOConfigSchema = z.object({ + ...jRiverSourceConfigSchema.shape, + type: z.literal('jriver'), +}); + +export type JRiverSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/kodi.ts b/src/backend/common/infrastructure/config/source/kodi.ts index 6c71d4c2..3efa1b5f 100644 --- a/src/backend/common/infrastructure/config/source/kodi.ts +++ b/src/backend/common/infrastructure/config/source/kodi.ts @@ -1,8 +1,10 @@ -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; - -export interface KodiData extends CommonSourceData, PollingOptions { +export const kodiDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, /** * URL of the Kodi HTTP server to connect to * @@ -21,22 +23,39 @@ export interface KodiData extends CommonSourceData, PollingOptions { * @examples ["http://localhost:8080/jsonrpc"] * @default "http://localhost:8080/jsonrpc" * */ - url: string + url: z.string().meta({ + description: "URL of the Kodi HTTP server to connect to", + default: "http://localhost:8080/jsonrpc", + examples: ["http://localhost:8080/jsonrpc"] + }), /** * The username set for Remote Control via Web Sever * */ - username: string + username: z.string().meta({ + description: "The username set for Remote Control via Web Sever" + }), /** * The password set for Remote Control via Web Sever * */ - password: string -} -export interface KodiSourceConfig extends CommonSourceConfig { - data: KodiData -} - -export interface KodiSourceAIOConfig extends KodiSourceConfig { - type: 'kodi' -} + password: z.string().meta({ + description: "The password set for Remote Control via Web Sever" + }), +}); + +export type KodiData = z.infer; + +export const kodiSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: kodiDataSchema, +}); + +export type KodiSourceConfig = z.infer; + +export const kodiSourceAIOConfigSchema = z.object({ + ...kodiSourceConfigSchema.shape, + type: z.literal('kodi'), +}); + +export type KodiSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/koito.ts b/src/backend/common/infrastructure/config/source/koito.ts index 784bb018..c8bb1a73 100644 --- a/src/backend/common/infrastructure/config/source/koito.ts +++ b/src/backend/common/infrastructure/config/source/koito.ts @@ -1,21 +1,37 @@ -import type {KoitoData} from "../client/koito.ts"; -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {koitoDataSchema} from "../client/koito.ts"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface KoitoSourceData extends KoitoData, CommonSourceData, PollingOptions { -} +export const koitoSourceDataSchema = z.object({ + ...koitoDataSchema.shape, + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, +}); -export interface KoitoSourceConfig extends CommonSourceConfig { +export type KoitoSourceData = z.infer; + +export const koitoSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, /** * When used in `koito.config` this tells multi-scrobbler whether to use this data to configure a source or client. * * @default source * @examples ["source"] * */ - configureAs: 'source' - data: KoitoSourceData -} + configureAs: z.literal('source').meta({ + description: "When used in `koito.config` this tells multi-scrobbler whether to use this data to configure a source or client.", + default: "source", + examples: ["source"] + }), + data: koitoSourceDataSchema, +}); + +export type KoitoSourceConfig = z.infer; + +export const koitoSourceAIOConfigSchema = z.object({ + ...koitoSourceConfigSchema.shape, + type: z.literal('koito'), +}); -export interface KoitoSourceAIOConfig extends KoitoSourceConfig { - type: 'koito' -} +export type KoitoSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/lastfm.ts b/src/backend/common/infrastructure/config/source/lastfm.ts index 4fb02ca3..5649d493 100644 --- a/src/backend/common/infrastructure/config/source/lastfm.ts +++ b/src/backend/common/infrastructure/config/source/lastfm.ts @@ -1,20 +1,37 @@ -import type {LastfmData} from "../client/lastfm.ts"; -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {lastfmDataSchema} from "../client/lastfm.ts"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface LastFmSourceData extends CommonSourceData, PollingOptions, LastfmData{} +export const lastFmSourceDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, + ...lastfmDataSchema.shape, +}); -export interface LastfmSourceConfig extends CommonSourceConfig { +export type LastFmSourceData = z.infer; + +export const lastfmSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, /** * When used in `lastfm.config` this tells multi-scrobbler whether to use this data to configure a source or client. * * @default source * @examples ["source"] * */ - configureAs: 'source' - data: LastFmSourceData -} + configureAs: z.literal('source').meta({ + description: "When used in `lastfm.config` this tells multi-scrobbler whether to use this data to configure a source or client.", + default: "source", + examples: ["source"] + }), + data: lastFmSourceDataSchema, +}); + +export type LastfmSourceConfig = z.infer; + +export const lastFmSouceAIOConfigSchema = z.object({ + ...lastfmSourceConfigSchema.shape, + type: z.literal('lastfm'), +}); -export interface LastFmSouceAIOConfig extends LastfmSourceConfig { - type: 'lastfm' -} +export type LastFmSouceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/librefm.ts b/src/backend/common/infrastructure/config/source/librefm.ts index 2637e6a8..141fe519 100644 --- a/src/backend/common/infrastructure/config/source/librefm.ts +++ b/src/backend/common/infrastructure/config/source/librefm.ts @@ -1,20 +1,37 @@ -import type {LibrefmData} from "../client/librefm.ts"; -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {librefmDataSchema} from "../client/librefm.ts"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface librefmSourceData extends CommonSourceData, PollingOptions, LibrefmData{} +export const librefmSourceDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, + ...librefmDataSchema.shape, +}); -export interface LibrefmSourceConfig extends CommonSourceConfig { +export type librefmSourceData = z.infer; + +export const librefmSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, /** * When used in `librefm.config` this tells multi-scrobbler whether to use this data to configure a source or client. * * @default source * @examples ["source"] * */ - configureAs?: 'source' - data: LibrefmData -} + configureAs: z.literal('source').optional().meta({ + description: "When used in `librefm.config` this tells multi-scrobbler whether to use this data to configure a source or client.", + default: "source", + examples: ["source"] + }), + data: librefmDataSchema, +}); + +export type LibrefmSourceConfig = z.infer; + +export const librefmSouceAIOConfigSchema = z.object({ + ...librefmSourceConfigSchema.shape, + type: z.literal('librefm'), +}); -export interface LibrefmSouceAIOConfig extends LibrefmSourceConfig { - type: 'librefm' -} +export type LibrefmSouceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/listenbrainz.ts b/src/backend/common/infrastructure/config/source/listenbrainz.ts index 029fde0b..cf84e35f 100644 --- a/src/backend/common/infrastructure/config/source/listenbrainz.ts +++ b/src/backend/common/infrastructure/config/source/listenbrainz.ts @@ -1,21 +1,37 @@ -import type {ListenBrainzData} from "../client/listenbrainz.ts"; -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {listenBrainzDataSchema} from "../client/listenbrainz.ts"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface ListenBrainzSourceData extends ListenBrainzData, CommonSourceData, PollingOptions { -} +export const listenBrainzSourceDataSchema = z.object({ + ...listenBrainzDataSchema.shape, + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, +}); -export interface ListenBrainzSourceConfig extends CommonSourceConfig { +export type ListenBrainzSourceData = z.infer; + +export const listenBrainzSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, /** * When used in `listenbrainz.config` this tells multi-scrobbler whether to use this data to configure a source or client. * * @default source * @examples ["source"] * */ - configureAs: 'source' - data: ListenBrainzSourceData -} + configureAs: z.literal('source').meta({ + description: "When used in `listenbrainz.config` this tells multi-scrobbler whether to use this data to configure a source or client.", + default: "source", + examples: ["source"] + }), + data: listenBrainzSourceDataSchema, +}); + +export type ListenBrainzSourceConfig = z.infer; + +export const listenBrainzSourceAIOConfigSchema = z.object({ + ...listenBrainzSourceConfigSchema.shape, + type: z.literal('listenbrainz'), +}); -export interface ListenBrainzSourceAIOConfig extends ListenBrainzSourceConfig { - type: 'listenbrainz' -} +export type ListenBrainzSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/maloja.ts b/src/backend/common/infrastructure/config/source/maloja.ts index 67fed754..4ff6ffb2 100644 --- a/src/backend/common/infrastructure/config/source/maloja.ts +++ b/src/backend/common/infrastructure/config/source/maloja.ts @@ -1,21 +1,37 @@ -import type {MalojaData} from "../client/maloja.ts"; -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {malojaDataSchema} from "../client/maloja.ts"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface MalojaSourceData extends MalojaData, CommonSourceData, PollingOptions { -} +export const malojaSourceDataSchema = z.object({ + ...malojaDataSchema.shape, + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, +}); -export interface MalojaSourceConfig extends CommonSourceConfig { +export type MalojaSourceData = z.infer; + +export const malojaSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, /** * When used in `maloja.config` this tells multi-scrobbler whether to use this data to configure a source or client. * * @default source * @examples ["source"] * */ - configureAs: 'source' - data: MalojaSourceData -} + configureAs: z.literal('source').meta({ + description: "When used in `maloja.config` this tells multi-scrobbler whether to use this data to configure a source or client.", + default: "source", + examples: ["source"] + }), + data: malojaSourceDataSchema, +}); + +export type MalojaSourceConfig = z.infer; + +export const malojaSourceAIOConfigSchema = z.object({ + ...malojaSourceConfigSchema.shape, + type: z.literal('maloja'), +}); -export interface MalojaSourceAIOConfig extends MalojaSourceConfig { - type: 'maloja' -} +export type MalojaSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/mopidy.ts b/src/backend/common/infrastructure/config/source/mopidy.ts index 5acd3b93..d5aed420 100644 --- a/src/backend/common/infrastructure/config/source/mopidy.ts +++ b/src/backend/common/infrastructure/config/source/mopidy.ts @@ -1,7 +1,10 @@ -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface MopidyData extends CommonSourceData, PollingOptions { +export const mopidyDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, /** * URL of the Mopidy HTTP server to connect to * @@ -22,7 +25,11 @@ export interface MopidyData extends CommonSourceData, PollingOptions { * @examples ["ws://localhost:6680/mopidy/ws/"] * @default "ws://localhost:6680/mopidy/ws/" * */ - url?: string + url: z.string().optional().meta({ + description: "URL of the Mopidy HTTP server to connect to", + default: "ws://localhost:6680/mopidy/ws/", + examples: ["ws://localhost:6680/mopidy/ws/"] + }), /** * Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive @@ -31,7 +38,9 @@ export interface MopidyData extends CommonSourceData, PollingOptions { * * List is ignored if uriWhitelist is used. * */ - uriBlacklist?: string[] + uriBlacklist: z.array(z.string()).optional().meta({ + description: "Do not scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive" + }), /** * Only scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive @@ -39,7 +48,9 @@ export interface MopidyData extends CommonSourceData, PollingOptions { * EX: Only scrobble tracks from soundcloud by adding 'soundcloud' to this list. * * */ - uriWhitelist?: string[] + uriWhitelist: z.array(z.string()).optional().meta({ + description: "Only scrobble tracks whose URI STARTS WITH any of these strings, case-insensitive" + }), /** * Remove album data that matches any case-insensitive string from this list when scrobbling, @@ -49,7 +60,11 @@ export interface MopidyData extends CommonSourceData, PollingOptions { * @examples [["Soundcloud", "Mixcloud"]] * @default ["Soundcloud"] * */ - albumBlacklist?: string[] + albumBlacklist: z.array(z.string()).optional().meta({ + description: "Remove album data that matches any case-insensitive string from this list when scrobbling,", + default: ["Soundcloud"], + examples: [["Soundcloud", "Mixcloud"]] + }), /** * How long to wait before polling the source API for new tracks (in seconds) @@ -57,7 +72,11 @@ export interface MopidyData extends CommonSourceData, 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) @@ -65,12 +84,25 @@ export interface MopidyData extends CommonSourceData, PollingOptions { * @default 30 * @examples [30] * */ - maxInterval?: number -} -export interface MopidySourceConfig extends CommonSourceConfig { - data: MopidyData -} + 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] + }), +}); -export interface MopidySourceAIOConfig extends MopidySourceConfig { - type: 'mopidy' -} +export type MopidyData = z.infer; + +export const mopidySourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: mopidyDataSchema, +}); + +export type MopidySourceConfig = z.infer; + +export const mopidySourceAIOConfigSchema = z.object({ + ...mopidySourceConfigSchema.shape, + type: z.literal('mopidy'), +}); + +export type MopidySourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/mpd.ts b/src/backend/common/infrastructure/config/source/mpd.ts index d6f31913..a979188c 100644 --- a/src/backend/common/infrastructure/config/source/mpd.ts +++ b/src/backend/common/infrastructure/config/source/mpd.ts @@ -1,7 +1,10 @@ -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; +import * as z from "zod"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema} from "./index.ts"; -export interface MPDData extends CommonSourceData, PollingOptions { +export const mpdDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, /** * URL:PORT of the MPD server to connect to * @@ -10,33 +13,51 @@ export interface MPDData extends CommonSourceData, PollingOptions { * @examples ["localhost:6600"] * @default "localhost:6600" * */ - url?: string + url: z.string().optional().meta({ + description: "URL:PORT of the MPD server to connect to", + default: "localhost:6600", + examples: ["localhost:6600"] + }), /** * If using socket specify the path instead of url. * * trailing `~` is replaced by your home directory * */ - path?: string + path: z.string().optional().meta({ + description: "If using socket specify the path instead of url." + }), /** * Password for the server, if set https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords * */ - password?: string + password: z.string().optional().meta({ + description: "Password for the server, if set https://mpd.readthedocs.io/en/stable/user.html#permissions-and-passwords" + }), -} +}); -export interface MPDSourceOptions extends CommonSourceOptions { - //disableDiscovery?: boolean -} +export type MPDData = z.infer; -export interface MPDSourceConfig extends CommonSourceConfig { - data: MPDData - options: MPDSourceOptions -} +export const mpdSourceOptionsSchema = z.object({ + ...commonSourceOptionsSchema.shape, +}); -export interface MPDSourceAIOConfig extends MPDSourceConfig { - type: 'mpd' -} +export type MPDSourceOptions = z.infer; -export type PlayerState = 'play' | 'stop' | 'pause'; \ No newline at end of file +export const mpdSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: mpdDataSchema, + options: mpdSourceOptionsSchema, +}); + +export type MPDSourceConfig = z.infer; + +export const mpdSourceAIOConfigSchema = z.object({ + ...mpdSourceConfigSchema.shape, + type: z.literal('mpd'), +}); + +export type MPDSourceAIOConfig = z.infer; + +export type PlayerState = 'play' | 'stop' | 'pause'; diff --git a/src/backend/common/infrastructure/config/source/mpris.ts b/src/backend/common/infrastructure/config/source/mpris.ts index d5ad3395..1f59c7b7 100644 --- a/src/backend/common/infrastructure/config/source/mpris.ts +++ b/src/backend/common/infrastructure/config/source/mpris.ts @@ -1,4 +1,5 @@ -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; export const PLAYBACK_STATUS_PLAYING = 'Playing'; export const PLAYBACK_STATUS_PAUSED = 'Paused'; @@ -28,13 +29,17 @@ export interface PlayerInfo { metadata: MPRISMetadata } -export interface MPRISData extends CommonSourceData { +export const mprisDataSchema = z.object({ + ...commonSourceDataSchema.shape, /** * DO NOT scrobble from any players that START WITH these values, case-insensitive * * @examples [["spotify","vlc"]] * */ - blacklist?: string | string[] + blacklist: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "DO NOT scrobble from any players that START WITH these values, case-insensitive", + examples: [["spotify", "vlc"]] + }), /** * ONLY from any players that START WITH these values, case-insensitive @@ -43,13 +48,24 @@ export interface MPRISData extends CommonSourceData { * * @examples [["spotify","vlc"]] * */ - whitelist?: string | string[] -} + whitelist: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "ONLY from any players that START WITH these values, case-insensitive", + examples: [["spotify", "vlc"]] + }), +}); -export interface MPRISSourceConfig extends CommonSourceConfig { - data: MPRISData -} +export type MPRISData = z.infer; -export interface MPRISSourceAIOConfig extends MPRISSourceConfig { - type: 'mpris' -} +export const mprisSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: mprisDataSchema, +}); + +export type MPRISSourceConfig = z.infer; + +export const mprisSourceAIOConfigSchema = z.object({ + ...mprisSourceConfigSchema.shape, + type: z.literal('mpris'), +}); + +export type MPRISSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/musiccast.ts b/src/backend/common/infrastructure/config/source/musiccast.ts index 5c433056..4ffc6ff8 100644 --- a/src/backend/common/infrastructure/config/source/musiccast.ts +++ b/src/backend/common/infrastructure/config/source/musiccast.ts @@ -1,6 +1,7 @@ +import * as z from "zod"; import { REPORTED_PLAYER_STATUSES } from '../../../../../core/Atomic.ts'; import type {ReportedPlayerStatus} from '../../../../../core/Atomic.ts'; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; export type PlaybackStatus = 'play' | 'stop' | 'pause' | 'fast_reverse' | 'fast_forward' @@ -70,19 +71,31 @@ export const playbackToReportedStatus = (pb: PlaybackStatus): ReportedPlayerStat } } -export interface MusicCastData extends CommonSourceData { +export const musicCastDataSchema = z.object({ + ...commonSourceDataSchema.shape, /** * The host or URL of the YamahaExtendedControl endpoint to use * * @examples [["192.168.0.101","http://192.168.0.101/YamahaExtendedControl"]] * */ - url: string -} + url: z.string().meta({ + description: "The host or URL of the YamahaExtendedControl endpoint to use", + examples: [["192.168.0.101", "http://192.168.0.101/YamahaExtendedControl"]] + }), +}); -export interface MusicCastSourceConfig extends CommonSourceConfig { - data: MusicCastData -} +export type MusicCastData = z.infer; -export interface MusicCastSourceAIOConfig extends MusicCastSourceConfig { - type: 'musiccast' -} +export const musicCastSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: musicCastDataSchema, +}); + +export type MusicCastSourceConfig = z.infer; + +export const musicCastSourceAIOConfigSchema = z.object({ + ...musicCastSourceConfigSchema.shape, + type: z.literal('musiccast'), +}); + +export type MusicCastSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/musikcube.ts b/src/backend/common/infrastructure/config/source/musikcube.ts index bcbc6dc2..0e1a9afd 100644 --- a/src/backend/common/infrastructure/config/source/musikcube.ts +++ b/src/backend/common/infrastructure/config/source/musikcube.ts @@ -1,4 +1,5 @@ -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; export const PLAYBACK_STATUS_PLAYING_MC = 'playing'; export const PLAYBACK_STATUS_PAUSED_MC = 'paused'; @@ -75,7 +76,8 @@ export interface MCPlaybackOverviewRequest extends MCRequestCommon { device_id: string } -export interface MusikcubeData extends CommonSourceData { +export const musikcubeDataSchema = z.object({ + ...commonSourceDataSchema.shape, /** * URL of the Musikcube Websocket (Metadata) server to connect to * @@ -94,23 +96,37 @@ export interface MusikcubeData extends CommonSourceData { * @examples ["ws://localhost:7905"] * @default "ws://localhost:7905" * */ - url?: string + url: z.string().optional().meta({ + description: "URL of the Musikcube Websocket (Metadata) server to connect to", + default: "ws://localhost:7905", + examples: ["ws://localhost:7905"] + }), /** * Password set in Musikcube https://github.com/clangen/musikcube/wiki/remote-api-documentation * * * musikcube -> settings -> server setup -> password * */ - password: string + password: z.string().meta({ + description: "Password set in Musikcube https://github.com/clangen/musikcube/wiki/remote-api-documentation" + }), - device_id?: string + device_id: z.string().optional(), -} +}); -export interface MusikcubeSourceConfig extends CommonSourceConfig { - data: MusikcubeData -} +export type MusikcubeData = z.infer; -export interface MusikcubeSourceAIOConfig extends MusikcubeSourceConfig { - type: 'musikcube' -} +export const musikcubeSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: musikcubeDataSchema, +}); + +export type MusikcubeSourceConfig = z.infer; + +export const musikcubeSourceAIOConfigSchema = z.object({ + ...musikcubeSourceConfigSchema.shape, + type: z.literal('musikcube'), +}); + +export type MusikcubeSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/plex.ts b/src/backend/common/infrastructure/config/source/plex.ts index 54882b75..d27f1d4e 100644 --- a/src/backend/common/infrastructure/config/source/plex.ts +++ b/src/backend/common/infrastructure/config/source/plex.ts @@ -1,63 +1,92 @@ -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; +import * as z from "zod"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema} from "./index.ts"; -export interface PlexApiData extends CommonSourceData, PollingOptions { - token?: string +export const plexApiDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, + token: z.string().optional(), /** * http(s)://HOST:PORT of the Plex server to connect to * */ - url: string + url: z.string().meta({ + description: "http(s)://HOST:PORT of the Plex server to connect to" + }), - /** + /** * Only scrobble for specific users (case-insensitive) * * If `true` MS will scrobble activity from all users * */ - usersAllow?: string | true | string[] - /** - * Do not scrobble for these users (case-insensitive) - * */ - usersBlock?: string | string[] - - /** - * Only scrobble if device or application name contains strings from this list (case-insensitive) - * */ - devicesAllow?: string | string[] - /** - * Do not scrobble if device or application name contains strings from this list (case-insensitive) - * */ - devicesBlock?: string | string[] - - /** - * Only scrobble if library name contains string from this list (case-insensitive) - * */ - librariesAllow?: string | string[] - /** - * Do not scrobble if library name contains strings from this list (case-insensitive) - * */ - librariesBlock?: string | string[] -} - -export interface PlexApiOptions extends CommonSourceOptions { - /** + usersAllow: z.union([z.string(), z.literal(true), z.array(z.string())]).optional().meta({ + description: "Only scrobble for specific users (case-insensitive)" + }), + /** + * Do not scrobble for these users (case-insensitive) + * */ + usersBlock: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Do not scrobble for these users (case-insensitive)" + }), + + /** + * Only scrobble if device or application name contains strings from this list (case-insensitive) + * */ + devicesAllow: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Only scrobble if device or application name contains strings from this list (case-insensitive)" + }), + /** + * Do not scrobble if device or application name contains strings from this list (case-insensitive) + * */ + devicesBlock: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Do not scrobble if device or application name contains strings from this list (case-insensitive)" + }), + + /** + * Only scrobble if library name contains string from this list (case-insensitive) + * */ + librariesAllow: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Only scrobble if library name contains string from this list (case-insensitive)" + }), + /** + * Do not scrobble if library name contains strings from this list (case-insensitive) + * */ + librariesBlock: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Do not scrobble if library name contains strings from this list (case-insensitive)" + }), +}); + +export type PlexApiData = z.infer; + +export const plexApiOptionsSchema = z.object({ + ...commonSourceOptionsSchema.shape, + /** * Ignore invalid cert errors when connecting to Plex - * + * * Useful for Plex servers using "Required" Secure Connections with self-signed certificates - * + * * Do not enable unless you know you need this. - * + * * @default false */ - ignoreInvalidCert?: boolean -} + ignoreInvalidCert: z.boolean().optional().meta({ + description: "Ignore invalid cert errors when connecting to Plex", + default: false + }), +}); + +export type PlexApiOptions = z.infer; + +export const plexApiSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: plexApiDataSchema, + options: plexApiOptionsSchema.optional(), +}); -export interface PlexApiSourceConfig extends CommonSourceConfig { - data: PlexApiData - options?: PlexApiOptions -} +export type PlexApiSourceConfig = z.infer; -export interface PlexApiSourceAIOConfig extends PlexApiSourceConfig { - type: 'plex' -} +export const plexApiSourceAIOConfigSchema = z.object({ + ...plexApiSourceConfigSchema.shape, + type: z.literal('plex'), +}); -export type PlexCompatConfig = PlexApiSourceConfig; \ No newline at end of file +export type PlexApiSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/rocksky.ts b/src/backend/common/infrastructure/config/source/rocksky.ts index df1d03c1..4b84a64f 100644 --- a/src/backend/common/infrastructure/config/source/rocksky.ts +++ b/src/backend/common/infrastructure/config/source/rocksky.ts @@ -1,26 +1,45 @@ -import type {RockSkyData, RockSkyOptions} from "../client/rocksky.ts"; -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; +import * as z from "zod"; +import {rockSkyDataSchema, rockSkyOptionsSchema} from "../client/rocksky.ts"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema} from "./index.ts"; -export interface RockskySourceData extends RockSkyData, CommonSourceData, PollingOptions { -} +export const rockskySourceDataSchema = z.object({ + ...rockSkyDataSchema.shape, + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, +}); -export interface RockskySourceOptions extends RockSkyOptions, CommonSourceOptions { +export type RockskySourceData = z.infer; -} +export const rockskySourceOptionsSchema = z.object({ + ...rockSkyOptionsSchema.shape, + ...commonSourceOptionsSchema.shape, +}); -export interface RockskySourceConfig extends CommonSourceConfig { +export type RockskySourceOptions = z.infer; + +export const rockskySourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, /** * When used in `rocksky.config` this tells multi-scrobbler whether to use this data to configure a source or client. * * @default source * @examples ["source"] * */ - configureAs: 'source' - data: RockskySourceData - options?: RockskySourceOptions -} - -export interface RockskySourceAIOConfig extends RockskySourceConfig { - type: 'rocksky' -} + configureAs: z.literal('source').meta({ + description: "When used in `rocksky.config` this tells multi-scrobbler whether to use this data to configure a source or client.", + default: "source", + examples: ["source"] + }), + data: rockskySourceDataSchema, + options: rockskySourceOptionsSchema.optional(), +}); + +export type RockskySourceConfig = z.infer; + +export const rockskySourceAIOConfigSchema = z.object({ + ...rockskySourceConfigSchema.shape, + type: z.literal('rocksky'), +}); + +export type RockskySourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/sonos.ts b/src/backend/common/infrastructure/config/source/sonos.ts index df55522f..8b5ecba2 100644 --- a/src/backend/common/infrastructure/config/source/sonos.ts +++ b/src/backend/common/infrastructure/config/source/sonos.ts @@ -1,41 +1,67 @@ -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; +import * as z from "zod"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema} from "./index.ts"; -export interface SonosData extends CommonSourceData, PollingOptions { +export const sonosDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, /** * IP address of any connected Sonos speaker or device * * @examples ["192.168.0.170"] * */ - host: string + host: z.string().meta({ + description: "IP address of any connected Sonos speaker or device", + examples: ["192.168.0.170"] + }), /** * Only scrobble if device name contains strings from this list (case-insensitive) * */ - devicesAllow?: string | string[] + devicesAllow: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Only scrobble if device name contains strings from this list (case-insensitive)" + }), /** * Do not scrobble if device name contains strings from this list (case-insensitive) * */ - devicesBlock?: string | string[] + devicesBlock: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Do not scrobble if device name contains strings from this list (case-insensitive)" + }), /** * Only scrobble if the name of a group the playing device belongs to contains strings from this list (case-insensitive) * */ - groupsAllow?: string | string[] + groupsAllow: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Only scrobble if the name of a group the playing device belongs to contains strings from this list (case-insensitive)" + }), /** * Do not scrobble if the name of a group the playing device belongs to contains strings from this list (case-insensitive) * */ - groupsBlock?: string | string[] -} - -export interface SonosSourceOptions extends CommonSourceOptions { - logEmptyPlayer?: boolean -} -export interface SonosSourceConfig extends CommonSourceConfig { - data: SonosData - options?: SonosSourceOptions -} - -export interface SonosSourceAIOConfig extends SonosSourceConfig { - type: 'sonos' -} + groupsBlock: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Do not scrobble if the name of a group the playing device belongs to contains strings from this list (case-insensitive)" + }), +}); + +export type SonosData = z.infer; + +export const sonosSourceOptionsSchema = z.object({ + ...commonSourceOptionsSchema.shape, + logEmptyPlayer: z.boolean().optional(), +}); + +export type SonosSourceOptions = z.infer; + +export const sonosSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: sonosDataSchema, + options: sonosSourceOptionsSchema.optional(), +}); + +export type SonosSourceConfig = z.infer; + +export const sonosSourceAIOConfigSchema = z.object({ + ...sonosSourceConfigSchema.shape, + type: z.literal('sonos'), +}); + +export type SonosSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/spotify.ts b/src/backend/common/infrastructure/config/source/spotify.ts index ab94ca20..cb7381af 100644 --- a/src/backend/common/infrastructure/config/source/spotify.ts +++ b/src/backend/common/infrastructure/config/source/spotify.ts @@ -1,26 +1,39 @@ -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface SpotifySourceData extends CommonSourceData, PollingOptions { +export const spotifySourceDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, /** * spotify client id * * @examples ["787c921a2a2ab42320831aba0c8f2fc2"] * */ - clientId: string + clientId: z.string().meta({ + description: "spotify client id", + examples: ["787c921a2a2ab42320831aba0c8f2fc2"] + }), /** * spotify client secret * * @examples ["ec42e09d5ae0ee0f0816ca151008412a"] * */ - clientSecret: string + clientSecret: z.string().meta({ + description: "spotify client secret", + examples: ["ec42e09d5ae0ee0f0816ca151008412a"] + }), /** * spotify redirect URI -- required only if not the default shown here. URI must end in "callback" * * @default "http://localhost:9078/callback" * @examples ["http://localhost:9078/callback"] * */ - redirectUri?: string + redirectUri: z.string().optional().meta({ + description: "spotify redirect URI -- required only if not the default shown here.", + default: "http://localhost:9078/callback", + examples: ["http://localhost:9078/callback"] + }), /** * How long to wait before polling the source API for new tracks (in seconds) * @@ -36,13 +49,25 @@ export interface SpotifySourceData extends CommonSourceData, 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] + }), +}); -export interface SpotifySourceConfig extends CommonSourceConfig { - data: SpotifySourceData -} +export type SpotifySourceData = z.infer; -export interface SpotifySourceAIOConfig extends SpotifySourceConfig { - type: 'spotify' -} +export const spotifySourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: spotifySourceDataSchema, +}); + +export type SpotifySourceConfig = z.infer; + +export const spotifySourceAIOConfigSchema = z.object({ + ...spotifySourceConfigSchema.shape, + type: z.literal('spotify'), +}); + +export type SpotifySourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/subsonic.ts b/src/backend/common/infrastructure/config/source/subsonic.ts index aa917e03..f2c730a5 100644 --- a/src/backend/common/infrastructure/config/source/subsonic.ts +++ b/src/backend/common/infrastructure/config/source/subsonic.ts @@ -1,26 +1,37 @@ -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface SubsonicData extends CommonSourceData, PollingOptions { +export const subsonicDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, /** * URL of the subsonic media server to query * * @examples ["http://airsonic.local"] * */ - url: string + url: z.string().meta({ + description: "URL of the subsonic media server to query", + examples: ["http://airsonic.local"] + }), /** * Username to login to the server with * * @example ["MyUser"] * */ - user: string + user: z.string().meta({ + description: "Username to login to the server with" + }), /** * Password for the user to login to the server with * * @examples ["MyPassword"] * */ - password: string + password: z.string().meta({ + description: "Password for the user to login to the server with", + examples: ["MyPassword"] + }), /** * How long to wait before polling the source API for new tracks (in seconds) @@ -28,7 +39,11 @@ export interface SubsonicData extends CommonSourceData, 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) @@ -36,7 +51,11 @@ export interface SubsonicData extends CommonSourceData, 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] + }), /** * If your subsonic server is using self-signed certs you may need to disable TLS errors in order to get a connection @@ -45,7 +64,10 @@ export interface SubsonicData extends CommonSourceData, PollingOptions { * * @default false * */ - ignoreTlsErrors?: boolean + ignoreTlsErrors: z.boolean().optional().meta({ + description: "If your subsonic server is using self-signed certs you may need to disable TLS errors in order to get a connection", + default: false + }), /** * Older Subsonic versions, and some badly implemented servers (Nextcloud), use legacy authentication which sends your password in CLEAR TEXT. This is less secure than the newer, recommended hashing authentication method but in some cases it is needed. See "Authentication" section here => https://www.subsonic.org/pages/api.jsp @@ -54,19 +76,33 @@ export interface SubsonicData extends CommonSourceData, PollingOptions { * * @default false * */ - legacyAuthentication?: boolean + legacyAuthentication: z.boolean().optional().meta({ + description: "Older Subsonic versions, and some badly implemented servers (Nextcloud), use legacy authentication which sends your password in CLEAR TEXT.", + default: false + }), /** * Only scrobble for specific users (case-insensitive) * * If undefined or an empty string/list MS will scrobble activity from all users * */ - usersAllow?: string | string[] -} -export interface SubSonicSourceConfig extends CommonSourceConfig { - data: SubsonicData -} + usersAllow: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Only scrobble for specific users (case-insensitive)" + }), +}); -export interface SubsonicSourceAIOConfig extends SubSonicSourceConfig { - type: 'subsonic' -} +export type SubsonicData = z.infer; + +export const subSonicSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: subsonicDataSchema, +}); + +export type SubSonicSourceConfig = z.infer; + +export const subsonicSourceAIOConfigSchema = z.object({ + ...subSonicSourceConfigSchema.shape, + type: z.literal('subsonic'), +}); + +export type SubsonicSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/tealfm.ts b/src/backend/common/infrastructure/config/source/tealfm.ts index 802db69d..75470aa4 100644 --- a/src/backend/common/infrastructure/config/source/tealfm.ts +++ b/src/backend/common/infrastructure/config/source/tealfm.ts @@ -1,28 +1,47 @@ -import type {TealData, TealOptions} from "../client/tealfm.ts"; -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; +import * as z from "zod"; +import {tealClientOptionsSchema, tealDataSchema} from "../client/tealfm.ts"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema} from "./index.ts"; +export const tealSourceDataSchema = z.object({ + ...tealDataSchema.shape, + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, + serviceAllow: z.array(z.string()).optional(), + serviceDeny: z.array(z.string()).optional(), +}); -export interface TealSourceData extends TealData, CommonSourceData, PollingOptions { - serviceAllow?: string[] - serviceDeny?: string[] -} +export type TealSourceData = z.infer; -export interface TealSourceConfig extends CommonSourceConfig { +export const tealSourceOptionsSchema = z.object({ + ...commonSourceOptionsSchema.shape, + ...tealClientOptionsSchema.shape, +}); + +export type TealSourceOptions = z.infer; + +export const tealSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, /** * Should always be `souce` when using Tealfm as a Source * * @default source * @examples ["source"] * */ - configureAs: 'source' - data: TealSourceData - options?: TealSourceOptions -} + configureAs: z.literal('source').meta({ + description: "Should always be `souce` when using Tealfm as a Source", + default: "source", + examples: ["source"] + }), + data: tealSourceDataSchema, + options: tealSourceOptionsSchema.optional(), +}); + +export type TealSourceConfig = z.infer; -export interface TealSourceOptions extends CommonSourceOptions, TealOptions { -} +export const tealSourceAIOConfigSchema = z.object({ + ...tealSourceConfigSchema.shape, + type: z.literal('tealfm'), +}); -export interface TealSourceAIOConfig extends TealSourceConfig { - type: 'tealfm' -} \ No newline at end of file +export type TealSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/vlc.ts b/src/backend/common/infrastructure/config/source/vlc.ts index f75c829f..10a4d992 100644 --- a/src/backend/common/infrastructure/config/source/vlc.ts +++ b/src/backend/common/infrastructure/config/source/vlc.ts @@ -1,8 +1,11 @@ +import * as z from "zod"; import type {VlcMeta} from "vlc-client/dist/Types.js"; -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema} from "./index.ts"; -export interface VLCData extends CommonSourceData, PollingOptions { +export const vlcDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, /** * URL:PORT of the VLC server to connect to * @@ -11,27 +14,41 @@ export interface VLCData extends CommonSourceData, PollingOptions { * @examples ["localhost:8080"] * @default "localhost:8080" * */ - url?: string + url: z.string().optional().meta({ + description: "URL:PORT of the VLC server to connect to", + default: "localhost:8080", + examples: ["localhost:8080"] + }), /** * Password for the server * */ - password: string + password: z.string().meta({ + description: "Password for the server" + }), -} +}); + +export type VLCData = z.infer; -export interface VLCSourceOptions extends CommonSourceOptions { +export const vlcSourceOptionsSchema = z.object({ + ...commonSourceOptionsSchema.shape, /** A list of regular expressions to use to extract metadata (title, album, artist) from a filename * * Used when VLC reports only the filename for the current audio track * */ - filenamePatterns?: string | string[] + filenamePatterns: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "A list of regular expressions to use to extract metadata (title, album, artist) from a filename" + }), /** * Log to DEBUG when a filename-only track is matched or not matched by filenamePatterns * * @default false * */ - logFilenamePatterns?: boolean + logFilenamePatterns: z.boolean().optional().meta({ + description: "Log to DEBUG when a filename-only track is matched or not matched by filenamePatterns", + default: false + }), /** * Dump all the metadata VLC reports for an audio track to DEBUG. * @@ -39,17 +56,28 @@ export interface VLCSourceOptions extends CommonSourceOptions { * * @default false * */ - dumpVlcMetadata?: boolean -} + dumpVlcMetadata: z.boolean().optional().meta({ + description: "Dump all the metadata VLC reports for an audio track to DEBUG.", + default: false + }), +}); -export interface VLCSourceConfig extends CommonSourceConfig { - data: VLCData - options?: VLCSourceOptions -} +export type VLCSourceOptions = z.infer; -export interface VLCSourceAIOConfig extends VLCSourceConfig { - type: 'vlc' -} +export const vlcSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: vlcDataSchema, + options: vlcSourceOptionsSchema.optional(), +}); + +export type VLCSourceConfig = z.infer; + +export const vlcSourceAIOConfigSchema = z.object({ + ...vlcSourceConfigSchema.shape, + type: z.literal('vlc'), +}); + +export type VLCSourceAIOConfig = z.infer; export type PlayerState = 'playing' | 'stopped' | 'paused'; diff --git a/src/backend/common/infrastructure/config/source/webscrobbler.ts b/src/backend/common/infrastructure/config/source/webscrobbler.ts index f90d1c67..1b534c14 100644 --- a/src/backend/common/infrastructure/config/source/webscrobbler.ts +++ b/src/backend/common/infrastructure/config/source/webscrobbler.ts @@ -1,6 +1,8 @@ -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface WebScrobblerData extends CommonSourceData { +export const webScrobblerDataSchema = z.object({ + ...commonSourceDataSchema.shape, /** * The URL ending that should be used to identify scrobbles for this source * @@ -17,27 +19,43 @@ export interface WebScrobblerData extends CommonSourceData { * * If no slug is found from an extension's incoming webhook event the first WebScrobbler source without a slug will be used * */ - slug?: string | null + slug: z.union([z.string(), z.null()]).optional().meta({ + description: "The URL ending that should be used to identify scrobbles for this source" + }), /** * Block scrobbling from specific WebScrobbler Connectors * - * @examples [["youtube"]] + * @examples ["youtube"] * */ - blacklist?: string | string[] + blacklist: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Block scrobbling from specific WebScrobbler Connectors", + examples: ["youtube"] + }), /** * Only allow scrobbling from specific WebScrobbler Connectors * - * @examples [["mixcloud","soundcloud","bandcamp"]] + * @examples ["mixcloud","soundcloud","bandcamp"] * */ - whitelist?: string | string[] -} + whitelist: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Only allow scrobbling from specific WebScrobbler Connectors", + examples: ["mixcloud", "soundcloud", "bandcamp"] + }), +}); -export interface WebScrobblerSourceConfig extends CommonSourceConfig { - data?: WebScrobblerData -} +export type WebScrobblerData = z.infer; -export interface WebScrobblerSourceAIOConfig extends WebScrobblerSourceConfig { - type: 'webscrobbler' -} +export const webScrobblerSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: webScrobblerDataSchema.optional(), +}); + +export type WebScrobblerSourceConfig = z.infer; + +export const webScrobblerSourceAIOConfigSchema = z.object({ + ...webScrobblerSourceConfigSchema.shape, + type: z.literal('webscrobbler'), +}); + +export type WebScrobblerSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/ymbridge.ts b/src/backend/common/infrastructure/config/source/ymbridge.ts index b9dde3a8..d4adc7e6 100644 --- a/src/backend/common/infrastructure/config/source/ymbridge.ts +++ b/src/backend/common/infrastructure/config/source/ymbridge.ts @@ -1,17 +1,32 @@ -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData} from "./index.ts"; +import * as z from "zod"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema} from "./index.ts"; -export interface YandexMusicBridgeData extends CommonSourceData, PollingOptions { +export const yandexMusicBridgeDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, /** URL of the local Python bridge, for example http://yandex-music-bridge:9980 */ - url: string + url: z.string().meta({ + description: "URL of the local Python bridge, for example http://yandex-music-bridge:9980" + }), /** Optional API key sent as X-API-Key to the bridge */ - apiKey?: string -} + apiKey: z.string().optional().meta({ + description: "Optional API key sent as X-API-Key to the bridge" + }), +}); -export interface YandexMusicBridgeSourceConfig extends CommonSourceConfig { - data?: YandexMusicBridgeData -} +export type YandexMusicBridgeData = z.infer; -export interface YandexMusicBridgeSourceAIOConfig extends YandexMusicBridgeSourceConfig { - type: 'ymbridge' -} +export const yandexMusicBridgeSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: yandexMusicBridgeDataSchema.optional(), +}); + +export type YandexMusicBridgeSourceConfig = z.infer; + +export const yandexMusicBridgeSourceAIOConfigSchema = z.object({ + ...yandexMusicBridgeSourceConfigSchema.shape, + type: z.literal('ymbridge'), +}); + +export type YandexMusicBridgeSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/ytmusic.ts b/src/backend/common/infrastructure/config/source/ytmusic.ts index 1d08c42a..d5339e38 100644 --- a/src/backend/common/infrastructure/config/source/ytmusic.ts +++ b/src/backend/common/infrastructure/config/source/ytmusic.ts @@ -1,41 +1,53 @@ -import type {PollingOptions} from "../common.ts"; -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; +import * as z from "zod"; +import {pollingOptionsSchema} from "../common.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema} from "./index.ts"; -export interface InnertubeOptions { +export const innertubeOptionsSchema = z.object({ /** * Proof of Origin token - * + * * May be required if YTM starts returning 403 - * + * * @see https://github.com/yt-dlp/yt-dlp/wiki/Extractors#po-token-guide */ - po_token?: string + po_token: z.string().optional().meta({ + description: "Proof of Origin token" + }), /** * Visitor ID value found in VISITOR_INFO1_LIVE or visitorData cookie - * + * * May be required if YTM starts returning 403 - * + * * @see https://github.com/yt-dlp/yt-dlp/wiki/Extractors#po-token-guide */ - visitor_data?: string + visitor_data: z.string().optional().meta({ + description: "Visitor ID value found in VISITOR_INFO1_LIVE or visitorData cookie" + }), /** * If account login results in being able to choose multiple account, use a zero-based index to choose which one to monitor - * + * * @examples [0,1] */ - account_index?: number + account_index: z.number().optional().meta({ + description: "If account login results in being able to choose multiple account, use a zero-based index to choose which one to monitor", + examples: [0, 1] + }), + + location: z.string().optional(), + lang: z.string().optional(), + generate_session_locally: z.boolean().optional(), + device_category: z.string().optional(), + client_type: z.string().optional(), + timezone: z.string().optional(), +}); - location?: string - lang?: string - generate_session_locally?: boolean - device_category?: string - client_type?: string - timezone?: string -} +export type InnertubeOptions = z.infer; -export interface YTMusicData extends CommonSourceData, PollingOptions { +export const ytMusicDataSchema = z.object({ + ...commonSourceDataSchema.shape, + ...pollingOptionsSchema.shape, /** * The cookie retrieved from the Request Headers of music.youtube.com after logging in. * @@ -43,55 +55,80 @@ export interface YTMusicData extends CommonSourceData, PollingOptions { * * @examples ["VISITOR_INFO1_LIVE=jMp2xA1Xz2_PbVc; __Secure-3PAPISID=3AxsXpy0M/AkISpjek; ..."] * */ - cookie?: string + cookie: z.string().optional().meta({ + description: "The cookie retrieved from the Request Headers of music.youtube.com after logging in.", + examples: ["VISITOR_INFO1_LIVE=jMp2xA1Xz2_PbVc; __Secure-3PAPISID=3AxsXpy0M/AkISpjek; ..."] + }), /** * Google Cloud Console project OAuth Client ID - * + * * Generated from a custom OAuth Client, see docs */ - clientId?: string - + clientId: z.string().optional().meta({ + description: "Google Cloud Console project OAuth Client ID" + }), + /** * Google Cloud Console project OAuth Client Secret - * + * * Generated from a custom OAuth Client, see docs */ - clientSecret?: string + clientSecret: z.string().optional().meta({ + description: "Google Cloud Console project OAuth Client Secret" + }), /** * Google Cloud Console project OAuth Client Authorized redirect URI - * + * * Generated from a custom OAuth Client, see docs. multi-scrobbler will generate a default based on BASE_URL. * Only specify this if the default does not work for you. */ - redirectUri?: string + redirectUri: z.string().optional().meta({ + description: "Google Cloud Console project OAuth Client Authorized redirect URI" + }), /** * Additional options for authorization and tailoring YTM client */ - innertubeOptions?: InnertubeOptions -} + innertubeOptions: innertubeOptionsSchema.optional().meta({ + description: "Additional options for authorization and tailoring YTM client" + }), +}); -export interface YTMusicSourceConfig extends CommonSourceConfig { - data?: YTMusicData - options?: CommonSourceOptions & { +export type YTMusicData = z.infer; + +export const ytMusicSourceConfigSchema = z.object({ + ...commonSourceConfigSchema.shape, + data: ytMusicDataSchema.optional(), + options: z.object({ + ...commonSourceOptionsSchema.shape, /** * When true MS will log to DEBUG all of the credentials data it receives from YTM * */ - logAuth?: boolean + logAuth: z.boolean().optional().meta({ + description: "When true MS will log to DEBUG all of the credentials data it receives from YTM" + }), /** * Always log history diff - * + * * By default MS will log to `WARN` if history diff is inconsistent but does not log if diff is expected (on new tracks found) * Set this to `true` to ALWAYS log diff on new tracks. Expected diffs will log to `DEBUG` and inconsistent diffs will continue to log to `WARN` - * + * * @default false */ - logDiff?: boolean - } -} + logDiff: z.boolean().optional().meta({ + description: "Always log history diff", + default: false + }), + }).optional(), +}); + +export type YTMusicSourceConfig = z.infer; + +export const ytMusicSourceAIOConfigSchema = z.object({ + ...ytMusicSourceConfigSchema.shape, + type: z.literal('ytmusic'), +}); -export interface YTMusicSourceAIOConfig extends YTMusicSourceConfig { - type: 'ytmusic' -} +export type YTMusicSourceAIOConfig = z.infer; -- 2.51.2 From f8401faf6147dbd355699a2ffd3a50c4c6bebc98 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 24 Jul 2026 16:28:01 -0400 Subject: [PATCH 04/30] convert webhooks --- .../infrastructure/config/health/webhooks.ts | 116 +++++++++++++----- 1 file changed, 87 insertions(+), 29 deletions(-) diff --git a/src/backend/common/infrastructure/config/health/webhooks.ts b/src/backend/common/infrastructure/config/health/webhooks.ts index 20e10b78..48504707 100644 --- a/src/backend/common/infrastructure/config/health/webhooks.ts +++ b/src/backend/common/infrastructure/config/health/webhooks.ts @@ -1,3 +1,5 @@ +import * as z from "zod"; + export interface WebhookPayload { title?: string identifier: string @@ -7,22 +9,30 @@ export interface WebhookPayload { export type Priority = 'info' | 'warn' | 'error'; -export interface PrioritiesConfig { +export const prioritiesConfigSchema = z.object({ /** * @examples [5] * */ - info: number + info: z.number().meta({ + examples: [5] + }), /** * @examples [7] * */ - warn: number + warn: z.number().meta({ + examples: [7] + }), /** * @examples [10] * */ - error: number -} + error: z.number().meta({ + examples: [10] + }), +}); + +export type PrioritiesConfig = z.infer; -export interface CommonWebhookConfig { +export const commonWebhookConfigSchema = z.object({ /** * Webhook type. Valid values are: * @@ -31,26 +41,40 @@ export interface CommonWebhookConfig { * * @examples ["gotify"] * */ - type: 'gotify' | 'ntfy' | 'apprise' + type: z.union([z.literal('gotify'), z.literal('ntfy'), z.literal('apprise')]).meta({ + description: "Webhook type. Valid values are:", + examples: ["gotify"] + }), /** * A friendly name used to identify webhook config in logs * */ - name?: string -} + name: z.string().optional().meta({ + description: "A friendly name used to identify webhook config in logs" + }), +}); + +export type CommonWebhookConfig = z.infer; -export interface GotifyConfig extends CommonWebhookConfig { +export const gotifyConfigSchema = z.object({ + ...commonWebhookConfigSchema.shape, /** * The URL of the Gotify server. Same URL that would be used to reach the Gotify UI * * @examples ["http://192.168.0.100:8078"] * */ - url: string + url: z.string().meta({ + description: "The URL of the Gotify server.", + examples: ["http://192.168.0.100:8078"] + }), /** * The token created for this Application in Gotify * * @examples ["AQZI58fA.rfSZbm"] * */ - token: string + token: z.string().meta({ + description: "The token created for this Application in Gotify", + examples: ["AQZI58fA.rfSZbm"] + }), /** * Priority of messages * @@ -58,35 +82,51 @@ export interface GotifyConfig extends CommonWebhookConfig { * * Warn -> 7 * * Error -> 10 * */ - priorities?: PrioritiesConfig -} + priorities: prioritiesConfigSchema.optional().meta({ + description: "Priority of messages" + }), +}); -export interface NtfyConfig extends CommonWebhookConfig { +export type GotifyConfig = z.infer; + +export const ntfyConfigSchema = z.object({ + ...commonWebhookConfigSchema.shape, /** * The URL of the Ntfy server * * @examples ["http://192.168.0.100:8078"] * */ - url: string + url: z.string().meta({ + description: "The URL of the Ntfy server", + examples: ["http://192.168.0.100:8078"] + }), /** * The topic mutli-scrobbler should POST to * */ - topic: string + topic: z.string().meta({ + description: "The topic mutli-scrobbler should POST to" + }), /** * Required if topic is protected * */ - username?: string + username: z.string().optional().meta({ + description: "Required if topic is protected" + }), /** * Required if topic is protected * */ - password?: string + password: z.string().optional().meta({ + description: "Required if topic is protected" + }), /** * Use instead of username/password, required if topic is protected */ - token?: string + token: z.string().optional().meta({ + description: "Use instead of username/password, required if topic is protected" + }), /** * Priority of messages @@ -95,33 +135,51 @@ export interface NtfyConfig extends CommonWebhookConfig { * * Warn -> 4 * * Error -> 5 * */ - priorities?: PrioritiesConfig -} + priorities: prioritiesConfigSchema.optional().meta({ + description: "Priority of messages" + }), +}); + +export type NtfyConfig = z.infer; -export interface AppriseConfig extends CommonWebhookConfig { +export const appriseConfigSchema = z.object({ + ...commonWebhookConfigSchema.shape, /** * The URL of the apprise-api server * * @examples ["http://192.168.0.100:8078"] * */ - host: string + host: z.string().meta({ + description: "The URL of the apprise-api server", + examples: ["http://192.168.0.100:8078"] + }), /** * If using [Stateless Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) the Apprise config URL(s) to send * */ - urls?: string | string[] + urls: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "If using [Stateless Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) the Apprise config URL(s) to send" + }), /** * If using [Persistent Store Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) the Configuration ID(s) to send to * * Note: If multiple keys are defined then MS will attempt to POST to each one individually * */ - keys?: string | string[] + keys: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "If using [Persistent Store Endpoints](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) the Configuration ID(s) to send to" + }), /** * Optional [tag(s)](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging) to send in the notification payload * */ - tags?: string | string[] -} + tags: z.union([z.string(), z.array(z.string())]).optional().meta({ + description: "Optional [tag(s)](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging) to send in the notification payload" + }), +}); + +export type AppriseConfig = z.infer; + +export const webhookConfigSchema = z.union([gotifyConfigSchema, ntfyConfigSchema, appriseConfigSchema]); -export type WebhookConfig = GotifyConfig | NtfyConfig | AppriseConfig; +export type WebhookConfig = z.infer; -- 2.51.2 From afea13e68183cd159a253e8026d8a10169a9688e Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 24 Jul 2026 16:28:17 -0400 Subject: [PATCH 05/30] convert barrel client and source configs --- .../infrastructure/config/client/clients.ts | 104 +++-- .../infrastructure/config/source/azuracast.ts | 22 +- .../infrastructure/config/source/sources.ts | 358 +++++++++++------- 3 files changed, 306 insertions(+), 178 deletions(-) diff --git a/src/backend/common/infrastructure/config/client/clients.ts b/src/backend/common/infrastructure/config/client/clients.ts index 7adb0b9d..f6b43606 100644 --- a/src/backend/common/infrastructure/config/client/clients.ts +++ b/src/backend/common/infrastructure/config/client/clients.ts @@ -1,45 +1,76 @@ -import type {KoitoClientAIOConfig, KoitoClientConfig} from "./koito.ts"; -import type {LastfmClientAIOConfig, LastfmClientConfig} from "./lastfm.ts"; -import type {ListenBrainzClientAIOConfig, ListenBrainzClientConfig} from "./listenbrainz.ts"; -import type {MalojaClientAIOConfig, MalojaClientConfig} from "./maloja.ts"; -import type {TealClientAIOConfig, TealClientConfig} from "./tealfm.ts"; -import type {RockSkyClientAIOConfig, RockSkyClientConfig} from "./rocksky.ts"; -import type {LibrefmClientConfig, LibrefmClientAIOConfig} from "./librefm.ts"; -import type {DiscordClientAIOConfig, DiscordClientConfig} from "./discord.ts"; - -export type ClientConfig = -MalojaClientConfig -| LastfmClientConfig -| LibrefmClientConfig -| ListenBrainzClientConfig -| KoitoClientConfig -| TealClientConfig -| RockSkyClientConfig -| DiscordClientConfig; - -export type ClientAIOConfig = MalojaClientAIOConfig -| LastfmClientAIOConfig -| LibrefmClientAIOConfig -| ListenBrainzClientAIOConfig -| KoitoClientAIOConfig -| TealClientAIOConfig -| RockSkyClientAIOConfig -| DiscordClientAIOConfig; +import * as z from "zod"; +import {koitoClientAIOConfigSchema, koitoClientConfigSchema} from "./koito.ts"; +import {lastfmClientAIOConfigSchema, lastfmClientConfigSchema} from "./lastfm.ts"; +import {listenBrainzClientAIOConfigSchema, listenBrainzClientConfigSchema} from "./listenbrainz.ts"; +import {malojaClientAIOConfigSchema, malojaClientConfigSchema} from "./maloja.ts"; +import {tealClientAIOConfigSchema, tealClientConfigSchema} from "./tealfm.ts"; +import {rockSkyClientAIOConfigSchema, rockSkyClientConfigSchema} from "./rocksky.ts"; +import {librefmClientAIOConfigSchema, librefmClientConfigSchema} from "./librefm.ts"; +import {discordClientAIOConfigSchema, discordClientConfigSchema} from "./discord.ts"; + +export const clientConfigSchema = z.union([ + malojaClientConfigSchema, + lastfmClientConfigSchema, + librefmClientConfigSchema, + listenBrainzClientConfigSchema, + koitoClientConfigSchema, + tealClientConfigSchema, + rockSkyClientConfigSchema, + discordClientConfigSchema, +]); + +export type ClientConfig = z.infer; + +export const clientAIOConfigSchema = z.union([ + malojaClientAIOConfigSchema, + lastfmClientAIOConfigSchema, + librefmClientAIOConfigSchema, + listenBrainzClientAIOConfigSchema, + koitoClientAIOConfigSchema, + tealClientAIOConfigSchema, + rockSkyClientAIOConfigSchema, + discordClientAIOConfigSchema, +]); + +export type ClientAIOConfig = z.infer; /** Used for docusaurus schemas * We need to show "array of" for each type of config when looking at File Config - * + * * This is defined in the AIO config and we *assume* arrays in individual files when parsing in builders * But we don't have any actual definitions for this that we can pull for generating individual schema files */ -export type MalojaClientConfigs = MalojaClientConfig[]; -export type LastfmClientConfigs = LastfmClientConfig[]; -export type LibrefmClientConfigs = LibrefmClientConfig[]; -export type ListenBrainzClientConfigs = ListenBrainzClientConfig[]; -export type KoitoClientConfigs = KoitoClientConfig[]; -export type TealClientConfigs = TealClientConfig[]; -export type RockSkyClientConfigs = RockSkyClientConfig[]; -export type DiscordClientConfigs = DiscordClientConfig[]; +export const malojaClientConfigsSchema = z.array(malojaClientConfigSchema); + +export type MalojaClientConfigs = z.infer; + +export const lastfmClientConfigsSchema = z.array(lastfmClientConfigSchema); + +export type LastfmClientConfigs = z.infer; + +export const librefmClientConfigsSchema = z.array(librefmClientConfigSchema); + +export type LibrefmClientConfigs = z.infer; + +export const listenBrainzClientConfigsSchema = z.array(listenBrainzClientConfigSchema); + +export type ListenBrainzClientConfigs = z.infer; + +export const koitoClientConfigsSchema = z.array(koitoClientConfigSchema); + +export type KoitoClientConfigs = z.infer; + +export const tealClientConfigsSchema = z.array(tealClientConfigSchema); + +export type TealClientConfigs = z.infer; + +export const rockSkyClientConfigsSchema = z.array(rockSkyClientConfigSchema); + +export type RockSkyClientConfigs = z.infer; + +export const discordClientConfigsSchema = z.array(discordClientConfigSchema); + +export type DiscordClientConfigs = z.infer; export const atomicClientInterfaces = [ 'MalojaClientConfig', @@ -56,4 +87,3 @@ export const clientInterfaces = [ 'AIOClientRelaxedConfig', ...atomicClientInterfaces ]; - diff --git a/src/backend/common/infrastructure/config/source/azuracast.ts b/src/backend/common/infrastructure/config/source/azuracast.ts index 299270fd..3b1a6965 100644 --- a/src/backend/common/infrastructure/config/source/azuracast.ts +++ b/src/backend/common/infrastructure/config/source/azuracast.ts @@ -1,5 +1,5 @@ import * as z from "zod"; -import type {CommonSourceConfig, CommonSourceData, CommonSourceOptions} from "./index.ts"; +import {commonSourceConfigSchema, commonSourceDataSchema, commonSourceOptionsSchema, manualListeningOptionsSchema} from "./index.ts"; export const azuraStationInfoResponseSchema = z.object({ id: z.string(), @@ -61,6 +61,7 @@ export type AzuraStationResponse = z.infer; export const azuracastDataSchema = z.object({ + ...commonSourceDataSchema.shape, /** * Base URL of the Azuracast instance * @@ -119,26 +120,25 @@ export const azuracastDataSchema = z.object({ }) }); -export type AzuracastData = z.infer & CommonSourceData; +export type AzuracastData = z.infer; -// `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 const azuracastSourceoptionsSchema = z.object({ + ...commonSourceOptionsSchema.shape, + ...manualListeningOptionsSchema.shape, +}); -export type AzuracastSourceoptions = z.infer & CommonSourceOptions & ManualListeningOptions; +export type AzuracastSourceoptions = z.infer; -// `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({ + ...commonSourceConfigSchema.shape, data: azuracastDataSchema }); -export type AzuracastSourceConfig = z.infer & CommonSourceConfig; +export type AzuracastSourceConfig = z.infer; export const azuracastSourceAIOConfigSchema = z.object({ ...azuracastSourceConfigSchema.shape, type: z.literal('azuracast') }); -export type AzuracastSourceAIOConfig = z.infer & CommonSourceConfig; +export type AzuracastSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index 7957f4fd..ebc98a93 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -1,138 +1,238 @@ -import type {AzuracastSourceAIOConfig, AzuracastSourceConfig} from "./azuracast.ts"; -import type {ChromecastSourceAIOConfig, ChromecastSourceConfig} from "./chromecast.ts"; -import type {ListenbrainzEndpointSourceAIOConfig, ListenbrainzEndpointSourceConfig} from "./endpointlz.ts"; -import type {LastFMEndpointSourceAIOConfig, LastFMEndpointSourceConfig} from "./endpointlfm.ts"; -import type {DeezerInternalSourceConfig, DeezerSourceConfig, DeezerCompatConfig, DeezerAIOCompatConfig} from "./deezer.ts"; -import type {JellyApiSourceAIOConfig, JellyApiSourceConfig} from "./jellyfin.ts"; -import type {JRiverSourceAIOConfig, JRiverSourceConfig} from "./jriver.ts"; -import type {KodiSourceAIOConfig, KodiSourceConfig} from "./kodi.ts"; -import type {LastFmSouceAIOConfig, LastfmSourceConfig} from "./lastfm.ts"; -import type {ListenBrainzSourceAIOConfig, ListenBrainzSourceConfig} from "./listenbrainz.ts"; -import type {MopidySourceAIOConfig, MopidySourceConfig} from "./mopidy.ts"; -import type {MPDSourceAIOConfig, MPDSourceConfig} from "./mpd.ts"; -import type {MPRISSourceAIOConfig, MPRISSourceConfig} from "./mpris.ts"; -import type {MusikcubeSourceAIOConfig, MusikcubeSourceConfig} from "./musikcube.ts"; -import type {MusicCastSourceConfig, MusicCastSourceAIOConfig} from "./musiccast.ts"; -import type {PlexApiSourceConfig, PlexApiSourceAIOConfig} from "./plex.ts"; -import type {SpotifySourceAIOConfig, SpotifySourceConfig} from "./spotify.ts"; -import type {SubsonicSourceAIOConfig, SubSonicSourceConfig} from "./subsonic.ts"; -import type {VLCSourceAIOConfig, VLCSourceConfig} from "./vlc.ts"; -import type {WebScrobblerSourceAIOConfig, WebScrobblerSourceConfig} from "./webscrobbler.ts"; -import type {YTMusicSourceAIOConfig, YTMusicSourceConfig} from "./ytmusic.ts"; -import type {YandexMusicBridgeSourceAIOConfig, YandexMusicBridgeSourceConfig} from "./ymbridge.ts"; -import type {IcecastSourceAIOConfig, IcecastSourceConfig} from "./icecast.ts"; -import type {KoitoSourceAIOConfig, KoitoSourceConfig} from "./koito.ts"; -import type {MalojaSourceAIOConfig, MalojaSourceConfig} from "./maloja.ts"; -import type {TealSourceAIOConfig, TealSourceConfig} from "./tealfm.ts"; -import type {RockskySourceAIOConfig, RockskySourceConfig} from "./rocksky.ts"; -import type {LibrefmSouceAIOConfig, LibrefmSourceConfig} from "./librefm.ts"; -import type {SonosSourceAIOConfig, SonosSourceConfig} from "./sonos.ts"; -import type {AppleMusicSourceAIOConfig, AppleMusicSourceConfig} from "./applemusic.ts"; - - -export type SourceConfig = - SpotifySourceConfig - | PlexApiSourceConfig - | DeezerCompatConfig - | ListenbrainzEndpointSourceConfig - | LastFMEndpointSourceConfig - | SubSonicSourceConfig - | JellyApiSourceConfig - | LastfmSourceConfig - | LibrefmSourceConfig - | YTMusicSourceConfig - | YandexMusicBridgeSourceConfig - | MPRISSourceConfig - | MopidySourceConfig - | ListenBrainzSourceConfig - | JRiverSourceConfig - | KodiSourceConfig - | WebScrobblerSourceConfig - | ChromecastSourceConfig - | MalojaSourceConfig - | MusikcubeSourceConfig - | MusicCastSourceConfig - | MPDSourceConfig - | VLCSourceConfig - | IcecastSourceConfig - | AzuracastSourceConfig - | KoitoSourceConfig - | TealSourceConfig - | RockskySourceConfig - | SonosSourceConfig - | AppleMusicSourceConfig; - -export type SourceAIOConfig = - SpotifySourceAIOConfig - | PlexApiSourceAIOConfig - | DeezerAIOCompatConfig - | ListenbrainzEndpointSourceAIOConfig - | LastFMEndpointSourceAIOConfig - | SubsonicSourceAIOConfig - | JellyApiSourceAIOConfig - | LastFmSouceAIOConfig - | LibrefmSouceAIOConfig - | YTMusicSourceAIOConfig - | YandexMusicBridgeSourceAIOConfig - | MPRISSourceAIOConfig - | MopidySourceAIOConfig - | ListenBrainzSourceAIOConfig - | JRiverSourceAIOConfig - | KodiSourceAIOConfig - | WebScrobblerSourceAIOConfig - | ChromecastSourceAIOConfig - | MalojaSourceAIOConfig - | MusikcubeSourceAIOConfig - | MusicCastSourceAIOConfig - | MPDSourceAIOConfig - | VLCSourceAIOConfig - | IcecastSourceAIOConfig - | AzuracastSourceAIOConfig - | KoitoSourceAIOConfig - | TealSourceAIOConfig - | RockskySourceAIOConfig - | SonosSourceAIOConfig - | AppleMusicSourceAIOConfig; +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"; + +export const sourceConfigSchema = z.union([ + spotifySourceConfigSchema, + plexApiSourceConfigSchema, + deezerCompatConfigSchema, + listenbrainzEndpointSourceConfigSchema, + lastFmEndpointSourceConfigSchema, + subSonicSourceConfigSchema, + jellyApiSourceConfigSchema, + lastfmSourceConfigSchema, + librefmSourceConfigSchema, + ytMusicSourceConfigSchema, + yandexMusicBridgeSourceConfigSchema, + mprisSourceConfigSchema, + mopidySourceConfigSchema, + listenBrainzSourceConfigSchema, + jRiverSourceConfigSchema, + kodiSourceConfigSchema, + webScrobblerSourceConfigSchema, + chromecastSourceConfigSchema, + malojaSourceConfigSchema, + musikcubeSourceConfigSchema, + musicCastSourceConfigSchema, + mpdSourceConfigSchema, + vlcSourceConfigSchema, + icecastSourceConfigSchema, + azuracastSourceConfigSchema, + koitoSourceConfigSchema, + tealSourceConfigSchema, + rockskySourceConfigSchema, + sonosSourceConfigSchema, + appleMusicSourceConfigSchema, +]); + +export type SourceConfig = z.infer; + +export const sourceAIOConfigSchema = z.union([ + spotifySourceAIOConfigSchema, + plexApiSourceAIOConfigSchema, + deezerAIOCompatConfigSchema, + listenbrainzEndpointSourceAIOConfigSchema, + lastFmEndpointSourceAIOConfigSchema, + subsonicSourceAIOConfigSchema, + jellyApiSourceAIOConfigSchema, + lastFmSouceAIOConfigSchema, + librefmSouceAIOConfigSchema, + ytMusicSourceAIOConfigSchema, + yandexMusicBridgeSourceAIOConfigSchema, + mprisSourceAIOConfigSchema, + mopidySourceAIOConfigSchema, + listenBrainzSourceAIOConfigSchema, + jRiverSourceAIOConfigSchema, + kodiSourceAIOConfigSchema, + webScrobblerSourceAIOConfigSchema, + chromecastSourceAIOConfigSchema, + malojaSourceAIOConfigSchema, + musikcubeSourceAIOConfigSchema, + musicCastSourceAIOConfigSchema, + mpdSourceAIOConfigSchema, + vlcSourceAIOConfigSchema, + icecastSourceAIOConfigSchema, + azuracastSourceAIOConfigSchema, + koitoSourceAIOConfigSchema, + tealSourceAIOConfigSchema, + rockskySourceAIOConfigSchema, + sonosSourceAIOConfigSchema, + appleMusicSourceAIOConfigSchema, +]); + +export type SourceAIOConfig = z.infer; /** Used for docusaurus schemas * We need to show "array of" for each type of config when looking at File Config - * + * * This is defined in the AIO config and we *assume* arrays in individual files when parsing in builders * But we don't have any actual definitions for this that we can pull for generating individual schema files */ -export type SpotifySourceConfigs = SpotifySourceConfig[]; -export type PlexApiSourceConfigs = PlexApiSourceConfig[]; -export type DeezerSourceConfigs = DeezerSourceConfig[]; -export type DeezerInternalSourceConfigs = DeezerInternalSourceConfig[]; -export type DeezerCompatConfigs = DeezerCompatConfig[]; -export type ListenbrainzEndpointSourceConfigs = ListenbrainzEndpointSourceConfig[]; -export type LastFMEndpointSourceConfigs = LastFMEndpointSourceConfig[]; -export type SubSonicSourceConfigs = SubSonicSourceConfig[]; -export type JellyApiSourceConfigs = JellyApiSourceConfig[]; -export type LastfmSourceConfigs = LastfmSourceConfig[]; -export type LibrefmSourceConfigs = LibrefmSourceConfig[]; -export type YTMusicSourceConfigs = YTMusicSourceConfig[]; -export type YandexMusicBridgeSourceConfigs = YandexMusicBridgeSourceConfig[]; -export type MPRISSourceConfigs = MPRISSourceConfig[]; -export type MopidySourceConfigs = MopidySourceConfig[]; -export type ListenBrainzSourceConfigs = ListenBrainzSourceConfig[]; -export type JRiverSourceConfigs = JRiverSourceConfig[]; -export type KodiSourceConfigs = KodiSourceConfig[]; -export type WebScrobblerSourceConfigs = WebScrobblerSourceConfig[]; -export type ChromecastSourceConfigs = ChromecastSourceConfig[]; -export type MalojaSourceConfigs = MalojaSourceConfig[]; -export type MusikcubeSourceConfigs = MusikcubeSourceConfig[]; -export type MusicCastSourceConfigs = MusicCastSourceConfig[]; -export type MPDSourceConfigs = MPDSourceConfig[]; -export type VLCSourceConfigs = VLCSourceConfig[]; -export type IcecastSourceConfigs = IcecastSourceConfig[]; -export type AzuracastSourceConfigs = AzuracastSourceConfig[]; -export type KoitoSourceConfigs = KoitoSourceConfig[]; -export type TealSourceConfigs = TealSourceConfig[]; -export type RockskySourceConfigs = RockskySourceConfig[]; -export type SonosSourceConfigs = SonosSourceConfig[]; -export type AppleMusicSourceConfigs = AppleMusicSourceConfig[]; +export const spotifySourceConfigsSchema = z.array(spotifySourceConfigSchema); + +export type SpotifySourceConfigs = z.infer; + +export const plexApiSourceConfigsSchema = z.array(plexApiSourceConfigSchema); + +export type PlexApiSourceConfigs = z.infer; + +export const deezerSourceConfigsSchema = z.array(deezerSourceConfigSchema); + +export type DeezerSourceConfigs = z.infer; + +export const deezerInternalSourceConfigsSchema = z.array(deezerInternalSourceConfigSchema); + +export type DeezerInternalSourceConfigs = z.infer; + +export const deezerCompatConfigsSchema = z.array(deezerCompatConfigSchema); + +export type DeezerCompatConfigs = z.infer; + +export const listenbrainzEndpointSourceConfigsSchema = z.array(listenbrainzEndpointSourceConfigSchema); + +export type ListenbrainzEndpointSourceConfigs = z.infer; + +export const lastFmEndpointSourceConfigsSchema = z.array(lastFmEndpointSourceConfigSchema); + +export type LastFMEndpointSourceConfigs = z.infer; + +export const subSonicSourceConfigsSchema = z.array(subSonicSourceConfigSchema); + +export type SubSonicSourceConfigs = z.infer; + +export const jellyApiSourceConfigsSchema = z.array(jellyApiSourceConfigSchema); + +export type JellyApiSourceConfigs = z.infer; + +export const lastfmSourceConfigsSchema = z.array(lastfmSourceConfigSchema); + +export type LastfmSourceConfigs = z.infer; + +export const librefmSourceConfigsSchema = z.array(librefmSourceConfigSchema); + +export type LibrefmSourceConfigs = z.infer; + +export const ytMusicSourceConfigsSchema = z.array(ytMusicSourceConfigSchema); + +export type YTMusicSourceConfigs = z.infer; + +export const yandexMusicBridgeSourceConfigsSchema = z.array(yandexMusicBridgeSourceConfigSchema); + +export type YandexMusicBridgeSourceConfigs = z.infer; + +export const mprisSourceConfigsSchema = z.array(mprisSourceConfigSchema); + +export type MPRISSourceConfigs = z.infer; + +export const mopidySourceConfigsSchema = z.array(mopidySourceConfigSchema); + +export type MopidySourceConfigs = z.infer; + +export const listenBrainzSourceConfigsSchema = z.array(listenBrainzSourceConfigSchema); +export type ListenBrainzSourceConfigs = z.infer; + +export const jRiverSourceConfigsSchema = z.array(jRiverSourceConfigSchema); + +export type JRiverSourceConfigs = z.infer; + +export const kodiSourceConfigsSchema = z.array(kodiSourceConfigSchema); + +export type KodiSourceConfigs = z.infer; + +export const webScrobblerSourceConfigsSchema = z.array(webScrobblerSourceConfigSchema); + +export type WebScrobblerSourceConfigs = z.infer; + +export const chromecastSourceConfigsSchema = z.array(chromecastSourceConfigSchema); + +export type ChromecastSourceConfigs = z.infer; + +export const malojaSourceConfigsSchema = z.array(malojaSourceConfigSchema); + +export type MalojaSourceConfigs = z.infer; + +export const musikcubeSourceConfigsSchema = z.array(musikcubeSourceConfigSchema); + +export type MusikcubeSourceConfigs = z.infer; + +export const musicCastSourceConfigsSchema = z.array(musicCastSourceConfigSchema); + +export type MusicCastSourceConfigs = z.infer; + +export const mpdSourceConfigsSchema = z.array(mpdSourceConfigSchema); + +export type MPDSourceConfigs = z.infer; + +export const vlcSourceConfigsSchema = z.array(vlcSourceConfigSchema); + +export type VLCSourceConfigs = z.infer; + +export const icecastSourceConfigsSchema = z.array(icecastSourceConfigSchema); + +export type IcecastSourceConfigs = z.infer; + +export const azuracastSourceConfigsSchema = z.array(azuracastSourceConfigSchema); + +export type AzuracastSourceConfigs = z.infer; + +export const koitoSourceConfigsSchema = z.array(koitoSourceConfigSchema); + +export type KoitoSourceConfigs = z.infer; + +export const tealSourceConfigsSchema = z.array(tealSourceConfigSchema); + +export type TealSourceConfigs = z.infer; + +export const rockskySourceConfigsSchema = z.array(rockskySourceConfigSchema); + +export type RockskySourceConfigs = z.infer; + +export const sonosSourceConfigsSchema = z.array(sonosSourceConfigSchema); + +export type SonosSourceConfigs = z.infer; + +export const appleMusicSourceConfigsSchema = z.array(appleMusicSourceConfigSchema); + +export type AppleMusicSourceConfigs = z.infer; export const atomicSourceInterfaces = [ 'SpotifySourceConfig', @@ -171,5 +271,3 @@ export const sourceInterfaces = [ 'AIOSourceRelaxedConfig', ...atomicSourceInterfaces ]; - - -- 2.51.2 From 06b6e200f582975f9049ae0b919922340799bd28 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 24 Jul 2026 16:41:49 -0400 Subject: [PATCH 06/30] convert aioconfig --- src/backend/common/infrastructure/Atomic.ts | 28 +-- .../common/infrastructure/config/aioConfig.ts | 169 ++++++++++++------ .../infrastructure/config/source/index.ts | 4 +- 3 files changed, 131 insertions(+), 70 deletions(-) diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 308c7f66..7f6264ea 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -278,20 +278,26 @@ export interface CacheConfigOptions { regex?: number } -export interface CacheConfigUser { - auth?: { - provider: 'valkey' | 'file', - [key: string]: any - }; - valkey?: string +export const cacheConfigUserAuthSchema = z.object({ + provider: z.union([z.literal('valkey'), z.literal('file')]), +}).catchall(z.any()); + +// The top-level (and `auth`) catchalls allow deprecated `scrobble`/`metadata` config keys to still be read off +// this type at runtime (see Cache.ts#parseUserConfig) without having them show up in schema docs. +export const cacheConfigUserSchema = z.object({ + auth: cacheConfigUserAuthSchema.optional(), + valkey: z.string().optional(), /** Number of regex functions to cache (LRU) - * + * * @default 200 */ - regex?: number - // to allow deprecated scrobble config without having it show up in schema docs - [key: string]: any -} + regex: z.number().optional().meta({ + description: "Number of regex functions to cache (LRU)", + default: 200 + }), +}).catchall(z.any()); + +export type CacheConfigUser = z.infer; export interface MusicbrainzApiConfigData { url?: string diff --git a/src/backend/common/infrastructure/config/aioConfig.ts b/src/backend/common/infrastructure/config/aioConfig.ts index e5f35901..0dd748e7 100644 --- a/src/backend/common/infrastructure/config/aioConfig.ts +++ b/src/backend/common/infrastructure/config/aioConfig.ts @@ -1,28 +1,54 @@ -import type { LogOptions } from "@foxxmd/logging"; -import type { ClientAIOConfig } from "./client/clients.ts"; -import type { CommonClientOptions } from "./client/index.ts"; -import type { RequestRetryOptions } from "./common.ts"; -import type { WebhookConfig } from "./health/webhooks.ts"; -import type { CommonSourceOptions, SourceRetryOptions } from "./source/index.ts"; -import type { SourceAIOConfig } from "./source/sources.ts"; -import type { CacheConfigUser } from "../Atomic.ts"; -import type { TransformerCommonConfig } from "../../../../core/Atomic.ts"; -import type { RetentionConfigDurationValue } from "./database.ts"; - - -export interface SourceDefaults extends CommonSourceOptions { -} - -export interface ClientDefaults extends CommonClientOptions { -} - -export interface AIOConfig { - sourceDefaults?: SourceDefaults - clientDefaults?: ClientDefaults - sources?: SourceAIOConfig[] - clients?: ClientAIOConfig[] - - webhooks?: WebhookConfig[] +import * as z from "zod"; +import {clientAIOConfigSchema} from "./client/clients.ts"; +import {commonClientOptionsSchema} from "./client/index.ts"; +import {requestRetryOptionsSchema} from "./common.ts"; +import {webhookConfigSchema} from "./health/webhooks.ts"; +import {commonSourceOptionsSchema, fileLogOptionsSchema, logLevelSchema, sourceRetryOptionsSchema} from "./source/index.ts"; +import {sourceAIOConfigSchema} from "./source/sources.ts"; +import {cacheConfigUserSchema} from "../Atomic.ts"; +import {retentionConfigDurationValueSchema} from "./database.ts"; + +export const sourceDefaultsSchema = z.object({ + ...commonSourceOptionsSchema.shape, +}); + +export type SourceDefaults = z.infer; + +export const clientDefaultsSchema = z.object({ + ...commonClientOptionsSchema.shape, +}); + +export type ClientDefaults = z.infer; + +// `TransformOptions`/`TransformerCommonConfig` (from `../../../../core/Atomic.ts`) are only ever used at +// their default type params (`Record`) everywhere in the codebase, but `TransformerCommonConfig` +// stays generic there and `TransformerCommon extends TransformerCommonConfig` - converting the +// exported interface itself would break that extends clause. Reconstructed locally here instead, scoped to +// this file's `transformers` field only. +const transformOptionsSchema = z.object({ + failOnFetch: z.boolean().optional(), + throwOnFailure: z.union([ + z.boolean(), + z.array(z.union([z.literal('artists'), z.literal('title'), z.literal('albumArtists'), z.literal('album'), z.literal('duration'), z.literal('meta'), z.literal('art')])), + ]).optional(), + ttl: z.string().optional(), +}); + +const transformerCommonConfigSchema = z.object({ + defaults: z.record(z.string(), z.any()).optional(), + data: z.record(z.string(), z.any()).optional(), + type: z.string(), + name: z.string().optional(), + options: transformOptionsSchema.optional(), +}); + +export const aioConfigSchema = z.object({ + sourceDefaults: sourceDefaultsSchema.optional(), + clientDefaults: clientDefaultsSchema.optional(), + sources: z.array(sourceAIOConfigSchema).optional(), + clients: z.array(clientAIOConfigSchema).optional(), + + webhooks: z.array(webhookConfigSchema).optional(), /** * Set the port the multi-scrobbler UI will be served from @@ -30,7 +56,11 @@ export interface AIOConfig { * @default 9078 * @examples [9078] * */ - port?: number + port: z.number().optional().meta({ + description: "Set the port the multi-scrobbler UI will be served from", + default: 9078, + examples: [9078] + }), /** * Set the Base URL the application should assume the UI is served from. @@ -44,16 +74,26 @@ export interface AIOConfig { * @default "http://localhost" * @examples ["http://localhost", "http://192.168.0.101", "https://ms.myDomain.tld"] * */ - baseUrl?: string - - logging?: LogOptions + baseUrl: z.string().optional().meta({ + description: "Set the Base URL the application should assume the UI is served from.", + default: "http://localhost", + examples: ["http://localhost", "http://192.168.0.101", "https://ms.myDomain.tld"] + }), + + logging: z.object({ + level: logLevelSchema.optional(), + file: z.union([logLevelSchema, z.literal(false), fileLogOptionsSchema]).optional(), + console: logLevelSchema.optional(), + }).optional(), /** * Disable web server from running/listening on port. * * This will also make any ingress sources (Plex, Jellyfin, Tautulli, etc...) unusable * */ - disableWeb?: boolean + disableWeb: z.boolean().optional().meta({ + description: "Disable web server from running/listening on port." + }), /** * Enables ALL relevant logging and debug options for all sources/clients, when none are defined. @@ -65,38 +105,53 @@ export interface AIOConfig { * @default false * @examples [false] * */ - debugMode?: boolean + debugMode: z.boolean().optional().meta({ + description: "Enables ALL relevant logging and debug options for all sources/clients, when none are defined.", + default: false, + examples: [false] + }), + + cache: cacheConfigUserSchema.optional(), + + transformers: z.array(transformerCommonConfigSchema).optional(), + + database: z.object({ + retention: retentionConfigDurationValueSchema.optional(), + }).optional(), +}); + +export type AIOConfig = z.infer; + +export const aioClientConfigSchema = z.object({ + clientDefaults: requestRetryOptionsSchema.optional(), + clients: z.array(clientAIOConfigSchema).optional(), +}); + +export type AIOClientConfig = z.infer; - cache?: CacheConfigUser +export const aioClientRelaxedConfigSchema = z.object({ + clientDefaults: requestRetryOptionsSchema.optional(), + clients: z.array(z.looseObject({})).optional(), +}); - transformers?: TransformerCommonConfig[] +export type AIOClientRelaxedConfig = z.infer; - database?: { - retention?: RetentionConfigDurationValue - } -} +export const aioSourceConfigSchema = z.object({ + sourceDefaults: sourceRetryOptionsSchema.optional(), + sources: z.array(sourceAIOConfigSchema).optional(), +}); -export interface AIOClientConfig { - clientDefaults?: RequestRetryOptions - clients?: ClientAIOConfig[] -} +export type AIOSourceConfig = z.infer; -export interface AIOClientRelaxedConfig { - clientDefaults?: RequestRetryOptions - clients?: object[] -} +export const aioSourceRelaxedConfigSchema = z.object({ + sourceDefaults: sourceRetryOptionsSchema.optional(), + sources: z.array(z.looseObject({})).optional(), +}); -export interface AIOSourceConfig { - sourceDefaults?: SourceRetryOptions - sources?: SourceAIOConfig[] -} +export type AIOSourceRelaxedConfig = z.infer; -export interface AIOSourceRelaxedConfig { - sourceDefaults?: SourceRetryOptions - sources?: object[] -} +export const typedConfigSchema = z.object({ + type: z.string(), +}); -export interface TypedConfig { - type: T - // [key: string]: any -} \ No newline at end of file +export type TypedConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/index.ts b/src/backend/common/infrastructure/config/source/index.ts index ba660415..b932c40d 100644 --- a/src/backend/common/infrastructure/config/source/index.ts +++ b/src/backend/common/infrastructure/config/source/index.ts @@ -60,12 +60,12 @@ export const scrobbleThresholdsSchema = z.object({ export type ScrobbleThresholds = z.infer; // `LogLevel` (from `@foxxmd/logging`) is a simple string-literal union, reconstructed directly. -const logLevelSchema = z.enum(["silent", "fatal", "error", "warn", "info", "log", "verbose", "debug", "trace"]); +export const logLevelSchema = z.enum(["silent", "fatal", "error", "warn", "info", "log", "verbose", "debug", "trace"]); // `FileLogOptions` (from `@foxxmd/logging`) extends `FileOptions`, which itself extends `PinoRollOptions` and // `RollOptions` - two levels deep, but all plain data fields, so it's reconstructed in full here rather than // stubbed. -const fileLogOptionsSchema = z.object({ +export const fileLogOptionsSchema = z.object({ size: z.union([z.number(), z.string()]).optional(), frequency: z.union([z.literal('daily'), z.literal('hourly'), z.number()]).optional(), timestamp: z.union([z.literal('unix'), z.literal('iso'), z.literal('auto')]).optional(), -- 2.51.2 From fe068a9216e316f84c7650c03d42b8a1be181dc7 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 24 Jul 2026 17:01:41 -0400 Subject: [PATCH 07/30] refactor: Convert MonitorOptions to zod schema after rebase --- .../common/infrastructure/config/client/index.ts | 3 ++- src/backend/common/infrastructure/config/common.ts | 12 ++++++++---- .../common/infrastructure/config/source/index.ts | 3 ++- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/backend/common/infrastructure/config/client/index.ts b/src/backend/common/infrastructure/config/client/index.ts index e5c69058..2060db26 100644 --- a/src/backend/common/infrastructure/config/client/index.ts +++ b/src/backend/common/infrastructure/config/client/index.ts @@ -1,6 +1,6 @@ import * as z from "zod"; import {playTransformOptionsSchema} from "../../../../../core/Transform.ts"; -import {commonConfigSchema, requestRetryOptionsSchema, MonitorOptions} from "../common.ts"; +import {commonConfigSchema, requestRetryOptionsSchema, monitorOptionsSchema} from "../common.ts"; import {retentionConfigDurationValueSchema} from "../database.ts"; /** @@ -125,6 +125,7 @@ export const nowPlayingOptionsSchema = z.object({ export type NowPlayingOptions = z.infer; export const commonClientOptionsSchema = z.object({ + ...monitorOptionsSchema.shape, ...requestRetryOptionsSchema.shape, ...upstreamRefreshOptionsSchema.shape, diff --git a/src/backend/common/infrastructure/config/common.ts b/src/backend/common/infrastructure/config/common.ts index 350afa34..40898142 100644 --- a/src/backend/common/infrastructure/config/common.ts +++ b/src/backend/common/infrastructure/config/common.ts @@ -125,12 +125,16 @@ export const pollingOptionsSchema = z.object({ }); export type PollingOptions = z.infer; -export interface MonitorOptions { + +export const monitorOptionsSchema = z.object({ /** * Set the default behavior for wether this component should automatically monitor any activity, or scrobble, it encounters * * @default true - * @examples [true, false] */ - autoMonitor?: boolean -} + autoMonitor: z.boolean().optional().meta({ + default: true, + description: 'Set the default behavior for wether this component should automatically monitor any activity, or scrobble, it encounters' + }) +}) +export type MonitorOptions = z.infer; \ No newline at end of file diff --git a/src/backend/common/infrastructure/config/source/index.ts b/src/backend/common/infrastructure/config/source/index.ts index b932c40d..77695a5b 100644 --- a/src/backend/common/infrastructure/config/source/index.ts +++ b/src/backend/common/infrastructure/config/source/index.ts @@ -1,5 +1,5 @@ import * as z from "zod"; -import {requestRetryOptionsSchema, commonConfigSchema, MonitorOptions} from "../common.ts"; +import {requestRetryOptionsSchema, commonConfigSchema, monitorOptionsSchema} from "../common.ts"; import {retentionConfigDurationValueSchema} from "../database.ts"; import {playTransformOptionsSchema} from "../../../../../core/Transform.ts"; @@ -74,6 +74,7 @@ export const fileLogOptionsSchema = z.object({ }); export const commonSourceOptionsSchema = z.object({ + ...monitorOptionsSchema.shape, ...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 -- 2.51.2 From 8f33dc9f28d19198d8ca15ac3be319848caca82c Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Fri, 24 Jul 2026 21:29:00 -0400 Subject: [PATCH 08/30] 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 From d7f2d6fa8e225142a93204b61acc9957e832b584 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Sat, 25 Jul 2026 15:19:36 -0400 Subject: [PATCH 09/30] use zod for client validation --- .../infrastructure/config/client/clients.ts | 42 +++++++++++++++---- src/backend/scrobblers/ScrobbleClients.ts | 15 +++++-- src/backend/tests/config/config.test.ts | 25 ++++++++++- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/backend/common/infrastructure/config/client/clients.ts b/src/backend/common/infrastructure/config/client/clients.ts index f6b43606..423d0ed6 100644 --- a/src/backend/common/infrastructure/config/client/clients.ts +++ b/src/backend/common/infrastructure/config/client/clients.ts @@ -1,12 +1,14 @@ import * as z from "zod"; -import {koitoClientAIOConfigSchema, koitoClientConfigSchema} from "./koito.ts"; -import {lastfmClientAIOConfigSchema, lastfmClientConfigSchema} from "./lastfm.ts"; -import {listenBrainzClientAIOConfigSchema, listenBrainzClientConfigSchema} from "./listenbrainz.ts"; -import {malojaClientAIOConfigSchema, malojaClientConfigSchema} from "./maloja.ts"; -import {tealClientAIOConfigSchema, tealClientConfigSchema} from "./tealfm.ts"; -import {rockSkyClientAIOConfigSchema, rockSkyClientConfigSchema} from "./rocksky.ts"; -import {librefmClientAIOConfigSchema, librefmClientConfigSchema} from "./librefm.ts"; -import {discordClientAIOConfigSchema, discordClientConfigSchema} from "./discord.ts"; +import {koitoClientAIOConfigSchema, koitoClientConfigSchema, type KoitoClientConfig} from "./koito.ts"; +import {lastfmClientAIOConfigSchema, lastfmClientConfigSchema, type LastfmClientConfig} from "./lastfm.ts"; +import {listenBrainzClientAIOConfigSchema, listenBrainzClientConfigSchema, type ListenBrainzClientConfig} from "./listenbrainz.ts"; +import {malojaClientAIOConfigSchema, malojaClientConfigSchema, type MalojaClientConfig} from "./maloja.ts"; +import {tealClientAIOConfigSchema, tealClientConfigSchema, type TealClientConfig} from "./tealfm.ts"; +import {rockSkyClientAIOConfigSchema, rockSkyClientConfigSchema, type RockSkyClientConfig} from "./rocksky.ts"; +import {librefmClientAIOConfigSchema, librefmClientConfigSchema, type LibrefmClientConfig} from "./librefm.ts"; +import {discordClientAIOConfigSchema, discordClientConfigSchema, type DiscordClientConfig} from "./discord.ts"; +import type { CommonClientConfig } from "./index.ts"; +import type { ClientType } from "../../../../../core/Atomic.ts"; export const clientConfigSchema = z.union([ malojaClientConfigSchema, @@ -87,3 +89,27 @@ export const clientInterfaces = [ 'AIOClientRelaxedConfig', ...atomicClientInterfaces ]; + +export interface ClientTypeConfigMap extends Record { + maloja: MalojaClientConfig, + lastfm: LastfmClientConfig, + librefm: LibrefmClientConfig, + listenbrainz: ListenBrainzClientConfig, + koito: KoitoClientConfig, + tealfm: TealClientConfig, + rocksky: RockSkyClientConfig, + discord: DiscordClientConfig +} + +const clientConfigSchemaMap: { [K in keyof ClientTypeConfigMap]: z.ZodType } = { + maloja: malojaClientConfigSchema, + lastfm: lastfmClientConfigSchema, + librefm: librefmClientConfigSchema, + listenbrainz: listenBrainzClientConfigSchema, + koito: koitoClientConfigSchema, + tealfm: tealClientConfigSchema, + rocksky: rockSkyClientConfigSchema, + discord: discordClientConfigSchema +} + +export const validateClientJson = (clientType: T, json: object): ClientTypeConfigMap[T] => clientConfigSchemaMap[clientType].parse(json); \ No newline at end of file diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index 4b657c84..6dc68262 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -7,7 +7,7 @@ import { isClientType } from '../../core/Atomic.ts'; import { clientTypes } from "../../core/Atomic.ts"; import type {ClientType} from "../../core/Atomic.ts"; import type {AIOConfig} from "../common/infrastructure/config/aioConfig.ts"; -import type {ClientAIOConfig, ClientConfig} from "../common/infrastructure/config/client/clients.ts"; +import {validateClientJson, type ClientAIOConfig, type ClientConfig} from "../common/infrastructure/config/client/clients.ts"; import type {LastfmClientConfig, LastfmData} from "../common/infrastructure/config/client/lastfm.ts"; import type {ListenBrainzClientConfig, ListenBrainzData} from "../common/infrastructure/config/client/listenbrainz.ts"; import type {MalojaClientConfig, MalojaData} from "../common/infrastructure/config/client/maloja.ts"; @@ -28,6 +28,7 @@ import clone from 'clone'; import type {DiscordClientConfig, DiscordData} from '../common/infrastructure/config/client/discord.ts'; import { stripIndents } from 'common-tags'; import { normalizeStr, type StringNormalizationOptions } from '../utils/StringUtils.ts'; +import { prettifyError, ZodError } from 'zod'; type groupedNamedConfigs = {[key: string]: ParsedConfig[]}; @@ -383,7 +384,7 @@ export default class ScrobbleClients { continue; } try { - const validConfig = await validateJson('client', rawConf, this.getSchemaByType(clientType), this.logger); + const validConfig = await validateClientJson(clientType, rawConf); // await validateJson('client', rawConf, this.getSchemaByType(clientType), this.logger); const {configureAs = defaultConfigureAs} = validConfig; if (configureAs === 'client') { const parsedConfig: ParsedConfig = { @@ -394,9 +395,15 @@ export default class ScrobbleClients { configs.push(parsedConfig); } } catch (e: any) { - const configErr = new Error(`The config entry at index ${i} from ${clientType}.json was not valid`, {cause: e}); + const msg = `The config entry at index ${i} from ${clientType}.json was not valid`; + const configErr = new Error(msg, {cause: e}); this.emitter.emit('error', configErr); - this.logger.error(configErr); + // pretty print error if its a zod error + if(e instanceof ZodError) { + this.logger.error(`${msg}:\n${prettifyError(e)}`); + } else { + this.logger.error(configErr); + } } } } diff --git a/src/backend/tests/config/config.test.ts b/src/backend/tests/config/config.test.ts index 52e03194..61ef5efc 100644 --- a/src/backend/tests/config/config.test.ts +++ b/src/backend/tests/config/config.test.ts @@ -16,6 +16,7 @@ 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'; +import { validateClientJson } from '../../common/infrastructure/config/client/clients.ts'; chai.use(asPromised); @@ -111,7 +112,29 @@ describe('Sample Configs', function () { }); for(const componentType of clientTypes) { - 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 === 'client'); + for (const [i,rawConf] of fileContents.entries()) { + try { + validateClientJson(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 ScrobbleClients`, async function () { this.timeout(5000); const emitter = new EventEmitter(); -- 2.51.2 From 28e0d03e6fa813ff35b6ecd06a92af105e70fa48 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Sat, 25 Jul 2026 20:04:16 +0000 Subject: [PATCH 10/30] Schema generation cleanup and vscode launch config fix --- .vscode/launch.json | 2 +- .../infrastructure/config/client/koito.ts | 4 +- src/backend/utils/SchemaDocsStaticUtils.ts | 56 +++++++++++++++---- src/core/Atomic.ts | 2 +- 4 files changed, 50 insertions(+), 14 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 0fc9eb19..7b2b8af6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -71,7 +71,7 @@ "type": "node", "request": "launch", // Debug app in VSCode - "program": "${workspaceFolder}/src/backend/utils/SchemaStaticUtil.ts", + "program": "${workspaceFolder}/src/backend/utils/SchemaDocsStaticUtils.ts", "runtimeExecutable": "node", "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", diff --git a/src/backend/common/infrastructure/config/client/koito.ts b/src/backend/common/infrastructure/config/client/koito.ts index a43be41e..19640e05 100644 --- a/src/backend/common/infrastructure/config/client/koito.ts +++ b/src/backend/common/infrastructure/config/client/koito.ts @@ -74,11 +74,11 @@ export const koitoDataSchema = z.object({ username: z.string().meta({ description: "Username of the user to scrobble for" }), -}); +}).meta({title: 'KoitoData'}); export type KoitoData = z.infer; -export const koitoClientDataSchema = koitoDataSchema.extend(commonClientDataSchema.shape); +export const koitoClientDataSchema = koitoDataSchema.extend(commonClientDataSchema.shape).meta({title: 'KoitoData'}); export type KoitoClientData = z.infer; diff --git a/src/backend/utils/SchemaDocsStaticUtils.ts b/src/backend/utils/SchemaDocsStaticUtils.ts index 643fa5a0..e251e517 100644 --- a/src/backend/utils/SchemaDocsStaticUtils.ts +++ b/src/backend/utils/SchemaDocsStaticUtils.ts @@ -1,21 +1,57 @@ import { writeFileSync, mkdirSync } from "node:fs"; import { resolve } from "path"; +import * as z from 'zod'; import {getTypeSchemaFromConfigGenerator} from "./SchemaUtils.ts"; import { atomicClientInterfaces } from '../common/infrastructure/config/client/clients.ts'; import { atomicSourceInterfaces } from '../common/infrastructure/config/source/sources.ts'; import { projectRootDir } from "../common/infrastructure/Atomic.ts"; +import { koitoClientConfigSchema } from "../common/infrastructure/config/client/koito.ts"; mkdirSync(resolve(projectRootDir, 'docsite/static/schemas'), {recursive: true}); -const aio = getTypeSchemaFromConfigGenerator('AIOConfig'); -writeFileSync(resolve(projectRootDir, 'docsite/static/schemas/aio.json'), JSON.stringify(aio)); +const jsonSchema = z.toJSONSchema(koitoClientConfigSchema, { + io: "input", + //reused: "ref", + unrepresentable: "any", + override: (ctx) => { + //const def = ctx.zodSchema._zod.def; + if(ctx.jsonSchema.anyOf !== undefined && Array.isArray(ctx.jsonSchema.anyOf)) { + for(const a of ctx.jsonSchema.anyOf) { + if(a.const !== undefined) { + a.enum = [a.const]; + delete a.const; + } + } + } + if(ctx.jsonSchema.oneOf !== undefined) { + for(const a of ctx.jsonSchema.oneOf) { + if(a.const !== undefined) { + a.enum = [a.const]; + delete a.const; + } + } + } + if(ctx.jsonSchema.const !== undefined) { + ctx.jsonSchema.enum = [ctx.jsonSchema.const]; + delete ctx.jsonSchema.const; + } + // if (ctx.jsonSchema.anyOf) { + // ctx.jsonSchema.oneOf = ctx.jsonSchema.anyOf; + // delete ctx.jsonSchema.anyOf; + // } + } + }); +writeFileSync(resolve(projectRootDir, `docsite/static/schemas/KoitoClientConfig.json`), JSON.stringify(jsonSchema)); -for(const inter of atomicSourceInterfaces) { - const schema = getTypeSchemaFromConfigGenerator(`${inter}s`); - writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${inter}.json`), JSON.stringify(schema)); -} +// const aio = getTypeSchemaFromConfigGenerator('AIOConfig'); +// writeFileSync(resolve(projectRootDir, 'docsite/static/schemas/aio.json'), JSON.stringify(aio)); -for(const inter of atomicClientInterfaces) { - const schema = getTypeSchemaFromConfigGenerator(`${inter}s`); - writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${inter}.json`), JSON.stringify(schema)); -} \ No newline at end of file +// for(const inter of atomicSourceInterfaces) { +// const schema = getTypeSchemaFromConfigGenerator(`${inter}s`); +// writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${inter}.json`), JSON.stringify(schema)); +// } + +// for(const inter of atomicClientInterfaces) { +// const schema = getTypeSchemaFromConfigGenerator(`${inter}s`); +// writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${inter}.json`), JSON.stringify(schema)); +// } \ No newline at end of file diff --git a/src/core/Atomic.ts b/src/core/Atomic.ts index 2faf06d0..5bb9508e 100644 --- a/src/core/Atomic.ts +++ b/src/core/Atomic.ts @@ -13,7 +13,7 @@ export const COMPONENT_TYPE_CLIENT: ComponentTypeClient = 'client'; export const componentTypeSourceSchema = z.literal('source'); export type ComponentTypeSource = z.infer; export const COMPONENT_TYPE_SOURCE: ComponentTypeSource = 'source'; -export const componentTypeSchema = z.union([componentTypeClientSchema, componentTypeSourceSchema]); +export const componentTypeSchema = z.enum(['client','source']); // 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; -- 2.51.2 From ff4dd25a979a66ed6be28110d236eae41096676d Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 14:30:24 +0000 Subject: [PATCH 11/30] refactor(transform)!: Require user stage is typed --- .../common/infrastructure/config/common.ts | 6 +- .../tests/component/transformers.test.ts | 45 +++++----- .../tests/scrobbler/scrobblers.test.ts | 3 + src/backend/tests/source/source.test.ts | 4 + src/backend/utils/SchemaDocsStaticUtils.ts | 2 +- src/core/Transform.ts | 90 ++++++++++--------- 6 files changed, 86 insertions(+), 64 deletions(-) diff --git a/src/backend/common/infrastructure/config/common.ts b/src/backend/common/infrastructure/config/common.ts index 40898142..86ff3825 100644 --- a/src/backend/common/infrastructure/config/common.ts +++ b/src/backend/common/infrastructure/config/common.ts @@ -1,3 +1,4 @@ +import { stripIndents } from "common-tags"; import * as z from "zod"; export const commonConfigPrimitivesSchema = z.object({ @@ -47,7 +48,8 @@ export const requestRetryOptionsSchema = z.object({ * @examples [1] * */ maxRequestRetries: z.number().optional().meta({ - description: "default # of http request retries a source/client can make before error is thrown", + description: stripIndents`default # of http **request** retries a source/client can make before error is thrown + a test`, default: 1, examples: [1] }), @@ -62,7 +64,7 @@ export const requestRetryOptionsSchema = z.object({ default: 1.5, examples: [1.5] }) -}); +}).meta({title: 'RequestRetryOptions'}); export type RequestRetryOptions = z.infer; diff --git a/src/backend/tests/component/transformers.test.ts b/src/backend/tests/component/transformers.test.ts index 64ebe4a2..52ecfc03 100644 --- a/src/backend/tests/component/transformers.test.ts +++ b/src/backend/tests/component/transformers.test.ts @@ -4,7 +4,7 @@ import asPromised from 'chai-as-promised'; import { describe, it } from 'mocha'; import AbstractComponent, { type AbstractComponentConfig } from "../../common/AbstractComponent.ts"; -import { type ConditionalSearchAndReplaceRegExp, TRANSFORM_HOOK } from "../../../core/Transform.ts"; +import { type ConditionalSearchAndReplaceRegExp, type StageTypeMetadata, TRANSFORM_HOOK } from "../../../core/Transform.ts"; import { isConditionalSearchAndReplace } from "../../utils/PlayTransformUtils.ts"; import { generateArtistsStr, generatePlay } from "../../../core/tests/utils/PlayTestUtils.ts"; @@ -89,7 +89,9 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + // @ts-expect-error no type named test type: "test", + // @ts-expect-error stage is untyped title: ['something'] } } @@ -106,25 +108,6 @@ describe('Play Transforms', function () { describe('User Stage Parsing', function () { - it(`Assumes user 'type' if no type is present`, function () { - component.config = { - options: { - playTransform: { - preCompare: { - title: ['something'] - } - } - } - } - - component.buildTransformRules(); - - expect(component.transformRules.preCompare).to.be.an('array'); - expect(component.transformRules.preCompare).to.be.length(1); - expect(component.transformRules.preCompare).to.have.nested.property('0.type'); - expect(component.transformRules.preCompare[0].type).eq('user'); - }); - it(`Allows user 'type'`, function () { component.config = { options: { @@ -151,9 +134,11 @@ describe('Play Transforms', function () { playTransform: { preCompare: [ { + type: 'user', title: ['something'] }, { + type: 'user', title: ['something else'] } ] @@ -174,6 +159,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', title: ['something'] } } @@ -193,6 +179,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', title: ['something'] } } @@ -215,6 +202,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', title: [ { @@ -250,7 +238,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { - type: t, + type: t as StageTypeMetadata, title: true } } @@ -286,6 +274,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', title: ['something'] } } @@ -303,6 +292,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', title: ['something', 'cool'] } } @@ -320,6 +310,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', title: [ { search: '/(cool )(some)(thing)/i', @@ -343,6 +334,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', artists: [ { search: '/(.*?)(\\s*\\/\\s*)(.*$)/i', @@ -366,6 +358,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', title: ['something'] } } @@ -383,6 +376,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', album: ['something'] } } @@ -400,6 +394,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', artists: ['something'] } } @@ -477,6 +472,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', when: [ { album: "Has This" @@ -500,6 +496,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', when: [ { album: "Has This" @@ -525,6 +522,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', artists: [ { search: "something", @@ -553,6 +551,7 @@ describe('Play Transforms', function () { options: { playTransform: { preCompare: { + type: 'user', artists: [ { search: "something", @@ -587,6 +586,7 @@ describe('Play Transforms', function () { playTransform: { preCompare: [ { + type: 'user', title: [ { search: "something", @@ -595,6 +595,7 @@ describe('Play Transforms', function () { ] }, { + type: 'user', title: [ { search: "another else", @@ -619,6 +620,7 @@ describe('Play Transforms', function () { playTransform: { preCompare: [ { + type: 'user', title: [ { search: "something", @@ -653,6 +655,7 @@ describe('Play Transforms', function () { playTransform: { preCompare: [ { + type: 'user', name: "barChange", title: [ { diff --git a/src/backend/tests/scrobbler/scrobblers.test.ts b/src/backend/tests/scrobbler/scrobblers.test.ts index f5ee956c..7560bda9 100644 --- a/src/backend/tests/scrobbler/scrobblers.test.ts +++ b/src/backend/tests/scrobbler/scrobblers.test.ts @@ -669,6 +669,7 @@ describe('Scrobble client uses transform plays correctly', function() { testScrobbler.config.options = { playTransform: { postCompare: { + type: 'user', title: [ 'cool' ] @@ -695,6 +696,7 @@ describe('Scrobble client uses transform plays correctly', function() { playTransform: { compare: { candidate: { + type: 'user', title: [ 'hugely cool and very different track' ] @@ -718,6 +720,7 @@ describe('Scrobble client uses transform plays correctly', function() { playTransform: { compare: { existing: { + type: 'user', title: [ 'hugely cool and very different track' ] diff --git a/src/backend/tests/source/source.test.ts b/src/backend/tests/source/source.test.ts index 0f7fc818..800fe161 100644 --- a/src/backend/tests/source/source.test.ts +++ b/src/backend/tests/source/source.test.ts @@ -53,6 +53,7 @@ describe('Sources use transform plays correctly', function () { source.config.options = { playTransform: { preCompare: { + type: 'user', title: [ { search: 'cool', @@ -76,6 +77,7 @@ describe('Sources use transform plays correctly', function () { source.config.options = { playTransform: { postCompare: { + type: 'user', title: [ { search: 'cool', @@ -106,6 +108,7 @@ describe('Sources use transform plays correctly', function () { playTransform: { compare: { existing: { + type: 'user', title: [ { search: 'hugely cool and very different track', @@ -133,6 +136,7 @@ describe('Sources use transform plays correctly', function () { playTransform: { compare: { candidate: { + type: 'user', title: [ { search: 'hugely cool and very different track', diff --git a/src/backend/utils/SchemaDocsStaticUtils.ts b/src/backend/utils/SchemaDocsStaticUtils.ts index e251e517..237dfc55 100644 --- a/src/backend/utils/SchemaDocsStaticUtils.ts +++ b/src/backend/utils/SchemaDocsStaticUtils.ts @@ -10,7 +10,7 @@ import { koitoClientConfigSchema } from "../common/infrastructure/config/client/ mkdirSync(resolve(projectRootDir, 'docsite/static/schemas'), {recursive: true}); const jsonSchema = z.toJSONSchema(koitoClientConfigSchema, { - io: "input", + io: "output", //reused: "ref", unrepresentable: "any", override: (ctx) => { diff --git a/src/core/Transform.ts b/src/core/Transform.ts index 74a00748..aed7a054 100644 --- a/src/core/Transform.ts +++ b/src/core/Transform.ts @@ -106,8 +106,7 @@ export type WhenConditions = WhenParts[]; // 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({ +const buildPartsAtomicSchema = (term: T) => z.object({ title: term.optional(), artists: term.optional(), albumArtists: term.optional(), @@ -115,16 +114,27 @@ function buildPartsAtomicSchema(term: T) { duration: term.optional(), meta: term.optional(), }); -} -const whenPartsStringSchema = buildPartsAtomicSchema(z.string()); +const buildWhennablePartschema = (term: T) => z.object({ + /** A string or regex pattern matching the title of a Play */ + title: term.optional().meta({description: 'A string or regex pattern matching the title of a Play'}), + /** A string or regex pattern matching any artist of a Play */ + artists: term.optional().meta({description: 'A string or regex pattern matching any artist of a Play'}), + /** A string or regex pattern matching any album artist of a Play */ + albumArtists: term.optional().meta({description: 'A string or regex pattern matching any artist of a Play'}), + /** A string or regex pattern matching the album of a Play */ + album: term.optional().meta({description: 'A string or regex pattern matching the album of a Play'}), + }); + +const whenPartsStringSchema = buildWhennablePartschema(z.string()); export const whenConditionsConfigSchema = z.array(whenPartsStringSchema); export type WhenConditionsConfig = z.infer; export const whennableSchema = z.object({ - when: whenConditionsConfigSchema.optional() + /** Only use this Stage (or Rules) if the properties in this object match the corresponding property in a Play. The value of each property is a string or regex pattern to match. */ + when: whenConditionsConfigSchema.optional().meta({description: 'Only use this Stage (or Rules) if the properties in this object match the corresponding property in a Play. The value of each property is a string or regex pattern to match.'}) }); export type Whennable = z.infer; @@ -152,19 +162,19 @@ const { test, ...restConditionalRegSchema } = conditionalSearchAndReplaceRegExpSchema.shape; -export const conditionalSearchAndReplaceTermSchema = z.object(restConditionalRegSchema); +export const conditionalSearchAndReplaceTermSchema = z.object(restConditionalRegSchema).meta({title: 'Conditional Search and Replace Term'}); export type ConditionalSearchAndReplaceTerm = z.infer; -export const searchAndReplaceTermSchema = z.union([z.string(), conditionalSearchAndReplaceTermSchema]); +export const searchAndReplaceTermSchema = z.union([z.string(), conditionalSearchAndReplaceTermSchema]).meta({title: 'Search and Replace Term'}); export type SearchAndReplaceTerm = z.infer; -export const externalMetadataTermSchema = z.union([z.boolean(), z.undefined(), whennableSchema]); +export const externalMetadataTermSchema = z.union([z.boolean(), z.undefined(), whennableSchema]).meta({title: 'Ext Metadata Term'}); export type ExternalMetadataTerm = z.infer; -export const flowControlTermSchema = z.enum(['continue', 'stop']); +export const flowControlTermSchema = z.enum(['continue', 'stop']).meta({title: 'Flow Control Term'}); export type FlowControlTerm = z.infer; export const FLOW_CONTROL_TERM = { @@ -177,27 +187,27 @@ export const flowControlSchema = z.object({ onFailure: flowControlTermSchema, onSkip: flowControlTermSchema, failureReturnPartial: z.boolean(), -}); +}).meta({title: 'Flow Control'}); export type FlowControl = z.infer; -export const stageTypeMetadataSchema = z.enum(['spotify', 'musicbrainz', 'native']); +export const stageTypeMetadataSchema = z.enum(['spotify', 'musicbrainz', 'native']).meta({title: 'Stage Type Metadata'}); export type StageTypeMetadata = z.infer; -export const stageTypeUserSchema = z.literal('user'); +export const stageTypeUserSchema = z.literal('user').meta({title: 'Stage Type 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 const stageTypeSchema = z.string().meta({title: 'Stage Type'}); export type StageType = z.infer; export const stageTypedConfigSchema = z.object({ type: stageTypeSchema, -}); +}).meta({title: 'Stage Type Config'}); export type StageTypedConfig = z.infer; @@ -207,7 +217,7 @@ export const stageConfigSchema = z.object({ ...flowControlSchema.partial().shape, name: z.string().optional(), stageHash: z.string().optional(), -}); +}).meta({title: 'Stage Config'}); export type StageConfig = z.infer; @@ -226,7 +236,7 @@ export const playTransformMetadataStageSchema = z.object({ ...metadataAtomicSchema.shape, score: z.number().optional(), type: stageTypeMetadataSchema, -}); +}).meta({title: 'Transform Metadata Stage'}); export type PlayTransformMetadataStage = z.infer; @@ -234,7 +244,7 @@ export const playTransformNativeStageSchema = z.object({ ...stageConfigSchema.shape, ...metadataAtomicSchema.shape, type: z.literal('native'), -}); +}).meta({title: 'Transform Native Stage'}); export type PlayTransformNativeStage = z.infer; @@ -244,7 +254,7 @@ const playTransformGenericStageSchema = z.object({ ...stageConfigSchema.shape, ...anyAtomicSchema.shape, type: stageTypeSchema, -}); +}).meta({title: 'Transform Generic Stage'}); // `PlayTransformOptions` term shape: T = SearchAndReplaceTerm[] | ExternalMetadataTerm const optionsPartsTermSchema = z.union([z.array(searchAndReplaceTermSchema), externalMetadataTermSchema]); @@ -254,22 +264,22 @@ const playTransformUserStageOptionsSchema = z.object({ ...stageConfigSchema.shape, ...optionsAtomicSchema.shape, type: stageTypeUserSchema, -}); +}).meta({title: 'Tranform User Stage'}); -const untypedPlayTransformUserStageOptionsSchema = z.object({ - ...untypedStageConfigSchema.shape, - ...optionsAtomicSchema.shape, -}); +// const untypedPlayTransformUserStageOptionsSchema = z.object({ +// ...untypedStageConfigSchema.shape, +// ...optionsAtomicSchema.shape, +// }).meta({title: 'Transform User Stage'}); // `PlayTransformRules` term shape: T = ConditionalSearchAndReplaceRegExp[] | ExternalMetadataTerm -const rulesPartsTermSchema = z.union([z.array(conditionalSearchAndReplaceRegExpSchema), externalMetadataTermSchema]); -const rulesAtomicSchema = buildPartsAtomicSchema(rulesPartsTermSchema); +const rulesPartsTermSchema = z.union([z.array(conditionalSearchAndReplaceRegExpSchema), externalMetadataTermSchema]).meta({title: 'Rule Parts Term'}); +const rulesAtomicSchema = buildPartsAtomicSchema(rulesPartsTermSchema).meta({title: 'Rules Atomic'}); const playTransformUserStageRulesSchema = z.object({ ...stageConfigSchema.shape, ...rulesAtomicSchema.shape, type: stageTypeUserSchema, -}); +}).meta({title: 'User Stage Rules'}); // 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 @@ -278,7 +288,7 @@ const playTransformUserStageRulesSchema = z.object({ // `type` values covered across the whole union is unchanged. const metadataStageForUnionSchema = playTransformMetadataStageSchema.extend({ type: z.enum(['spotify', 'musicbrainz']), -}); +}).meta({title: 'Metadata Types'}); // `PlayTransformParts = Extract, Y> & Whennable` - the `& Whennable` intersection // is redundant here since every stage schema already includes `when` via `stageConfigSchema`/`untypedStageConfigSchema`. @@ -289,12 +299,12 @@ const playTransformTypedStageOptionsSchema = z.discriminatedUnion('type', [ metadataStageForUnionSchema, playTransformNativeStageSchema, playTransformUserStageOptionsSchema, -]); -const playTransformStageOptionsSchema = z.union([ - playTransformTypedStageOptionsSchema, - playTransformGenericStageSchema, - untypedPlayTransformUserStageOptionsSchema, -]); +]).meta({title: 'Stage'}); +// const playTransformStageOptionsSchema = z.union([ +// playTransformTypedStageOptionsSchema, +// playTransformGenericStageSchema, +// untypedPlayTransformUserStageOptionsSchema, +// ]).meta({title: 'Stage'}); // Rules pool: `Extract, StageTyped>` excludes `UntypedPlayTransformUserStage`, // since it has no `type` field and so isn't assignable to `StageTyped`. @@ -309,8 +319,8 @@ const playTransformStageRulesSchema = z.union([ ]); const playTransformPartsConfigOptionsSchema = z.union([ - z.array(playTransformStageOptionsSchema), - playTransformStageOptionsSchema, + z.array(playTransformTypedStageOptionsSchema), + playTransformTypedStageOptionsSchema, ]); export const playTransformConfigSchema = z.object({ @@ -320,7 +330,7 @@ export const playTransformConfigSchema = z.object({ existing: playTransformPartsConfigOptionsSchema.optional(), }).optional(), postCompare: playTransformPartsConfigOptionsSchema.optional(), -}); +}).meta({title: 'Transform Config'}); export type PlayTransformConfig = z.infer; @@ -328,11 +338,11 @@ export type PlayTransformConfig = z.infer; export const playTransformOptionsSchema = z.object({ ...playTransformConfigSchema.shape, log: z.union([z.boolean(), z.literal('all')]).optional(), -}); +}).meta({title: 'Transform Options'}); export type PlayTransformOptions = z.infer; -const playTransformPartsArrayRulesSchema = z.array(playTransformStageRulesSchema); +const playTransformPartsArrayRulesSchema = z.array(playTransformStageRulesSchema).meta({title: 'Transform Parts'}); /** Represents the final, strongly-typed transform configuration used during runtime. */ export const playTransformRulesSchema = z.object({ @@ -342,7 +352,7 @@ export const playTransformRulesSchema = z.object({ existing: playTransformPartsArrayRulesSchema.optional(), }).optional(), postCompare: playTransformPartsArrayRulesSchema.optional(), -}); +}).meta({title: 'Transform Rules'}); export type PlayTransformRules = z.infer; @@ -353,4 +363,4 @@ export type PlayTransformRules = z.infer; // `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'); -}); +}).meta({title: 'Transform Options to Rules'}); -- 2.51.2 From e02a0cc6aebc4ce25ce5ead6ac37d39c7745035a Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 15:10:03 +0000 Subject: [PATCH 12/30] refactor(transform): Simplify transform config by removing untyped stages --- src/backend/common/AbstractComponent.ts | 3 +- src/core/Transform.ts | 72 +++++++++++++------------ 2 files changed, 39 insertions(+), 36 deletions(-) diff --git a/src/backend/common/AbstractComponent.ts b/src/backend/common/AbstractComponent.ts index acbdd822..a0a124c1 100644 --- a/src/backend/common/AbstractComponent.ts +++ b/src/backend/common/AbstractComponent.ts @@ -237,8 +237,7 @@ export default abstract class AbstractComponent extends AbstractInitializable { if(data === undefined) { return undefined; } - // default to user transform type for backward compatibility - const partArr = (Array.isArray(data) ? data : [data]).map(x => ({type: 'user', ...x})); + const partArr = (Array.isArray(data) ? data : [data]); return partArr.map(x => this.transformManager.parseTransformerConfig(x)); } diff --git a/src/core/Transform.ts b/src/core/Transform.ts index aed7a054..26a49b2b 100644 --- a/src/core/Transform.ts +++ b/src/core/Transform.ts @@ -9,14 +9,14 @@ import type { MarkRequired } from "ts-essentials"; // `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 PlayTransformParts = Extract, Y> & Whennable; //export type PlayTransformUserParts = PlayTransformUserStage & { when?: WhenConditionsConfig }; //export type PlayTransformMetaParts = PlayTransformMetadataStage & { when?: WhenConditionsConfig }; -export type PlayTransformPartsArray = PlayTransformParts[]; +export type PlayTransformPartsArray = PlayTransformParts[]; /** Represents the weakly-defined user config. May be an array of parts or one parts object */ -export type PlayTransformPartsConfig = PlayTransformPartsArray | PlayTransformParts; +export type PlayTransformPartsConfig = PlayTransformPartsArray | PlayTransformParts; export interface PlayTransformPartsAtomic { title?: T @@ -35,10 +35,10 @@ export interface StageTyped { type: StageType } -export interface NotStageTyped { - //type?: never -} -export type MaybeStageTyped = StageTyped | NotStageTyped; +// export interface NotStageTyped { +// //type?: never +// } +// export type MaybeStageTyped = StageTyped | NotStageTyped; export interface AtomicStageConfig extends StageConfig, PlayTransformPartsAtomic {} @@ -50,15 +50,15 @@ export interface PlayTransformUserStage extends StageConfig, PlayTransformPar type: StageTypeUser } -export interface PlayTransformGenericStage extends StageConfig, PlayTransformPartsAtomic { - type: string -} +// export interface PlayTransformGenericStage extends StageConfig, PlayTransformPartsAtomic { +// type: string +// } -export interface UntypedPlayTransformUserStage extends UntypedStageConfig, PlayTransformPartsAtomic { +// export interface UntypedPlayTransformUserStage extends UntypedStageConfig, PlayTransformPartsAtomic { -} +// } -export type PlayTransformStage = PlayTransformMetadataStage | PlayTransformUserStage | PlayTransformNativeStage | UntypedPlayTransformUserStage | PlayTransformGenericStage; +export type PlayTransformStage = PlayTransformMetadataStage | PlayTransformUserStage | PlayTransformNativeStage;// | UntypedPlayTransformUserStage | PlayTransformGenericStage; /** Represents the plain json user-configured structure (input) */ @@ -144,8 +144,10 @@ export type Whennable = z.infer; // used as a best-effort check. export const conditionalSearchAndReplaceRegExpSchema = z.object({ ...whennableSchema.shape, - search: z.xor([z.string(), z.instanceof(RegExp)]), - replace: z.string(), + /** The plain string or regex pattern to match */ + search: z.string().meta({description: 'The plain string or regex pattern to match'}), + /** The replacement string. Can be empty string or use js replacement string pattern */ + replace: z.string().meta({description: 'The replacement string. Can be empty string or use js replacement string pattern'}), test: z.custom<(obj: SearchAndReplaceRegExp) => boolean>((val) => typeof val === 'function').optional(), }); @@ -170,7 +172,7 @@ export const searchAndReplaceTermSchema = z.union([z.string(), conditionalSearch export type SearchAndReplaceTerm = z.infer; -export const externalMetadataTermSchema = z.union([z.boolean(), z.undefined(), whennableSchema]).meta({title: 'Ext Metadata Term'}); +export const externalMetadataTermSchema = z.union([z.boolean(), whennableSchema]).optional().meta({title: 'Ext Metadata Term'}); export type ExternalMetadataTerm = z.infer; @@ -193,6 +195,8 @@ export type FlowControl = z.infer; export const stageTypeMetadataSchema = z.enum(['spotify', 'musicbrainz', 'native']).meta({title: 'Stage Type Metadata'}); +export const typedStageSchema = z.enum(['spotify', 'musicbrainz', 'native','user']).meta({title: 'Stage Type'}); + export type StageTypeMetadata = z.infer; export const stageTypeUserSchema = z.literal('user').meta({title: 'Stage Type User'}); @@ -206,7 +210,7 @@ export const stageTypeSchema = z.string().meta({title: 'Stage Type'}); export type StageType = z.infer; export const stageTypedConfigSchema = z.object({ - type: stageTypeSchema, + type: typedStageSchema, }).meta({title: 'Stage Type Config'}); export type StageTypedConfig = z.infer; @@ -249,21 +253,21 @@ export const playTransformNativeStageSchema = z.object({ 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, -}).meta({title: 'Transform Generic Stage'}); +//const anyAtomicSchema = buildPartsAtomicSchema(z.any()); +// const playTransformGenericStageSchema = z.object({ +// ...stageConfigSchema.shape, +// ...anyAtomicSchema.shape, +// type: stageTypeSchema, +// }).meta({title: 'Transform Generic Stage'}); // `PlayTransformOptions` term shape: T = SearchAndReplaceTerm[] | ExternalMetadataTerm -const optionsPartsTermSchema = z.union([z.array(searchAndReplaceTermSchema), externalMetadataTermSchema]); -const optionsAtomicSchema = buildPartsAtomicSchema(optionsPartsTermSchema); +//const optionsPartsTermSchema = z.union([z.array(searchAndReplaceTermSchema), externalMetadataTermSchema]); +const optionsAtomicSchema = buildPartsAtomicSchema(z.array(searchAndReplaceTermSchema)); const playTransformUserStageOptionsSchema = z.object({ ...stageConfigSchema.shape, ...optionsAtomicSchema.shape, - type: stageTypeUserSchema, + type: z.literal('user'), }).meta({title: 'Tranform User Stage'}); // const untypedPlayTransformUserStageOptionsSchema = z.object({ @@ -272,13 +276,13 @@ const playTransformUserStageOptionsSchema = z.object({ // }).meta({title: 'Transform User Stage'}); // `PlayTransformRules` term shape: T = ConditionalSearchAndReplaceRegExp[] | ExternalMetadataTerm -const rulesPartsTermSchema = z.union([z.array(conditionalSearchAndReplaceRegExpSchema), externalMetadataTermSchema]).meta({title: 'Rule Parts Term'}); -const rulesAtomicSchema = buildPartsAtomicSchema(rulesPartsTermSchema).meta({title: 'Rules Atomic'}); +//const rulesPartsTermSchema = z.union([z.array(conditionalSearchAndReplaceRegExpSchema), externalMetadataTermSchema]).meta({title: 'Rule Parts Term'}); +const rulesAtomicSchema = buildPartsAtomicSchema(z.array(searchAndReplaceTermSchema)).meta({title: 'Rules Atomic'}); const playTransformUserStageRulesSchema = z.object({ ...stageConfigSchema.shape, ...rulesAtomicSchema.shape, - type: stageTypeUserSchema, + type: z.literal('user'), }).meta({title: 'User Stage Rules'}); // zod's `discriminatedUnion` requires each branch's discriminant literal(s) to be unique across the whole @@ -313,10 +317,10 @@ const playTransformTypedStageRulesSchema = z.discriminatedUnion('type', [ playTransformNativeStageSchema, playTransformUserStageRulesSchema, ]); -const playTransformStageRulesSchema = z.union([ - playTransformTypedStageRulesSchema, - playTransformGenericStageSchema, -]); +// const playTransformStageRulesSchema = z.union([ +// playTransformTypedStageRulesSchema, +// playTransformGenericStageSchema, +// ]); const playTransformPartsConfigOptionsSchema = z.union([ z.array(playTransformTypedStageOptionsSchema), @@ -342,7 +346,7 @@ export const playTransformOptionsSchema = z.object({ export type PlayTransformOptions = z.infer; -const playTransformPartsArrayRulesSchema = z.array(playTransformStageRulesSchema).meta({title: 'Transform Parts'}); +const playTransformPartsArrayRulesSchema = z.array(playTransformTypedStageRulesSchema).meta({title: 'Transform Parts'}); /** Represents the final, strongly-typed transform configuration used during runtime. */ export const playTransformRulesSchema = z.object({ -- 2.51.2 From bf0b27924ec9f859df9358619adbd94ac0a44fdb Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 15:54:55 +0000 Subject: [PATCH 13/30] more config annotation --- src/backend/common/infrastructure/Atomic.ts | 8 ++++- .../infrastructure/config/client/index.ts | 6 ++-- .../common/infrastructure/config/database.ts | 2 +- src/backend/utils/SchemaDocsStaticUtils.ts | 17 +++++++++++ src/core/Transform.ts | 30 ++++++++++++------- 5 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 7f6264ea..938d3adf 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -12,6 +12,7 @@ import type { ClientType } from "../../../core/Atomic.ts"; import assert from 'assert'; import * as path from 'path'; import * as z from 'zod'; +import { stripIndents } from 'common-tags'; export const __filename = import.meta.filename; export const projectRootDir = path.resolve(__filename, '../../../../../'); @@ -416,7 +417,12 @@ export interface ScrobbleRangeResult { export const REFRESH_STALE_DEFAULT = 60; -export const durationValueSchema = z.union([z.number(), z.string()]); +export const durationValueSchema = z.union([z.number(), z.string()]).meta({ + description: stripIndents`A duration of time as either + + * a number of seconds + * or a string containing a number and a unit of time compatible with dayjs`, + example: [60, 3600, "1 hour", "4 days"]}); /** * A duration of time * diff --git a/src/backend/common/infrastructure/config/client/index.ts b/src/backend/common/infrastructure/config/client/index.ts index 2060db26..85ee1a5d 100644 --- a/src/backend/common/infrastructure/config/client/index.ts +++ b/src/backend/common/infrastructure/config/client/index.ts @@ -159,9 +159,11 @@ export const commonClientOptionsSchema = z.object({ examples: [3] }), - playTransform: playTransformOptionsSchema.optional(), + /** Enhance/correct Play data by applying a transform pipeline */ + playTransform: playTransformOptionsSchema.optional().meta({description: 'Enhance/correct Play data by applying a transform pipeline'}), - retention: retentionConfigDurationValueSchema.optional(), + /** Retention policy for Plays stored in the database */ + retention: retentionConfigDurationValueSchema.optional().meta({description: 'Retention policy for Plays stored in the database'}), }); export type CommonClientOptions = z.infer; diff --git a/src/backend/common/infrastructure/config/database.ts b/src/backend/common/infrastructure/config/database.ts index d38787d6..6ceb7d48 100644 --- a/src/backend/common/infrastructure/config/database.ts +++ b/src/backend/common/infrastructure/config/database.ts @@ -17,7 +17,7 @@ const durationSchema = z.custom( {message: 'Expected a dayjs Duration instance'} ); -export const retentionValueUnparsedSchema = z.union([durationValueSchema, durationSchema, z.literal(false)]); +export const retentionValueUnparsedSchema = z.union([durationValueSchema, z.literal(false)]); export type RetentionValueUnparsed = z.infer; diff --git a/src/backend/utils/SchemaDocsStaticUtils.ts b/src/backend/utils/SchemaDocsStaticUtils.ts index 237dfc55..8edbce98 100644 --- a/src/backend/utils/SchemaDocsStaticUtils.ts +++ b/src/backend/utils/SchemaDocsStaticUtils.ts @@ -22,6 +22,7 @@ const jsonSchema = z.toJSONSchema(koitoClientConfigSchema, { delete a.const; } } + //return; } if(ctx.jsonSchema.oneOf !== undefined) { for(const a of ctx.jsonSchema.oneOf) { @@ -30,11 +31,27 @@ const jsonSchema = z.toJSONSchema(koitoClientConfigSchema, { delete a.const; } } + //return; } if(ctx.jsonSchema.const !== undefined) { ctx.jsonSchema.enum = [ctx.jsonSchema.const]; delete ctx.jsonSchema.const; + return } + // if(ctx.zodSchema.type === 'object') { + // const ks = Object.keys(ctx.jsonSchema.properties); + // for(const k of ks) { + // if(ctx.jsonSchema.properties[k]?.description !== undefined) { + // let isUnion = ctx.zodSchema.shape[k].type === 'union'; + // if(!isUnion && ctx.zodSchema.shape[k].type === 'optional' && ctx.zodSchema.shape[k].unwrap().type === 'union') { + // isUnion = true; + // } + // if(ctx.zodSchema.shape[k].type === 'union') + // // ctx.zodSchema.shape[k]. + // } + // } + // } + // if (ctx.jsonSchema.anyOf) { // ctx.jsonSchema.oneOf = ctx.jsonSchema.anyOf; // delete ctx.jsonSchema.anyOf; diff --git a/src/core/Transform.ts b/src/core/Transform.ts index 26a49b2b..5f6f7793 100644 --- a/src/core/Transform.ts +++ b/src/core/Transform.ts @@ -1,6 +1,7 @@ import * as z from "zod"; import type {SearchAndReplaceRegExp} from "@foxxmd/regex-buddy-core"; import type { MarkRequired } from "ts-essentials"; +import { stripIndents } from "common-tags"; // The following generic types are used elsewhere in the codebase with several different concrete type // parameters (e.g. `PlayTransformHooks`, `PlayTransformStage`, @@ -28,7 +29,7 @@ export interface PlayTransformPartsAtomic { } export const STAGE_TYPES_USER: StageTypeUser[] = ['user']; -export const STAGE_TYPES_METADATA: StageTypeMetadata[] = ['spotify','musicbrainz','native']; +export const STAGE_TYPES_METADATA: StageTypeMetadata[] = ['musicbrainz','native']; export const STAGE_TYPES: StageType[] = [...STAGE_TYPES_METADATA, ...STAGE_TYPES_USER]; export interface StageTyped { @@ -193,9 +194,9 @@ export const flowControlSchema = z.object({ export type FlowControl = z.infer; -export const stageTypeMetadataSchema = z.enum(['spotify', 'musicbrainz', 'native']).meta({title: 'Stage Type Metadata'}); +export const stageTypeMetadataSchema = z.enum(['musicbrainz', 'native']).meta({title: 'Stage Type Metadata'}); -export const typedStageSchema = z.enum(['spotify', 'musicbrainz', 'native','user']).meta({title: 'Stage Type'}); +export const typedStageSchema = z.enum(['musicbrainz', 'native','user']).meta({title: 'Stage Type'}); export type StageTypeMetadata = z.infer; @@ -291,8 +292,8 @@ const playTransformUserStageRulesSchema = z.object({ // 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']), -}).meta({title: 'Metadata Types'}); + type: z.enum(['musicbrainz']), +}).meta({title: 'Transform External Stage'}); // `PlayTransformParts = Extract, Y> & Whennable` - the `& Whennable` intersection // is redundant here since every stage schema already includes `when` via `stageConfigSchema`/`untypedStageConfigSchema`. @@ -328,11 +329,20 @@ const playTransformPartsConfigOptionsSchema = z.union([ ]); export const playTransformConfigSchema = z.object({ - preCompare: playTransformPartsConfigOptionsSchema.optional(), + /** Stages to be applied when a Play is first seen by this component */ + preCompare: playTransformPartsConfigOptionsSchema.optional().meta({description: 'Stages to be applied when a Play is first seen by this component'}), + /** Stages to be applied when comparing a candidate Play to any existing Plays such as when checking for duplicates or discovered Plays + * + * **Note:** Transforms applies are not persistent. They are used only during comparison operations. + */ compare: z.object({ - candidate: playTransformPartsConfigOptionsSchema.optional(), - existing: playTransformPartsConfigOptionsSchema.optional(), - }).optional(), + /** Stages to apply to the Play being considered for discovery or scrobbling */ + candidate: playTransformPartsConfigOptionsSchema.optional().meta({description: `Stages to apply to the Play being considered for discovery or scrobbling`}), + /** Stages to apply to the existing Plays (already discovered, existing scrobbles) */ + existing: playTransformPartsConfigOptionsSchema.optional().meta({description: `Stages to apply to the existing Plays (already discovered, existing scrobbles)`}), + }).optional().meta({description: stripIndents`Stages to be applied when comparing a candidate Play to any existing Plays such as when checking for duplicates or discovered Plays + + **Note:** Transforms applies are not persistent. They are used only during comparison operations.`}), postCompare: playTransformPartsConfigOptionsSchema.optional(), }).meta({title: 'Transform Config'}); @@ -350,7 +360,7 @@ const playTransformPartsArrayRulesSchema = z.array(playTransformTypedStageRulesS /** Represents the final, strongly-typed transform configuration used during runtime. */ export const playTransformRulesSchema = z.object({ - preCompare: playTransformPartsArrayRulesSchema.optional(), + preCompare: playTransformPartsArrayRulesSchema.optional().meta({description: 'Stages to be applied when a Play is first seen by this component'}), compare: z.object({ candidate: playTransformPartsArrayRulesSchema.optional(), existing: playTransformPartsArrayRulesSchema.optional(), -- 2.51.2 From 03dbbdc2cf3de12c99da7dce2ee6c471886536fe Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 19:28:57 +0000 Subject: [PATCH 14/30] feat(docs): Patch schema viewer to show more descriptions --- docsite/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docsite/package.json b/docsite/package.json index 7205fb5f..cca38842 100644 --- a/docsite/package.json +++ b/docsite/package.json @@ -26,7 +26,7 @@ "@mdx-js/react": "^3.0.0", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", - "cf-json-schema-viz": "^0.3.0", + " ": "^0.3.0", "clsx": "^2.0.0", "docusaurus-plugin-sass": "^0.2.6", "docusaurus-theme-github-codeblock": "^2.0.2", -- 2.51.2 From aa508424442b275c41b594b5b083edf8deee0d27 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 19:29:16 +0000 Subject: [PATCH 15/30] feat(docs): Don't hide top-level descriptions in json viewer --- docsite/src/components/Schema/Explorer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/docsite/src/components/Schema/Explorer.tsx b/docsite/src/components/Schema/Explorer.tsx index 5f576e79..8d847d57 100644 --- a/docsite/src/components/Schema/Explorer.tsx +++ b/docsite/src/components/Schema/Explorer.tsx @@ -21,6 +21,7 @@ export default function Explorer(props: ExplorerProps): JSX.Element { renderRootTreeLines={true} emptyText="No schema defined" defaultExpandedDepth={0} + skipTopLevelDescription={false} markup /> -- 2.51.2 From 03fc3e3a091d0258145241c14d264b58be6272a5 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 19:29:40 +0000 Subject: [PATCH 16/30] feat(docs): Inline defintions to prevent deduplication erasing descriptions --- .../patches/cf-json-schema-viz+0.3.0.patch | 24 ++++++++++++----- src/backend/common/infrastructure/Atomic.ts | 27 +++++++++++++++---- .../common/infrastructure/config/database.ts | 26 ++++++++++-------- src/backend/utils/SchemaDocsStaticUtils.ts | 2 +- 4 files changed, 56 insertions(+), 23 deletions(-) diff --git a/docsite/patches/cf-json-schema-viz+0.3.0.patch b/docsite/patches/cf-json-schema-viz+0.3.0.patch index d37d9504..318d0944 100644 --- a/docsite/patches/cf-json-schema-viz+0.3.0.patch +++ b/docsite/patches/cf-json-schema-viz+0.3.0.patch @@ -11,7 +11,7 @@ index 0536c69..be6274b 100644 type JsonSchemaProps = Partial & { diff --git a/node_modules/cf-json-schema-viz/dist/index.js b/node_modules/cf-json-schema-viz/dist/index.js -index b23316c..ef8be1e 100644 +index b23316c..d89b3ea 100644 --- a/node_modules/cf-json-schema-viz/dist/index.js +++ b/node_modules/cf-json-schema-viz/dist/index.js @@ -41,19 +41,19 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge @@ -3462,7 +3462,19 @@ index b23316c..ef8be1e 100644 } return [makeChoice(schemaNode)]; }, [schemaNode]); -@@ -8468,22 +8501,6 @@ var TopLevelSchemaRow = ({ +@@ -8313,7 +8346,10 @@ var SchemaRow = React2.memo( + ); + const { selectedChoice, setSelectedChoice, choices } = useChoices(schemaNode); + const typeToShow = selectedChoice.type; +- const description = isRegularNode(typeToShow) ? typeToShow.annotations.description : null; ++ let description = isRegularNode(typeToShow) ? typeToShow.annotations.description : null; ++ if ((description === null || description === void 0) && "annotations" in schemaNode && (schemaNode.annotations?.description !== null && schemaNode.annotations?.description !== void 0)) { ++ description = schemaNode.annotations?.description; ++ } + const rootLevel = renderRootTreeLines ? 1 : 2; + const childNodes = React2.useMemo(() => visibleChildren(typeToShow), [typeToShow]); + const combiner = isRegularNode(schemaNode) && schemaNode.combiners?.length ? schemaNode.combiners[0] : null; +@@ -8468,22 +8504,6 @@ var TopLevelSchemaRow = ({ [schemaNode.fragment] ); const hasVendorProperties = totalVendorExtensions > 0; @@ -3485,7 +3497,7 @@ index b23316c..ef8be1e 100644 if (isRegularNode(schemaNode) && choices.length > 1) { const combiner = isRegularNode(schemaNode) && schemaNode.combiners?.length ? schemaNode.combiners[0] : null; return /* @__PURE__ */ jsxs(Fragment, { children: [ -@@ -8517,6 +8534,22 @@ var TopLevelSchemaRow = ({ +@@ -8517,6 +8537,22 @@ var TopLevelSchemaRow = ({ ) : combiner ? /* @__PURE__ */ jsx(SchemaRow, { schemaNode: selectedChoice.type, nestingLevel }) : null ] }); } @@ -3508,7 +3520,7 @@ index b23316c..ef8be1e 100644 if (isComplexArray(schemaNode) && isPureObjectNode(schemaNode.children[0])) { const validations = isRegularNode(schemaNode) ? getValidationsFromSchema(schemaNode) : {}; return /* @__PURE__ */ jsxs(Fragment, { children: [ -@@ -8550,6 +8583,7 @@ var JsonSchemaViewer = ({ +@@ -8550,6 +8586,7 @@ var JsonSchemaViewer = ({ disableCrumbs, nodeHasChanged, skipTopLevelDescription, @@ -3516,7 +3528,7 @@ index b23316c..ef8be1e 100644 ...rest }) => { const options = React2.useMemo( -@@ -8562,7 +8596,8 @@ var JsonSchemaViewer = ({ +@@ -8562,7 +8599,8 @@ var JsonSchemaViewer = ({ hideExamples, renderRootTreeLines, disableCrumbs, @@ -3526,7 +3538,7 @@ index b23316c..ef8be1e 100644 }), [ defaultExpandedDepth, -@@ -8573,7 +8608,8 @@ var JsonSchemaViewer = ({ +@@ -8573,7 +8611,8 @@ var JsonSchemaViewer = ({ hideExamples, renderRootTreeLines, disableCrumbs, diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 938d3adf..163b3366 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -417,12 +417,29 @@ export interface ScrobbleRangeResult { export const REFRESH_STALE_DEFAULT = 60; -export const durationValueSchema = z.union([z.number(), z.string()]).meta({ - description: stripIndents`A duration of time as either +/** A number of seconds */ +export const secondsSchema = z.number().meta({ + description: 'number of seconds', + example: [60, 3600], + title: 'Seconds' +}); + + +/** A string representing a number and unit of time comptabile with dayjs */ +export const durationStringSchema = z.string().meta({ + description: 'a string containing a number and a unit of time compatible with dayjs', + example:["1 hour", "4 days"], + title: 'Duration Value' +}) + +export const durationValueSchema = z.xor([secondsSchema, durationStringSchema]); + +// .meta({ +// description: stripIndents`A duration of time as either - * a number of seconds - * or a string containing a number and a unit of time compatible with dayjs`, - example: [60, 3600, "1 hour", "4 days"]}); +// * a number of seconds +// * or a string containing a number and a unit of time compatible with dayjs`, +// example: [60, 3600, "1 hour", "4 days"]}); /** * A duration of time * diff --git a/src/backend/common/infrastructure/config/database.ts b/src/backend/common/infrastructure/config/database.ts index 6ceb7d48..5367d480 100644 --- a/src/backend/common/infrastructure/config/database.ts +++ b/src/backend/common/infrastructure/config/database.ts @@ -36,7 +36,9 @@ export type RetentionValue = z.infer; export const rententionGranularDurationValueSchema = z.object({ failed: durationValueSchema.optional(), completed: durationValueSchema.optional(), - duped: durationValueSchema.optional(), + duped: durationValueSchema.optional() +}).meta({ + description: 'Individual duration policies based on the state of the Play' }); export type RententionGranularDurationValue = z.infer; @@ -77,7 +79,9 @@ export const retentionConfigValueSchema = z.union([ ]); export type RetentionConfigValue = z.infer; -export const compactablePropertySchema = z.enum(['transform', 'input']); +export const compactablePropertySchema = z.enum(['transform', 'input']).meta({ + description: 'The type of data to compact on the Play' +}); export type CompactableProperty = z.infer; @@ -108,22 +112,22 @@ export const retentionOptionSchema = z.union([retentionOptionDurationSchema, ret export type RetentionOption = z.infer; export const retentionConfigDurationValueSchema = z.object({ - deleteAfter: retentionConfigValueDurationValueSchema.optional(), - compactAfter: retentionConfigValueDurationValueSchema.optional(), - compact: z.array(compactablePropertySchema).optional(), + deleteAfter: retentionConfigValueDurationValueSchema.optional().meta({description: 'Delete Plays using these retention policies'}), + compactAfter: retentionConfigValueDurationValueSchema.optional().meta({description: 'Compact Plays using these retention policies'}), + compact: z.array(compactablePropertySchema).optional().meta({ description: 'The type of data to compact on the Play'}) }); export type RetentionConfigDurationValue = z.infer; export const retentionConfigDurationSchema = z.object({ - deleteAfter: retentionConfigValueDurationSchema.optional(), - compactAfter: retentionConfigValueDurationSchema.optional(), + deleteAfter: retentionConfigValueDurationSchema.optional().meta({description: 'test'}), + compactAfter: retentionConfigValueDurationSchema.optional().meta({description: 'test'}), compact: z.array(compactablePropertySchema).optional(), }); export type RetentionConfigDuration = z.infer; export const retentionConfigRetentionValueSchema = z.object({ - deleteAfter: retentionConfigValueRetentionValueSchema.optional(), - compactAfter: retentionConfigValueRetentionValueSchema.optional(), + deleteAfter: retentionConfigValueRetentionValueSchema.optional().meta({description: 'test'}), + compactAfter: retentionConfigValueRetentionValueSchema.optional().meta({description: 'test'}), compact: z.array(compactablePropertySchema).optional(), }); export type RetentionConfigRetentionValue = z.infer; @@ -136,8 +140,8 @@ export const retentionConfigSchema = z.union([ export type RetentionConfig = z.infer; export const retentionOptionsSchema = z.object({ - deleteAfter: retentionOptionDurationSchema, - compactAfter: retentionOptionRetentionValueSchema, + deleteAfter: retentionOptionDurationSchema.meta({description: 'test'}), + compactAfter: retentionOptionRetentionValueSchema.meta({description: 'test'}), compact: z.array(compactablePropertySchema), }); diff --git a/src/backend/utils/SchemaDocsStaticUtils.ts b/src/backend/utils/SchemaDocsStaticUtils.ts index 8edbce98..d2a81731 100644 --- a/src/backend/utils/SchemaDocsStaticUtils.ts +++ b/src/backend/utils/SchemaDocsStaticUtils.ts @@ -11,7 +11,7 @@ mkdirSync(resolve(projectRootDir, 'docsite/static/schemas'), {recursive: true}); const jsonSchema = z.toJSONSchema(koitoClientConfigSchema, { io: "output", - //reused: "ref", + reused: "inline", unrepresentable: "any", override: (ctx) => { //const def = ctx.zodSchema._zod.def; -- 2.51.2 From ebf6ec6957d5a010bb3199eb12c816ae0715be2e Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 19:31:29 +0000 Subject: [PATCH 17/30] add postCompare description --- src/core/Transform.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/core/Transform.ts b/src/core/Transform.ts index 5f6f7793..0bd9592b 100644 --- a/src/core/Transform.ts +++ b/src/core/Transform.ts @@ -343,7 +343,9 @@ export const playTransformConfigSchema = z.object({ }).optional().meta({description: stripIndents`Stages to be applied when comparing a candidate Play to any existing Plays such as when checking for duplicates or discovered Plays **Note:** Transforms applies are not persistent. They are used only during comparison operations.`}), - postCompare: playTransformPartsConfigOptionsSchema.optional(), + postCompare: playTransformPartsConfigOptionsSchema.optional().meta({ + description: 'Stages applied before Play is sent downstream (to MS Client or just before being scrobbling)' + }), }).meta({title: 'Transform Config'}); export type PlayTransformConfig = z.infer; -- 2.51.2 From 206d1571ace96af501dc40e706a5b780dcfb1c7e Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 20:56:14 +0000 Subject: [PATCH 18/30] feat(docs): Use zod for all doc schemas and simplify generation/usage --- .../src/components/GenericConfiguration.mdx | 4 +- src/backend/utils/SchemaDocsStaticUtils.ts | 52 +++++-------------- 2 files changed, 16 insertions(+), 40 deletions(-) diff --git a/docsite/src/components/GenericConfiguration.mdx b/docsite/src/components/GenericConfiguration.mdx index 07862d38..15342f80 100644 --- a/docsite/src/components/GenericConfiguration.mdx +++ b/docsite/src/components/GenericConfiguration.mdx @@ -25,7 +25,7 @@ import {useWindowSize} from '@docusaurus/theme-common'; This displays the **structure** of the [File Configuration](/configuration?configType=aio#configuration-types) for a type with all possible properties, their shape, and descriptions/types. Use this to understand how to write a valid config. - +
@@ -51,7 +51,7 @@ import {useWindowSize} from '@docusaurus/theme-common'; filePathName={`CONFIG/${props.name}.json`} configureAs={props.client ? 'client' : 'source'} data={props.fileContent} - schemaName={props.config} + schemaName={`${props.name}-${props.client ? 'client' : 'source'}`} readOnlyAtWidth={500} />
diff --git a/src/backend/utils/SchemaDocsStaticUtils.ts b/src/backend/utils/SchemaDocsStaticUtils.ts index d2a81731..ba479c74 100644 --- a/src/backend/utils/SchemaDocsStaticUtils.ts +++ b/src/backend/utils/SchemaDocsStaticUtils.ts @@ -1,20 +1,18 @@ import { writeFileSync, mkdirSync } from "node:fs"; import { resolve } from "path"; import * as z from 'zod'; -import {getTypeSchemaFromConfigGenerator} from "./SchemaUtils.ts"; -import { atomicClientInterfaces } from '../common/infrastructure/config/client/clients.ts'; -import { atomicSourceInterfaces } from '../common/infrastructure/config/source/sources.ts'; +import { clientConfigSchemaMap } from '../common/infrastructure/config/client/clients.ts'; +import { sourceConfigSchemaMap } from '../common/infrastructure/config/source/sources.ts'; import { projectRootDir } from "../common/infrastructure/Atomic.ts"; -import { koitoClientConfigSchema } from "../common/infrastructure/config/client/koito.ts"; mkdirSync(resolve(projectRootDir, 'docsite/static/schemas'), {recursive: true}); -const jsonSchema = z.toJSONSchema(koitoClientConfigSchema, { - io: "output", +const generateSchema = (schema: z.ZodType) => z.toJSONSchema(z.array(schema), { + io: "input", reused: "inline", + target: "draft-07", unrepresentable: "any", override: (ctx) => { - //const def = ctx.zodSchema._zod.def; if(ctx.jsonSchema.anyOf !== undefined && Array.isArray(ctx.jsonSchema.anyOf)) { for(const a of ctx.jsonSchema.anyOf) { if(a.const !== undefined) { @@ -38,37 +36,15 @@ const jsonSchema = z.toJSONSchema(koitoClientConfigSchema, { delete ctx.jsonSchema.const; return } - // if(ctx.zodSchema.type === 'object') { - // const ks = Object.keys(ctx.jsonSchema.properties); - // for(const k of ks) { - // if(ctx.jsonSchema.properties[k]?.description !== undefined) { - // let isUnion = ctx.zodSchema.shape[k].type === 'union'; - // if(!isUnion && ctx.zodSchema.shape[k].type === 'optional' && ctx.zodSchema.shape[k].unwrap().type === 'union') { - // isUnion = true; - // } - // if(ctx.zodSchema.shape[k].type === 'union') - // // ctx.zodSchema.shape[k]. - // } - // } - // } - - // if (ctx.jsonSchema.anyOf) { - // ctx.jsonSchema.oneOf = ctx.jsonSchema.anyOf; - // delete ctx.jsonSchema.anyOf; - // } } - }); -writeFileSync(resolve(projectRootDir, `docsite/static/schemas/KoitoClientConfig.json`), JSON.stringify(jsonSchema)); - -// const aio = getTypeSchemaFromConfigGenerator('AIOConfig'); -// writeFileSync(resolve(projectRootDir, 'docsite/static/schemas/aio.json'), JSON.stringify(aio)); + }) -// for(const inter of atomicSourceInterfaces) { -// const schema = getTypeSchemaFromConfigGenerator(`${inter}s`); -// writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${inter}.json`), JSON.stringify(schema)); -// } +const clientEntries = Object.entries(clientConfigSchemaMap); +for(const [k,v] of clientEntries) { + writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${k}-client.json`), JSON.stringify(generateSchema(v))); +} -// for(const inter of atomicClientInterfaces) { -// const schema = getTypeSchemaFromConfigGenerator(`${inter}s`); -// writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${inter}.json`), JSON.stringify(schema)); -// } \ No newline at end of file +const sourcesEntries = Object.entries(sourceConfigSchemaMap); +for(const [k,v] of sourcesEntries) { + writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${k}-source.json`), JSON.stringify(generateSchema(v[0]))); +} \ No newline at end of file -- 2.51.2 From e8a91bb6de8fd69d22612889b57d203d7dfd3a14 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 20:57:00 +0000 Subject: [PATCH 19/30] feat: Replace ajv/ts-schema-generator validation with pure zod --- src/backend/common/database/Database.ts | 3 +- .../common/infrastructure/config/aioConfig.ts | 10 +- .../infrastructure/config/client/clients.ts | 44 ++- .../infrastructure/config/source/sources.ts | 325 ++++++------------ src/backend/scrobblers/ScrobbleClients.ts | 41 +-- src/backend/sources/ScrobbleSources.ts | 109 ++---- src/backend/utils.ts | 1 - src/backend/utils/SchemaAppStaticUtils.ts | 16 - src/backend/utils/SchemaCompiledUtils.ts | 19 - src/backend/utils/SchemaUtils.ts | 66 ---- src/backend/utils/ValidationUtils.ts | 103 ------ 11 files changed, 174 insertions(+), 563 deletions(-) delete mode 100644 src/backend/utils/SchemaAppStaticUtils.ts delete mode 100644 src/backend/utils/SchemaCompiledUtils.ts delete mode 100644 src/backend/utils/SchemaUtils.ts delete mode 100644 src/backend/utils/ValidationUtils.ts diff --git a/src/backend/common/database/Database.ts b/src/backend/common/database/Database.ts index eeb63e57..df75168d 100644 --- a/src/backend/common/database/Database.ts +++ b/src/backend/common/database/Database.ts @@ -9,6 +9,7 @@ import assert from 'node:assert'; import * as sqlite from 'node:sqlite'; import { parseBoolStrict } from '../../utils.ts'; import { SimpleError } from '../errors/MSErrors.ts'; +import type { Duration } from 'dayjs/plugin/duration.js'; export const MEMORY_DB_NAME = ':memory:'; export const isMemoryDb = (name: string): boolean => name === MEMORY_DB_NAME; @@ -50,7 +51,7 @@ export const backupDb = async (db: sqlite.DatabaseSync, dbPath: string, opts: { logger.info('Backed up!'); } -const parseRetentionValue = (val: RetentionValueUnparsed): RetentionValue => { +const parseRetentionValue = (val: RetentionValueUnparsed | Duration): RetentionValue => { if(typeof val === 'string' || typeof val === 'boolean') { try { const boolVal = parseBoolStrict(val); diff --git a/src/backend/common/infrastructure/config/aioConfig.ts b/src/backend/common/infrastructure/config/aioConfig.ts index 0dd748e7..faade95b 100644 --- a/src/backend/common/infrastructure/config/aioConfig.ts +++ b/src/backend/common/infrastructure/config/aioConfig.ts @@ -131,7 +131,10 @@ export type AIOClientConfig = z.infer; export const aioClientRelaxedConfigSchema = z.object({ clientDefaults: requestRetryOptionsSchema.optional(), - clients: z.array(z.looseObject({})).optional(), + clients: z.array(z.looseObject({ type: z.string(), configureAs: z.string().optional() })).optional(), + database: z.object({ + retention: retentionConfigDurationValueSchema.optional(), + }).optional(), }); export type AIOClientRelaxedConfig = z.infer; @@ -145,7 +148,10 @@ export type AIOSourceConfig = z.infer; export const aioSourceRelaxedConfigSchema = z.object({ sourceDefaults: sourceRetryOptionsSchema.optional(), - sources: z.array(z.looseObject({})).optional(), + sources: z.array(z.looseObject({ type: z.string(), configureAs: z.string().optional() })).optional(), + database: z.object({ + retention: retentionConfigDurationValueSchema.optional(), + }).optional(), }); export type AIOSourceRelaxedConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/client/clients.ts b/src/backend/common/infrastructure/config/client/clients.ts index 423d0ed6..e7e370e7 100644 --- a/src/backend/common/infrastructure/config/client/clients.ts +++ b/src/backend/common/infrastructure/config/client/clients.ts @@ -1,12 +1,12 @@ import * as z from "zod"; -import {koitoClientAIOConfigSchema, koitoClientConfigSchema, type KoitoClientConfig} from "./koito.ts"; -import {lastfmClientAIOConfigSchema, lastfmClientConfigSchema, type LastfmClientConfig} from "./lastfm.ts"; -import {listenBrainzClientAIOConfigSchema, listenBrainzClientConfigSchema, type ListenBrainzClientConfig} from "./listenbrainz.ts"; -import {malojaClientAIOConfigSchema, malojaClientConfigSchema, type MalojaClientConfig} from "./maloja.ts"; -import {tealClientAIOConfigSchema, tealClientConfigSchema, type TealClientConfig} from "./tealfm.ts"; -import {rockSkyClientAIOConfigSchema, rockSkyClientConfigSchema, type RockSkyClientConfig} from "./rocksky.ts"; -import {librefmClientAIOConfigSchema, librefmClientConfigSchema, type LibrefmClientConfig} from "./librefm.ts"; -import {discordClientAIOConfigSchema, discordClientConfigSchema, type DiscordClientConfig} from "./discord.ts"; +import {koitoClientAIOConfigSchema, koitoClientConfigSchema, type KoitoClientAIOConfig, type KoitoClientConfig} from "./koito.ts"; +import {lastfmClientAIOConfigSchema, lastfmClientConfigSchema, type LastfmClientAIOConfig, type LastfmClientConfig} from "./lastfm.ts"; +import {listenBrainzClientAIOConfigSchema, listenBrainzClientConfigSchema, type ListenBrainzClientAIOConfig, type ListenBrainzClientConfig} from "./listenbrainz.ts"; +import {malojaClientAIOConfigSchema, malojaClientConfigSchema, type MalojaClientAIOConfig, type MalojaClientConfig} from "./maloja.ts"; +import {tealClientAIOConfigSchema, tealClientConfigSchema, type TealClientAIOConfig, type TealClientConfig} from "./tealfm.ts"; +import {rockSkyClientAIOConfigSchema, rockSkyClientConfigSchema, type RockSkyClientAIOConfig, type RockSkyClientConfig} from "./rocksky.ts"; +import {librefmClientAIOConfigSchema, librefmClientConfigSchema, type LibrefmClientAIOConfig, type LibrefmClientConfig} from "./librefm.ts"; +import {discordClientAIOConfigSchema, discordClientConfigSchema, type DiscordClientAIOConfig, type DiscordClientConfig} from "./discord.ts"; import type { CommonClientConfig } from "./index.ts"; import type { ClientType } from "../../../../../core/Atomic.ts"; @@ -101,7 +101,7 @@ export interface ClientTypeConfigMap extends Record } = { +export const clientConfigSchemaMap: { [K in keyof ClientTypeConfigMap]: z.ZodType } = { maloja: malojaClientConfigSchema, lastfm: lastfmClientConfigSchema, librefm: librefmClientConfigSchema, @@ -112,4 +112,28 @@ const clientConfigSchemaMap: { [K in keyof ClientTypeConfigMap]: z.ZodType(clientType: T, json: object): ClientTypeConfigMap[T] => clientConfigSchemaMap[clientType].parse(json); \ No newline at end of file +export const validateClientJson = (clientType: T, json: object): ClientTypeConfigMap[T] => clientConfigSchemaMap[clientType].parse(json); + +export interface ClientTypeAIOConfigMap extends Record { + maloja: MalojaClientAIOConfig, + lastfm: LastfmClientAIOConfig, + librefm: LibrefmClientAIOConfig, + listenbrainz: ListenBrainzClientAIOConfig, + koito: KoitoClientAIOConfig, + tealfm: TealClientAIOConfig, + rocksky: RockSkyClientAIOConfig, + discord: DiscordClientAIOConfig +} + +export const clientAIOConfigSchemaMap: { [K in keyof ClientTypeAIOConfigMap]: z.ZodType } = { + maloja: malojaClientAIOConfigSchema, + lastfm: lastfmClientAIOConfigSchema, + librefm: librefmClientAIOConfigSchema, + listenbrainz: listenBrainzClientAIOConfigSchema, + koito: koitoClientAIOConfigSchema, + tealfm: tealClientAIOConfigSchema, + rocksky: rockSkyClientAIOConfigSchema, + discord: discordClientAIOConfigSchema +} + +export const validateClientAIOJson = (clientType: T, json: object): ClientTypeAIOConfigMap[T] => clientAIOConfigSchemaMap[clientType].parse(json); \ No newline at end of file diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index 85185856..3d7f7655 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -1,41 +1,41 @@ import * as z from "zod"; -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 {azuracastSourceAIOConfigSchema, azuracastSourceConfigSchema, type AzuracastSourceAIOConfig, type AzuracastSourceConfig} from "./azuracast.ts"; +import {chromecastSourceAIOConfigSchema, chromecastSourceConfigSchema, type ChromecastSourceAIOConfig, type ChromecastSourceConfig} from "./chromecast.ts"; +import {listenbrainzEndpointSourceAIOConfigSchema, listenbrainzEndpointSourceConfigSchema, type ListenbrainzEndpointSourceAIOConfig, type ListenbrainzEndpointSourceConfig} from "./endpointlz.ts"; +import {lastFmEndpointSourceAIOConfigSchema, lastFmEndpointSourceConfigSchema, type LastFMEndpointSourceAIOConfig, type LastFMEndpointSourceConfig} from "./endpointlfm.ts"; +import {deezerInternalSourceConfigSchema, deezerInternalAIOConfigSchema, type DeezerInternalSourceConfig, type DeezerInternalAIOConfig} from "./deezer.ts"; +import {jellyApiSourceAIOConfigSchema, jellyApiSourceConfigSchema, type JellyApiSourceAIOConfig, type JellyApiSourceConfig} from "./jellyfin.ts"; +import {jRiverSourceAIOConfigSchema, jRiverSourceConfigSchema, type JRiverSourceAIOConfig, type JRiverSourceConfig} from "./jriver.ts"; +import {kodiSourceAIOConfigSchema, kodiSourceConfigSchema, type KodiSourceAIOConfig, type KodiSourceConfig} from "./kodi.ts"; +import {lastFmSouceAIOConfigSchema, lastfmSourceConfigSchema, type LastFmSouceAIOConfig, type LastfmSourceConfig} from "./lastfm.ts"; +import {listenBrainzSourceAIOConfigSchema, listenBrainzSourceConfigSchema, type ListenBrainzSourceAIOConfig, type ListenBrainzSourceConfig} from "./listenbrainz.ts"; +import {mopidySourceAIOConfigSchema, mopidySourceConfigSchema, type MopidySourceAIOConfig, type MopidySourceConfig} from "./mopidy.ts"; +import {mpdSourceAIOConfigSchema, mpdSourceConfigSchema, type MPDSourceAIOConfig, type MPDSourceConfig} from "./mpd.ts"; +import {mprisSourceAIOConfigSchema, mprisSourceConfigSchema, type MPRISSourceAIOConfig, type MPRISSourceConfig} from "./mpris.ts"; +import {musikcubeSourceAIOConfigSchema, musikcubeSourceConfigSchema, type MusikcubeSourceAIOConfig, type MusikcubeSourceConfig} from "./musikcube.ts"; +import {musicCastSourceConfigSchema, musicCastSourceAIOConfigSchema, type MusicCastSourceConfig, type MusicCastSourceAIOConfig} from "./musiccast.ts"; +import {plexApiSourceConfigSchema, plexApiSourceAIOConfigSchema, type PlexApiSourceConfig, type PlexApiSourceAIOConfig} from "./plex.ts"; +import {spotifySourceAIOConfigSchema, spotifySourceConfigSchema, type SpotifySourceAIOConfig, type SpotifySourceConfig} from "./spotify.ts"; +import {subsonicSourceAIOConfigSchema, subSonicSourceConfigSchema, type SubsonicSourceAIOConfig, type SubSonicSourceConfig} from "./subsonic.ts"; +import {vlcSourceAIOConfigSchema, vlcSourceConfigSchema, type VLCSourceAIOConfig, type VLCSourceConfig} from "./vlc.ts"; +import {webScrobblerSourceAIOConfigSchema, webScrobblerSourceConfigSchema, type WebScrobblerSourceAIOConfig, type WebScrobblerSourceConfig} from "./webscrobbler.ts"; +import {ytMusicSourceAIOConfigSchema, ytMusicSourceConfigSchema, type YTMusicSourceAIOConfig, type YTMusicSourceConfig} from "./ytmusic.ts"; +import {yandexMusicBridgeSourceAIOConfigSchema, yandexMusicBridgeSourceConfigSchema, type YandexMusicBridgeSourceAIOConfig, type YandexMusicBridgeSourceConfig} from "./ymbridge.ts"; +import {icecastSourceAIOConfigSchema, icecastSourceConfigSchema, type IcecastSourceAIOConfig, type IcecastSourceConfig} from "./icecast.ts"; +import {koitoSourceAIOConfigSchema, koitoSourceConfigSchema, type KoitoSourceAIOConfig, type KoitoSourceConfig} from "./koito.ts"; +import {malojaSourceAIOConfigSchema, malojaSourceConfigSchema, type MalojaSourceAIOConfig, type MalojaSourceConfig} from "./maloja.ts"; +import {tealSourceAIOConfigSchema, tealSourceConfigSchema, type TealSourceAIOConfig, type TealSourceConfig} from "./tealfm.ts"; +import {rockskySourceAIOConfigSchema, rockskySourceConfigSchema, type RockskySourceAIOConfig, type RockskySourceConfig} from "./rocksky.ts"; +import {librefmSouceAIOConfigSchema, librefmSourceConfigSchema, type LibrefmSouceAIOConfig, type LibrefmSourceConfig} from "./librefm.ts"; +import {sonosSourceAIOConfigSchema, sonosSourceConfigSchema, type SonosSourceAIOConfig, type SonosSourceConfig} from "./sonos.ts"; +import {appleMusicSourceAIOConfigSchema, appleMusicSourceConfigSchema, type AppleMusicSourceAIOConfig, type AppleMusicSourceConfig} from "./applemusic.ts"; import type { SourceType } from "../../../../../core/Atomic.ts"; import type { CommonSourceConfig } from "./index.ts"; export const sourceConfigSchema = z.union([ spotifySourceConfigSchema, plexApiSourceConfigSchema, - deezerCompatConfigSchema, + deezerInternalSourceConfigSchema, listenbrainzEndpointSourceConfigSchema, lastFmEndpointSourceConfigSchema, subSonicSourceConfigSchema, @@ -70,7 +70,7 @@ export type SourceConfig = z.infer; export const sourceAIOConfigSchema = z.union([ spotifySourceAIOConfigSchema, plexApiSourceAIOConfigSchema, - deezerAIOCompatConfigSchema, + deezerInternalAIOConfigSchema, listenbrainzEndpointSourceAIOConfigSchema, lastFmEndpointSourceAIOConfigSchema, subsonicSourceAIOConfigSchema, @@ -102,140 +102,6 @@ export const sourceAIOConfigSchema = z.union([ export type SourceAIOConfig = z.infer; -/** Used for docusaurus schemas - * We need to show "array of" for each type of config when looking at File Config - * - * This is defined in the AIO config and we *assume* arrays in individual files when parsing in builders - * But we don't have any actual definitions for this that we can pull for generating individual schema files - */ -export const spotifySourceConfigsSchema = z.array(spotifySourceConfigSchema); - -export type SpotifySourceConfigs = z.infer; - -export const plexApiSourceConfigsSchema = z.array(plexApiSourceConfigSchema); - -export type PlexApiSourceConfigs = z.infer; - -export const deezerSourceConfigsSchema = z.array(deezerSourceConfigSchema); - -export type DeezerSourceConfigs = z.infer; - -export const deezerInternalSourceConfigsSchema = z.array(deezerInternalSourceConfigSchema); - -export type DeezerInternalSourceConfigs = z.infer; - -export const deezerCompatConfigsSchema = z.array(deezerCompatConfigSchema); - -export type DeezerCompatConfigs = z.infer; - -export const listenbrainzEndpointSourceConfigsSchema = z.array(listenbrainzEndpointSourceConfigSchema); - -export type ListenbrainzEndpointSourceConfigs = z.infer; - -export const lastFmEndpointSourceConfigsSchema = z.array(lastFmEndpointSourceConfigSchema); - -export type LastFMEndpointSourceConfigs = z.infer; - -export const subSonicSourceConfigsSchema = z.array(subSonicSourceConfigSchema); - -export type SubSonicSourceConfigs = z.infer; - -export const jellyApiSourceConfigsSchema = z.array(jellyApiSourceConfigSchema); - -export type JellyApiSourceConfigs = z.infer; - -export const lastfmSourceConfigsSchema = z.array(lastfmSourceConfigSchema); - -export type LastfmSourceConfigs = z.infer; - -export const librefmSourceConfigsSchema = z.array(librefmSourceConfigSchema); - -export type LibrefmSourceConfigs = z.infer; - -export const ytMusicSourceConfigsSchema = z.array(ytMusicSourceConfigSchema); - -export type YTMusicSourceConfigs = z.infer; - -export const yandexMusicBridgeSourceConfigsSchema = z.array(yandexMusicBridgeSourceConfigSchema); - -export type YandexMusicBridgeSourceConfigs = z.infer; - -export const mprisSourceConfigsSchema = z.array(mprisSourceConfigSchema); - -export type MPRISSourceConfigs = z.infer; - -export const mopidySourceConfigsSchema = z.array(mopidySourceConfigSchema); - -export type MopidySourceConfigs = z.infer; - -export const listenBrainzSourceConfigsSchema = z.array(listenBrainzSourceConfigSchema); - -export type ListenBrainzSourceConfigs = z.infer; - -export const jRiverSourceConfigsSchema = z.array(jRiverSourceConfigSchema); - -export type JRiverSourceConfigs = z.infer; - -export const kodiSourceConfigsSchema = z.array(kodiSourceConfigSchema); - -export type KodiSourceConfigs = z.infer; - -export const webScrobblerSourceConfigsSchema = z.array(webScrobblerSourceConfigSchema); - -export type WebScrobblerSourceConfigs = z.infer; - -export const chromecastSourceConfigsSchema = z.array(chromecastSourceConfigSchema); - -export type ChromecastSourceConfigs = z.infer; - -export const malojaSourceConfigsSchema = z.array(malojaSourceConfigSchema); - -export type MalojaSourceConfigs = z.infer; - -export const musikcubeSourceConfigsSchema = z.array(musikcubeSourceConfigSchema); - -export type MusikcubeSourceConfigs = z.infer; - -export const musicCastSourceConfigsSchema = z.array(musicCastSourceConfigSchema); - -export type MusicCastSourceConfigs = z.infer; - -export const mpdSourceConfigsSchema = z.array(mpdSourceConfigSchema); - -export type MPDSourceConfigs = z.infer; - -export const vlcSourceConfigsSchema = z.array(vlcSourceConfigSchema); - -export type VLCSourceConfigs = z.infer; - -export const icecastSourceConfigsSchema = z.array(icecastSourceConfigSchema); - -export type IcecastSourceConfigs = z.infer; - -export const azuracastSourceConfigsSchema = z.array(azuracastSourceConfigSchema); - -export type AzuracastSourceConfigs = z.infer; - -export const koitoSourceConfigsSchema = z.array(koitoSourceConfigSchema); - -export type KoitoSourceConfigs = z.infer; - -export const tealSourceConfigsSchema = z.array(tealSourceConfigSchema); - -export type TealSourceConfigs = z.infer; - -export const rockskySourceConfigsSchema = z.array(rockskySourceConfigSchema); - -export type RockskySourceConfigs = z.infer; - -export const sonosSourceConfigsSchema = z.array(sonosSourceConfigSchema); - -export type SonosSourceConfigs = z.infer; - -export const appleMusicSourceConfigsSchema = z.array(appleMusicSourceConfigSchema); - -export type AppleMusicSourceConfigs = z.infer; - export const atomicSourceInterfaces = [ 'SpotifySourceConfig', 'PlexApiSourceConfig', @@ -269,73 +135,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 +export interface SourceTypeConfigMap extends Record { + spotify: [SpotifySourceConfig, SpotifySourceAIOConfig], + plex: [PlexApiSourceConfig, PlexApiSourceAIOConfig], + deezer: [DeezerInternalSourceConfig, DeezerInternalAIOConfig], + endpointlz: [ListenbrainzEndpointSourceConfig, ListenbrainzEndpointSourceAIOConfig], + endpointlfm: [LastFMEndpointSourceConfig, LastFMEndpointSourceAIOConfig], + icecast: [IcecastSourceConfig, IcecastSourceAIOConfig], + subsonic: [SubSonicSourceConfig, SubsonicSourceAIOConfig], + jellyfin: [JellyApiSourceConfig, JellyApiSourceAIOConfig], + lastfm: [LastfmSourceConfig, LastFmSouceAIOConfig], + librefm: [LibrefmSourceConfig, LibrefmSouceAIOConfig], + ytmusic: [YTMusicSourceConfig, YTMusicSourceAIOConfig], + ymbridge: [YandexMusicBridgeSourceConfig, YandexMusicBridgeSourceAIOConfig], + maloja: [MalojaSourceConfig, MalojaSourceAIOConfig], + mpris: [MPRISSourceConfig, MPRISSourceAIOConfig], + mopidy: [MopidySourceConfig, MopidySourceAIOConfig], + listenbrainz: [ListenBrainzSourceConfig, ListenBrainzSourceAIOConfig], + jriver: [JRiverSourceConfig, JRiverSourceAIOConfig], + kodi: [KodiSourceConfig, KodiSourceAIOConfig], + chromecast: [ChromecastSourceConfig, ChromecastSourceAIOConfig], + webscrobbler: [WebScrobblerSourceConfig, WebScrobblerSourceAIOConfig], + musikcube: [MusikcubeSourceConfig, MusikcubeSourceAIOConfig], + musiccast: [MusicCastSourceConfig, MusicCastSourceAIOConfig], + mpd: [MPDSourceConfig, MPDSourceAIOConfig], + vlc: [VLCSourceConfig, VLCSourceAIOConfig], + azuracast: [AzuracastSourceConfig, AzuracastSourceAIOConfig], + koito: [KoitoSourceConfig, KoitoSourceAIOConfig], + tealfm: [TealSourceConfig, TealSourceAIOConfig], + rocksky: [RockskySourceConfig, RockskySourceAIOConfig], + sonos: [SonosSourceConfig, SonosSourceAIOConfig], + applemusic: [AppleMusicSourceConfig, AppleMusicSourceAIOConfig] } -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 sourceConfigSchemaMap: { [K in keyof SourceTypeConfigMap]: [z.ZodType, z.ZodType] } = { + spotify: [spotifySourceConfigSchema, spotifySourceAIOConfigSchema], + plex: [plexApiSourceConfigSchema, plexApiSourceAIOConfigSchema], + deezer: [deezerInternalSourceConfigSchema, deezerInternalAIOConfigSchema], + endpointlz: [listenbrainzEndpointSourceConfigSchema, listenbrainzEndpointSourceAIOConfigSchema], + endpointlfm: [lastFmEndpointSourceConfigSchema, lastFmEndpointSourceAIOConfigSchema], + icecast: [icecastSourceConfigSchema, icecastSourceAIOConfigSchema], + subsonic: [subSonicSourceConfigSchema, subsonicSourceAIOConfigSchema], + jellyfin: [jellyApiSourceConfigSchema, jellyApiSourceAIOConfigSchema], + lastfm: [lastfmSourceConfigSchema, lastFmSouceAIOConfigSchema], + librefm: [librefmSourceConfigSchema, librefmSouceAIOConfigSchema], + ytmusic: [ytMusicSourceConfigSchema, ytMusicSourceAIOConfigSchema], + ymbridge: [yandexMusicBridgeSourceConfigSchema, yandexMusicBridgeSourceAIOConfigSchema], + maloja: [malojaSourceConfigSchema, malojaSourceAIOConfigSchema], + mpris: [mprisSourceConfigSchema, mprisSourceAIOConfigSchema], + mopidy: [mopidySourceConfigSchema, mopidySourceAIOConfigSchema], + listenbrainz: [listenBrainzSourceConfigSchema, listenBrainzSourceAIOConfigSchema], + jriver: [jRiverSourceConfigSchema, jRiverSourceAIOConfigSchema], + kodi: [kodiSourceConfigSchema, kodiSourceAIOConfigSchema], + chromecast: [chromecastSourceConfigSchema, chromecastSourceAIOConfigSchema], + webscrobbler: [webScrobblerSourceConfigSchema, webScrobblerSourceAIOConfigSchema], + musikcube: [musikcubeSourceConfigSchema, musikcubeSourceAIOConfigSchema], + musiccast: [musicCastSourceConfigSchema, musicCastSourceAIOConfigSchema], + mpd: [mpdSourceConfigSchema, mpdSourceAIOConfigSchema], + vlc: [vlcSourceConfigSchema, vlcSourceAIOConfigSchema], + azuracast: [azuracastSourceConfigSchema, azuracastSourceAIOConfigSchema], + koito: [koitoSourceConfigSchema, koitoSourceAIOConfigSchema], + tealfm: [tealSourceConfigSchema, tealSourceAIOConfigSchema], + rocksky: [rockskySourceConfigSchema, rockskySourceAIOConfigSchema], + sonos: [sonosSourceConfigSchema, sonosSourceAIOConfigSchema], + applemusic: [appleMusicSourceConfigSchema, appleMusicSourceAIOConfigSchema] }; -export const validateSourceJson = (sourceType: T, json: object): SourceTypeConfigMap[T] => sourceConfigSchemaMap[sourceType].parse(json); +export const validateSourceJson = (sourceType: T, json: object): SourceTypeConfigMap[T][0] => sourceConfigSchemaMap[sourceType][0].parse(json); +export const validateSourceAIOJson = (sourceType: T, json: object): SourceTypeConfigMap[T][1] => sourceConfigSchemaMap[sourceType][1].parse(json); export const sourceInterfaces = [ 'AIOSourceRelaxedConfig', diff --git a/src/backend/scrobblers/ScrobbleClients.ts b/src/backend/scrobblers/ScrobbleClients.ts index 6dc68262..bdabe6e1 100644 --- a/src/backend/scrobblers/ScrobbleClients.ts +++ b/src/backend/scrobblers/ScrobbleClients.ts @@ -6,17 +6,16 @@ import type {ConfigMeta, InternalConfig, InternalConfigOptional, SourceIdentifie import { isClientType } from '../../core/Atomic.ts'; import { clientTypes } from "../../core/Atomic.ts"; import type {ClientType} from "../../core/Atomic.ts"; -import type {AIOConfig} from "../common/infrastructure/config/aioConfig.ts"; -import {validateClientJson, type ClientAIOConfig, type ClientConfig} from "../common/infrastructure/config/client/clients.ts"; +import {aioClientRelaxedConfigSchema} from "../common/infrastructure/config/aioConfig.ts"; +import {validateClientAIOJson, validateClientJson, type ClientAIOConfig} from "../common/infrastructure/config/client/clients.ts"; import type {LastfmClientConfig, LastfmData} from "../common/infrastructure/config/client/lastfm.ts"; import type {ListenBrainzClientConfig, ListenBrainzData} from "../common/infrastructure/config/client/listenbrainz.ts"; import type {MalojaClientConfig, MalojaData} from "../common/infrastructure/config/client/maloja.ts"; import type { WildcardEmitter } from "../common/WildcardEmitter.ts"; import type { Notifiers } from "../notifier/Notifiers.ts"; -import { isDebugMode, nonEmptyObj } from "../utils.ts"; +import { nonEmptyObj } from "../utils.ts"; import { removeUndefinedKeys } from '../../core/DataUtils.ts'; import { getCommonComponentEnvConfig, readJson } from '../utils/DataUtils.ts'; -import { validateJson } from "../utils/ValidationUtils.ts"; import type AbstractScrobbleClient from "./AbstractScrobbleClient.ts"; import type {KoitoClientConfig, KoitoData} from '../common/infrastructure/config/client/koito.ts'; import type {TealClientConfig, TealData} from '../common/infrastructure/config/client/tealfm.ts'; @@ -107,27 +106,6 @@ export default class ScrobbleClients { return [clientsReady, messages]; } - private getSchemaByType = (type: ClientType): string => { - switch(type) { - case 'maloja': - return "MalojaClientConfig"; - case 'lastfm': - return "LastfmClientConfig"; - case 'librefm': - return "LibrefmClientConfig"; - case 'listenbrainz': - return "ListenBrainzClientConfig"; - case 'koito': - return "KoitoClientConfig"; - case 'tealfm': - return "TealClientConfig"; - case 'rocksky': - return "RockSkyClientConfig"; - case 'discord': - return 'DiscordClientConfig'; - } - } - buildClientsFromConfig = async (notifier: Notifiers) => { const configs: ParsedConfig[] = []; @@ -136,12 +114,12 @@ export default class ScrobbleClients { configFile = await readJson(`${this.internalConfig.configDir}/config.json`, {throwOnNotFound: false, logger: childLogger(this.logger, `Secrets`)}); } catch (e) { // think this should stay as show-stopper since config could include important defaults (delay, retries) we don't want to ignore - throw new Error('config.json could not be parsed'); + throw new Error('config.json could not be parsed', {cause: e}); } let clientDefaults = {}; if (configFile !== undefined) { - const aioConfig = await validateJson('client', configFile, 'AIOClientRelaxedConfig', this.logger); + const aioConfig = aioClientRelaxedConfigSchema.parse(configFile); // await validateJson('client', configFile, 'AIOClientRelaxedConfig', this.logger); const { clients: mainConfigClientConfigs = [], clientDefaults: cd = {}, @@ -167,16 +145,17 @@ export default class ScrobbleClients { this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a source.`); continue; } + let validatedConfig: ClientAIOConfig; try { - await validateJson('client', c, this.getSchemaByType(c.type.toLocaleLowerCase() as ClientType), this.logger); + validatedConfig = await validateClientAIOJson(c.type.toLocaleLowerCase() as ClientType, c) // validateJson('client', c, this.getSchemaByType(c.type.toLocaleLowerCase() as ClientType), this.logger); } catch (e) { const err = new Error(`Client config ${index + 1} (${c.type} - ${name}) in config.json is invalid and will not be used.`, {cause: e}); this.emitter.emit('error', err); this.logger.error(err); continue; } - configs.push({...c, - name, + configs.push({...validatedConfig, + name: validatedConfig.name ?? 'unnamed', source: 'config.json', configureAs: 'client', //override user value }); @@ -638,7 +617,7 @@ const transformPresetEnv = } } - // @ts-ignore + // @ts-expect-error T is fine return { ...(existing || {}), playTransform: popts diff --git a/src/backend/sources/ScrobbleSources.ts b/src/backend/sources/ScrobbleSources.ts index 58dc8d43..62548a77 100644 --- a/src/backend/sources/ScrobbleSources.ts +++ b/src/backend/sources/ScrobbleSources.ts @@ -2,10 +2,10 @@ import { childLogger, type Logger } from '@foxxmd/logging'; import type EventEmitter from "events"; import type {ConfigMeta, ConfigureAsSource, InternalConfig, InternalConfigOptional} from "../common/infrastructure/Atomic.ts"; -import { isSourceType } from "../../core/Atomic.ts"; +import { clientTypes, isSourceType } from "../../core/Atomic.ts"; import { sourceTypes } from "../../core/Atomic.ts"; -import type {SourceType} from "../../core/Atomic.ts"; -import type {AIOConfig, SourceDefaults} from "../common/infrastructure/config/aioConfig.ts"; +import type {ClientType, SourceType} from "../../core/Atomic.ts"; +import {aioSourceRelaxedConfigSchema, type SourceDefaults} from "../common/infrastructure/config/aioConfig.ts"; import type {AzuracastData, AzuracastSourceConfig} from "../common/infrastructure/config/source/azuracast.ts"; import type {ChromecastData, ChromecastSourceConfig} from "../common/infrastructure/config/source/chromecast.ts"; import type {DeezerSourceConfig, DeezerInternalSourceConfig, DeezerCompatConfig} from "../common/infrastructure/config/source/deezer.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 {validateSourceJson, type SourceAIOConfig, type SourceConfig} from "../common/infrastructure/config/source/sources.ts"; +import {validateSourceAIOJson, 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"; @@ -37,7 +37,6 @@ import type { WildcardEmitter } from "../common/WildcardEmitter.ts"; import { nonEmptyObj, parseBool, parseBoolStrict } from "../utils.ts"; import { removeUndefinedKeys } from '../../core/DataUtils.ts'; import { getCommonComponentEnvConfig, readJson } from '../utils/DataUtils.ts'; -import { validateJson } from "../utils/ValidationUtils.ts"; import type AbstractSource from "./AbstractSource.ts"; import { nonEmptyStringOrDefault } from '../../core/StringUtils.ts'; import type {KoitoSourceConfig} from '../common/infrastructure/config/source/koito.ts'; @@ -112,71 +111,6 @@ export default class ScrobbleSources { return [sourcesReady, messages]; } - private getSchemaByType = (type: SourceType): string => { - switch(type) { - case 'spotify': - return "SpotifySourceConfig"; - case 'plex': - return "PlexApiSourceConfig"; - case 'deezer': - return "DeezerCompatConfig"; - case 'endpointlz': - return "ListenbrainzEndpointSourceConfig"; - case 'endpointlfm': - return "LastFMEndpointSourceConfig"; - case 'icecast': - return "IcecastSourceConfig"; - case 'subsonic': - return "SubSonicSourceConfig"; - case 'jellyfin': - return "JellyApiSourceConfig"; - case 'lastfm': - return "LastfmSourceConfig"; - case 'librefm': - return "LibrefmSourceConfig"; - case 'ytmusic': - return "YTMusicSourceConfig"; - case 'ymbridge': - return "YandexMusicBridgeSourceConfig"; - case 'maloja': - return "MalojaSourceConfig"; - case 'mpris': - return "MPRISSourceConfig"; - case 'mopidy': - return "MopidySourceConfig"; - case 'listenbrainz': - return "ListenBrainzSourceConfig"; - case 'jriver': - return "JRiverSourceConfig"; - case 'kodi': - return "KodiSourceConfig"; - case 'chromecast': - return "ChromecastSourceConfig"; - case 'webscrobbler': - return "WebScrobblerSourceConfig"; - case 'musikcube': - return "MusikcubeSourceConfig"; - case 'musiccast': - return "MusicCastSourceConfig"; - case 'mpd': - return "MPDSourceConfig"; - case 'vlc': - return "VLCSourceConfig"; - case 'azuracast': - return "AzuracastSourceConfig"; - case 'koito': - return "KoitoSourceConfig"; - case 'tealfm': - return "TealSourceConfig"; - case 'rocksky': - return "RockskySourceConfig"; - case 'sonos': - return 'SonosSourceConfig'; - case 'applemusic': - return 'AppleMusicSourceConfig'; - } - } - buildSourceDefaults = (fileDefaults: SourceDefaults = {}): SourceDefaults => { const scrobbleDurationEnv = process.env.SOURCE_SCROBBLE_DURATION; const scrobblePercentEnv = process.env.SOURCE_SCROBBLE_PERCENT; @@ -223,12 +157,12 @@ export default class ScrobbleSources { try { configFile = await readJson(`${this.internalConfig.configDir}/config.json`, {throwOnNotFound: false, logger: childLogger(this.logger, `Secrets`)}); } catch (e) { - throw new Error('config.json could not be parsed'); + throw new Error('config.json could not be parsed', {cause: e}); } - let sourceDefaults = {}; + let sourceDefaults: SourceDefaults; if (configFile !== undefined) { - const aioConfig = await validateJson('source', configFile, 'AIOSourceRelaxedConfig', this.logger); + const aioConfig = aioSourceRelaxedConfigSchema.parse(configFile); // await validateJson('source', configFile, 'AIOSourceRelaxedConfig', this.logger); const { sources: mainConfigSourcesConfigs = [], sourceDefaults: sd = {}, @@ -251,13 +185,14 @@ export default class ScrobbleSources { this.logger.error(invalidMsgType); continue; } - if(['lastfm','listenbrainz','koito','tealfm','rocksky'].includes(c.type.toLocaleLowerCase()) && ((c as LastfmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | TealSourceConfig | RockskySourceConfig).configureAs !== 'source')) + if(clientTypes.includes(c.type.toLocaleLowerCase() as ClientType) && (c.configureAs !== 'source')) { this.logger.debug(`Skipping config ${index + 1} (${name}) in config.json because it is configured as a client.`); continue; } + let validatedSourceConfig: SourceAIOConfig; try { - validateSourceJson(c.type.toLocaleLowerCase() as SourceType, c); // validateJson('source', c, this.getSchemaByType(c.type.toLocaleLowerCase() as SourceType), this.logger); + validatedSourceConfig = validateSourceAIOJson(c.type.toLocaleLowerCase() as SourceType, c); // validateJson('source', c, this.getSchemaByType(c.type.toLocaleLowerCase() as SourceType), this.logger); } catch (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}); @@ -270,8 +205,8 @@ export default class ScrobbleSources { } continue; } - configs.push({...c, - name, + configs.push({...validatedSourceConfig, + name: validatedSourceConfig.name ?? 'unnamed', source: 'config.json', configureAs: 'source' // override user value }); @@ -930,7 +865,7 @@ export default class ScrobbleSources { continue; } if (rawSourceConfigs !== undefined) { - let sourceConfigs: ParsedConfig[] = []; + let sourceConfigs: ParsedConfig[]; if (Array.isArray(rawSourceConfigs)) { sourceConfigs = rawSourceConfigs; } else if (rawSourceConfigs === null) { @@ -943,15 +878,14 @@ export default class ScrobbleSources { continue; } for (const [i,rawConf] of sourceConfigs.entries()) { - if(['lastfm','listenbrainz','koito','maloja','tealfm','rocksky','librefm'].includes(sourceType) && - ((rawConf as LastfmSourceConfig | LibrefmSourceConfig | ListenBrainzSourceConfig | KoitoSourceConfig | MalojaSourceConfig | TealSourceConfig | RockskySourceConfig).configureAs !== 'source')) + if(clientTypes.includes(sourceType as ClientType) && + (rawConf.configureAs !== 'source')) { this.logger.debug(`Skipping config ${i + 1} from ${sourceType}.json because it is configured as a client.`); continue; } try { - const validConfig = await validateJson('source', rawConf, this.getSchemaByType(sourceType), this.logger); - + await validateSourceJson(sourceType, rawConf); // @ts-expect-error will eventually have all info (lazy) const parsedConfig: ParsedConfig = { ...rawConf, @@ -960,9 +894,14 @@ export default class ScrobbleSources { } configs.push(parsedConfig); } catch (e: any) { - const configErr = new Error(`The config entry at index ${i} from ${sourceType}.json was not valid`, {cause: e}); + const msg = `The config entry at index ${i} from ${sourceType}.json was not valid`; + const configErr = new Error(msg, {cause: e}); this.emitter.emit('error', configErr); - this.logger.error(configErr); + if(e instanceof ZodError) { + this.logger.error(`${msg}:\n${prettifyError(e)}`); + } else { + this.logger.error(configErr); + } } } } @@ -1225,7 +1164,7 @@ const transformPresetEnv = } } - // @ts-ignore + // @ts-expect-error T is fine return { ...(existing || {}), playTransform: popts diff --git a/src/backend/utils.ts b/src/backend/utils.ts index 84aa1d5d..a4a46b29 100644 --- a/src/backend/utils.ts +++ b/src/backend/utils.ts @@ -20,7 +20,6 @@ import type {PlayPlatformId} from '../core/Atomic.ts'; import { genGroupIdStr } from '../core/PlayUtils.ts'; import { durationToNormalizedTime } from '../core/TimeUtils.ts'; -//const { default: Ajv } = AjvNS; dayjs.extend(utc); export function sleep(ms: any) { diff --git a/src/backend/utils/SchemaAppStaticUtils.ts b/src/backend/utils/SchemaAppStaticUtils.ts deleted file mode 100644 index ec8431af..00000000 --- a/src/backend/utils/SchemaAppStaticUtils.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { resolve } from "path"; -import {getTypeSchemaFromConfigGenerator} from "./SchemaUtils.ts"; -import { clientInterfaces } from '../common/infrastructure/config/client/clients.ts'; -import { sourceInterfaces } from '../common/infrastructure/config/source/sources.ts'; -import { projectRootDir } from "../common/infrastructure/Atomic.ts"; - -for(const inter of sourceInterfaces) { - const schema = getTypeSchemaFromConfigGenerator(inter); - writeFileSync(resolve(projectRootDir, `src/backend/common/schema/${inter}.json`), JSON.stringify(schema)); -} - -for(const inter of clientInterfaces) { - const schema = getTypeSchemaFromConfigGenerator(inter); - writeFileSync(resolve(projectRootDir, `src/backend/common/schema/${inter}.json`), JSON.stringify(schema)); -} \ No newline at end of file diff --git a/src/backend/utils/SchemaCompiledUtils.ts b/src/backend/utils/SchemaCompiledUtils.ts deleted file mode 100644 index 91fd0487..00000000 --- a/src/backend/utils/SchemaCompiledUtils.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { readFileSync, accessSync } from "node:fs"; -import { constants } from "fs"; -import { resolve } from "path"; -import { MaybeLogger } from '../common/MaybeLogger.ts'; -import { projectRootDir } from "../common/infrastructure/Atomic.ts"; - -export const getSchemaForType = (type: string, logger: MaybeLogger = new MaybeLogger()): any => { - const path = resolve(projectRootDir, `src/backend/common/schema/${type}.json`); - try { - accessSync(path, constants.R_OK); - } catch (e) { - const { code } = e; - if (code === 'ENOENT') { - throw new Error(`No file found at given path: ${path}`, { cause: e }); - } - throw new Error(`Encountered error while parsing file: ${path}`, { cause: e }); - } - return JSON.parse(readFileSync(path).toString()); -} \ No newline at end of file diff --git a/src/backend/utils/SchemaUtils.ts b/src/backend/utils/SchemaUtils.ts deleted file mode 100644 index 3aa8c35f..00000000 --- a/src/backend/utils/SchemaUtils.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { resolve } from "path"; -import type { LiteralType} from "ts-json-schema-generator"; -import { - type Definition as TSJDefinition, - createFormatter, - type CompletedConfig as TSJCompletedConfig, - DEFAULT_CONFIG, - createProgram, - createParser, - SchemaGenerator, LiteralTypeFormatter -} from "ts-json-schema-generator"; -import { MaybeLogger } from '../common/MaybeLogger.ts'; -import { projectRootDir } from "../common/infrastructure/Atomic.ts"; - -// https://github.com/vega/ts-json-schema-generator/issues/1899#issuecomment-2407674526 -// https://github.com/vega/ts-json-schema-generator?tab=readme-ov-file#custom-formatting -class CustomLiteralTypeFormatter extends LiteralTypeFormatter { - public getDefinition(type: LiteralType): TSJDefinition { - const result = super.getDefinition(type); - - if ("const" in result && result.const) { - return { type: result.type, enum: [result.const] }; - } - - return result; - } -} - - -const tsjConfig: TSJCompletedConfig = { - ...DEFAULT_CONFIG, - sortProps: false, - additionalProperties: true, - markdownDescription: true, - minify: false, - topRef: false, - path: resolve(projectRootDir, "src/backend/common/infrastructure/config/aioConfig.ts"), - tsconfig: resolve(projectRootDir, "src/backend/tsconfig.json"), -}; - -const formatter = createFormatter(tsjConfig, (fmt, circularReferenceTypeFormatter) => { - fmt.addTypeFormatter(new CustomLiteralTypeFormatter()); -}); - -let vegaGenerator: SchemaGenerator; - -export const createVegaGenerator = (logger: MaybeLogger = new MaybeLogger()) => { - if(vegaGenerator === undefined) { - logger.info('Generating schema definitions...'); - const program = createProgram(tsjConfig); - const parser = createParser(program, tsjConfig); - vegaGenerator = new SchemaGenerator(program, parser, formatter, tsjConfig); - logger.info('Schema definitions generated'); - } - return vegaGenerator; -} - -export const getTypeSchemaFromConfigGenerator = (type: string, logger: MaybeLogger = new MaybeLogger()): any => { - const generator = createVegaGenerator(logger); - const schema = generator.createSchema(type) - return schema; -} - -export const getSchemaForType = (type: string, logger: MaybeLogger = new MaybeLogger()): any => { - return getTypeSchemaFromConfigGenerator(type, logger); -} \ No newline at end of file diff --git a/src/backend/utils/ValidationUtils.ts b/src/backend/utils/ValidationUtils.ts deleted file mode 100644 index 86a01db9..00000000 --- a/src/backend/utils/ValidationUtils.ts +++ /dev/null @@ -1,103 +0,0 @@ -import type {Logger} from "@foxxmd/logging"; -import type * as AjvNS from "ajv"; -import Ajv from "ajv"; -import f from "ajv-formats" -import { resolve } from "path"; -import { projectRootDir } from "../common/infrastructure/Atomic.ts"; - -const ajvInstances: Record = {}; - -export const createAjvFactory = (logger: Logger): AjvNS.default => { - const validator = new Ajv.default({logger: logger, verbose: true, strict: "log", strictSchema: "log", allowUnionTypes: true}); - // https://ajv.js.org/strict-mode.html#unknown-keywords - validator.addKeyword('deprecationMessage'); - f.default(validator); - return validator; -} -export const validateJson = async (type: string, config: object, schemaIdentifier: string, logger: Logger): Promise => { - if(ajvInstances[type] === undefined) { - ajvInstances[type] = createAjvFactory(logger); - } - const ajv = ajvInstances[type]; - - let validate = ajv.getSchema(schemaIdentifier); - if(validate === undefined) { - const func = await getSchemaFunc(); - let schema; - try { - schema = await func(schemaIdentifier, logger); - } catch (e) { - logger.warn(new Error(`Could not retrieve schema for ${schemaIdentifier}, skipping validation`, {cause: e})); - return config as unknown as T; - } - ajv.addSchema(schema, schemaIdentifier); - validate = ajv.getSchema(schemaIdentifier); - } - - const valid = validate(config); - if (valid) { - return config as unknown as T; - } else { - const schemaErrors = ['Json config was not valid. Please use schema to check validity.']; - if (Array.isArray(validate.errors)) { - for (const err of validate.errors) { - const parts = [ - `At: ${err.instancePath}`, - ]; - let data; - if (typeof err.data === 'string') { - data = err.data; - } else if (err.data !== null && typeof err.data === 'object' && (err.data as any).name !== undefined) { - data = `Object named '${(err.data as any).name}'`; - } - if (data !== undefined) { - parts.push(`Data: ${data}`); - } - let suffix = ''; - if (err.params.allowedValues !== undefined) { - suffix = err.params.allowedValues.join(', '); - suffix = ` [${suffix}]`; - } - parts.push(`${err.keyword}: ${err.schemaPath} => ${err.message}${suffix}`); - - // if we have a reference in the description parse it out so we can log it here for context - if (err.parentSchema !== undefined && err.parentSchema.description !== undefined) { - const desc = err.parentSchema.description as string; - const seeIndex = desc.indexOf('[See]'); - if (seeIndex !== -1) { - let newLineIndex: number | undefined = desc.indexOf('\n', seeIndex); - if (newLineIndex === -1) { - newLineIndex = undefined; - } - const seeFragment = desc.slice(seeIndex + 5, newLineIndex); - parts.push(`See:${seeFragment}`); - } - } - - schemaErrors.push(`Schema Error:\r\n${parts.join('\r\n')}`); - } - } - throw new Error(schemaErrors.join('\n\n')); - } -} - -let schemaFetchFunc; - -const compiledPath = 'src/backend/utils/SchemaCompiledUtils.ts', -dynamicPath = 'src/backend/utils/SchemaUtils.ts'; - -const getSchemaFunc = async () => { - if(schemaFetchFunc !== undefined) { - return schemaFetchFunc; - } - const useCompiled = process.env.NODE_ENV === 'production' || process.env.COMPILED_VALIDATION === 'true'; - const schemaFuncPath = resolve(projectRootDir, useCompiled ? compiledPath : dynamicPath); - try { - const module = await import(resolve(projectRootDir, schemaFuncPath)) - schemaFetchFunc = module.getSchemaForType; - } catch (e) { - throw new Error(`Could not load module from path: ${schemaFuncPath}`, {cause: e}); - } - return schemaFetchFunc; -} - -- 2.51.2 From 98084665ba2f5cb8890b827564f3d266b31403a0 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 20:57:12 +0000 Subject: [PATCH 20/30] chore: remove ts-schema-generator and ajv --- package-lock.json | 362 ---------------------------------------------- package.json | 8 +- 2 files changed, 2 insertions(+), 368 deletions(-) diff --git a/package-lock.json b/package-lock.json index 49781c4e..d656066b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,8 +37,6 @@ "@xhayper/discord-rpc": "1.3.0", "abort-controller-x": "^0.5.0", "address": "^1.2.2", - "ajv": "^8.18.0", - "ajv-formats": "^3.0.1", "avahi-browse": "^1.1.4", "better-sse": "^0.8.0", "body-parser": "^2.2.2", @@ -206,7 +204,6 @@ "tailwindcss": "^4.2.2", "tinyexec": "^1.1.1", "ts-essentials": "^10.2.0", - "ts-json-schema-generator": "^2.3.0", "ts-to-zod": "^5.1.0", "typescript": "^5.9.2", "typescript-eslint": "^8.59.3", @@ -4540,17 +4537,6 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -8686,39 +8672,6 @@ "node": ">= 20" } }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" - } - }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", - "license": "MIT", - "dependencies": { - "ajv": "^8.0.0" - }, - "peerDependencies": { - "ajv": "^8.0.0" - }, - "peerDependenciesMeta": { - "ajv": { - "optional": true - } - } - }, "node_modules/anser": { "version": "2.3.5", "resolved": "https://registry.npmjs.org/anser/-/anser-2.3.5.tgz", @@ -11235,13 +11188,6 @@ "integrity": "sha512-1A8za6ws41LQgv9HrE/66jyC5yuSjQ3L/KOpFtoBilsAK2iA2wuS5rTt1OCzIvtS2V7nVmedsUU+DGRcjBmOYA==", "license": "BSD-3-Clause" }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, "node_modules/ecdsa-sig-formatter": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", @@ -12708,22 +12654,6 @@ "fast-string-truncated-width": "^3.0.2" } }, - "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" - }, "node_modules/fast-wrap-ansi": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/fast-wrap-ansi/-/fast-wrap-ansi-0.2.2.tgz", @@ -14833,12 +14763,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, "node_modules/json-stable-stringify": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.3.0.tgz", @@ -15686,13 +15610,6 @@ "integrity": "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg==", "license": "MIT" }, - "node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, "node_modules/lz-string": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", @@ -18373,15 +18290,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-from-string": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", - "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/reselect": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", @@ -19481,35 +19389,6 @@ "node": ">=8" } }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/string-width/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", @@ -19553,20 +19432,6 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/strip-ansi/node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -20189,201 +20054,6 @@ } } }, - "node_modules/ts-json-schema-generator": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/ts-json-schema-generator/-/ts-json-schema-generator-2.3.0.tgz", - "integrity": "sha512-t4lBQAwZc0sOJq9LJt3NgbznIcslVnm0JeEMFq8qIRklpMRY8jlYD0YmnRWbqBKANxkby91P1XanSSlSOFpUmg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/json-schema": "^7.0.15", - "commander": "^12.0.0", - "glob": "^10.3.12", - "json5": "^2.2.3", - "normalize-path": "^3.0.0", - "safe-stable-stringify": "^2.4.3", - "tslib": "^2.6.2", - "typescript": "^5.4.5" - }, - "bin": { - "ts-json-schema-generator": "bin/ts-json-schema-generator.js" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/ts-json-schema-generator/node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/ts-json-schema-generator/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ts-json-schema-generator/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ts-json-schema-generator/node_modules/brace-expansion": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.1.tgz", - "integrity": "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/ts-json-schema-generator/node_modules/commander": { - "version": "12.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", - "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/ts-json-schema-generator/node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/ts-json-schema-generator/node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ts-json-schema-generator/node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/ts-json-schema-generator/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ts-json-schema-generator/node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/ts-json-schema-generator/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/ts-json-schema-generator/node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/ts-mixer": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz", @@ -21524,38 +21194,6 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/wrap-ansi/node_modules/strip-ansi": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", diff --git a/package.json b/package.json index d13654be..30c2f009 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,8 @@ "type": "module", "description": "scrobble plays from multiple sources to multiple clients", "scripts": { - "schema": "npm run -s schema:docs && npm run -s schema:app", + "schema": "npm run -s schema:docs", "schema:docs": "node src/backend/utils/SchemaDocsStaticUtils.ts", - "schema:app": "node src/backend/utils/SchemaAppStaticUtils.ts", "circular": "madge --circular --extensions ts src/index.ts", "test": "npm run -s test:backend", "test:backend": "mocha --reporter spec", @@ -14,7 +13,7 @@ "start": "NODE_ENV=production node src/backend/index.ts", "build:frontend": "vite build", "build:backend:ts": "tsc -p src/backend", - "build:backend": "npm run -s build:backend:ts && npm run -s schema:app", + "build:backend": "npm run -s build:backend:ts", "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", @@ -78,8 +77,6 @@ "@xhayper/discord-rpc": "1.3.0", "abort-controller-x": "^0.5.0", "address": "^1.2.2", - "ajv": "^8.18.0", - "ajv-formats": "^3.0.1", "avahi-browse": "^1.1.4", "better-sse": "^0.8.0", "body-parser": "^2.2.2", @@ -247,7 +244,6 @@ "tailwindcss": "^4.2.2", "tinyexec": "^1.1.1", "ts-essentials": "^10.2.0", - "ts-json-schema-generator": "^2.3.0", "ts-to-zod": "^5.1.0", "typescript": "^5.9.2", "typescript-eslint": "^8.59.3", -- 2.51.2 From f21572bed08b3cf9217d26a2c2efa6b569532c57 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 20:58:04 +0000 Subject: [PATCH 21/30] chore: Remove ts-generator-schema rm from dockerfile layer --- Dockerfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index d429a237..f63d38de 100644 --- a/Dockerfile +++ b/Dockerfile @@ -115,8 +115,6 @@ ARG APP_BUILD_VERSION ENV APP_VERSION=$APP_BUILD_VERSION RUN npm ci --omit=dev --no-audit \ - # we used compiled schema json for production so this isn't needed - && npm uninstall ts-json-schema-generator \ && npm cache clean --force \ && chown -R abc:abc node_modules \ ## peerDependency of validbot and can(?) be removed for production -- 2.51.2 From 6eab629168042f8ba08c377e43d3041c4a9be5a5 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 21:09:00 +0000 Subject: [PATCH 22/30] remove unused patch --- patches/ts-json-schema-generator+2.3.0.patch | 39 -------------------- 1 file changed, 39 deletions(-) delete mode 100644 patches/ts-json-schema-generator+2.3.0.patch diff --git a/patches/ts-json-schema-generator+2.3.0.patch b/patches/ts-json-schema-generator+2.3.0.patch deleted file mode 100644 index 7c7031b3..00000000 --- a/patches/ts-json-schema-generator+2.3.0.patch +++ /dev/null @@ -1,39 +0,0 @@ -diff --git a/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js b/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js -index 107dc62..d8cda3c 100644 ---- a/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js -+++ b/node_modules/ts-json-schema-generator/dist/src/AnnotationsReader/ExtendedAnnotationsReader.js -@@ -49,7 +49,7 @@ class ExtendedAnnotationsReader extends BasicAnnotationsReader_js_1.BasicAnnotat - .replace(/\r/g, "") - .trim(); - const description = markdownDescription.replace(/(?<=[^\n])\n(?=[^\n*-])/g, " ").trim(); -- return this.markdownDescription ? { description, markdownDescription } : { description }; -+ return this.markdownDescription ? { description: markdownDescription } : { description }; - } - getTypeAnnotation(node) { - const symbol = (0, symbolAtNode_js_1.symbolAtNode)(node); -diff --git a/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js b/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js -index e3b7761..93f914a 100644 ---- a/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js -+++ b/node_modules/ts-json-schema-generator/dist/src/SchemaGenerator.js -@@ -95,7 +95,7 @@ class SchemaGenerator { - children.reduce((definitions, child) => { - const name = child.getName(); - if (!(name in definitions)) { -- definitions[name] = this.typeFormatter.getDefinition(child.getType()); -+ definitions[name] = {...this.typeFormatter.getDefinition(child.getType()), title: name}; - } - return definitions; - }, childDefinitions); -diff --git a/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts b/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts -index ba8fd57..806396c 100644 ---- a/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts -+++ b/node_modules/ts-json-schema-generator/src/AnnotationsReader/ExtendedAnnotationsReader.ts -@@ -57,7 +57,7 @@ export class ExtendedAnnotationsReader extends BasicAnnotationsReader { - - const description = markdownDescription.replace(/(?<=[^\n])\n(?=[^\n*-])/g, " ").trim(); - -- return this.markdownDescription ? { description, markdownDescription } : { description }; -+ return this.markdownDescription ? { description: markdownDescription } : { description }; - } - private getTypeAnnotation(node: ts.Node): Annotations | undefined { - const symbol = symbolAtNode(node); -- 2.51.2 From de69977d7eca22738f2f4f41c7487f574d65bd4f Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Mon, 27 Jul 2026 21:10:00 +0000 Subject: [PATCH 23/30] fix cf-json-schema-viz package name type --- docsite/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docsite/package.json b/docsite/package.json index cca38842..7205fb5f 100644 --- a/docsite/package.json +++ b/docsite/package.json @@ -26,7 +26,7 @@ "@mdx-js/react": "^3.0.0", "ajv": "^8.18.0", "ajv-formats": "^3.0.1", - " ": "^0.3.0", + "cf-json-schema-viz": "^0.3.0", "clsx": "^2.0.0", "docusaurus-plugin-sass": "^0.2.6", "docusaurus-theme-github-codeblock": "^2.0.2", -- 2.51.2 From ee2f988357ccc6fcc4d4913e5cb008c935fee918 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 28 Jul 2026 01:10:13 +0000 Subject: [PATCH 24/30] fix(docs): add aio json with reused refs to reduce file size --- src/backend/utils/SchemaDocsStaticUtils.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/backend/utils/SchemaDocsStaticUtils.ts b/src/backend/utils/SchemaDocsStaticUtils.ts index ba479c74..5b039ca4 100644 --- a/src/backend/utils/SchemaDocsStaticUtils.ts +++ b/src/backend/utils/SchemaDocsStaticUtils.ts @@ -4,12 +4,13 @@ import * as z from 'zod'; import { clientConfigSchemaMap } from '../common/infrastructure/config/client/clients.ts'; import { sourceConfigSchemaMap } from '../common/infrastructure/config/source/sources.ts'; import { projectRootDir } from "../common/infrastructure/Atomic.ts"; +import { aioConfigSchema } from "../common/infrastructure/config/aioConfig.ts"; mkdirSync(resolve(projectRootDir, 'docsite/static/schemas'), {recursive: true}); -const generateSchema = (schema: z.ZodType) => z.toJSONSchema(z.array(schema), { +const generateSchema = (schema: z.ZodType, reused: z.core.ToJSONSchemaParams['reused'] = 'inline') => z.toJSONSchema(schema, { io: "input", - reused: "inline", + reused, target: "draft-07", unrepresentable: "any", override: (ctx) => { @@ -41,10 +42,12 @@ const generateSchema = (schema: z.ZodType) => z.toJSONSchema(z.array(schema), { const clientEntries = Object.entries(clientConfigSchemaMap); for(const [k,v] of clientEntries) { - writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${k}-client.json`), JSON.stringify(generateSchema(v))); + writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${k}-client.json`), JSON.stringify(generateSchema(z.array(v)))); } const sourcesEntries = Object.entries(sourceConfigSchemaMap); for(const [k,v] of sourcesEntries) { - writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${k}-source.json`), JSON.stringify(generateSchema(v[0]))); -} \ No newline at end of file + writeFileSync(resolve(projectRootDir, `docsite/static/schemas/${k}-source.json`), JSON.stringify(generateSchema(z.array(v[0])))); +} + +writeFileSync(resolve(projectRootDir, 'docsite/static/schemas/aio.json'), JSON.stringify(generateSchema(aioConfigSchema, 'ref'))); -- 2.51.2 From d764b0204e11c61e90bbdea518f149a179231073 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 28 Jul 2026 01:10:36 +0000 Subject: [PATCH 25/30] fix(docs): Replace old json viewer links --- docsite/docs/configuration/configuration.mdx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docsite/docs/configuration/configuration.mdx b/docsite/docs/configuration/configuration.mdx index 6687ff27..1b711a77 100644 --- a/docsite/docs/configuration/configuration.mdx +++ b/docsite/docs/configuration/configuration.mdx @@ -146,7 +146,7 @@ Check the [**FAQ**](../FAQ.md) if you have any issues after configuration! * There is also a [**kitchensink example**](/configuration/kitchensink) that provides examples of using all sources/clients in a complex configuration. ::: - [**Explore the schema for this configuration, along with an example generator and validator, here**](https://json-schema.app/view/%23?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json) + [**Explore the schema for this configuration, along with an example generator and validator, here**](/playground)
@@ -810,7 +810,7 @@ Webhooks are configured in the AIO [config.json](#configuration-types) file unde #### [Gotify](https://gotify.net/) -Refer to the [config schema for GotifyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FGotifyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json) +Refer to the [config schema for GotifyConfig](/playground) multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings. @@ -836,7 +836,7 @@ multi-scrobbler optionally supports setting message notification priority via `i #### [Ntfy](https://ntfy.sh/) -Refer to the [config schema for NtfyConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FNtfyConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json) +Refer to the [config schema for NtfyConfig](/playground) multi-scrobbler optionally supports setting message notification priority via `info` `warn` and `error` mappings. @@ -864,7 +864,7 @@ multi-scrobbler optionally supports setting message notification priority via `i #### [Apprise](https://github.com/caronc/apprise-api) -Refer to the [config schema for AppriseConfig](https://json-schema.app/view/%23/%23%2Fdefinitions%2FAppriseConfig?url=https%3A%2F%2Fraw.githubusercontent.com%2FFoxxMD%2Fmulti-scrobbler%2Fmaster%2Fsrc%2Fbackend%2Fcommon%2Fschema%2Faio.json) +Refer to the [config schema for AppriseConfig](/playground) multi-scrobbler supports [stateless](https://github.com/caronc/apprise-api?tab=readme-ov-file#stateless-solution) and [persistent storage](https://github.com/caronc/apprise-api?tab=readme-ov-file#persistent-storage-solution) endpoints as well as [tags](https://github.com/caronc/apprise-api?tab=readme-ov-file#tagging)/ -- 2.51.2 From 857f44fa14b1cada110d8af4d5b773ec0153eb17 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 28 Jul 2026 01:11:01 +0000 Subject: [PATCH 26/30] docs: Add meta descriptions/titles for objects that become refs --- src/backend/common/infrastructure/Atomic.ts | 6 +++--- .../infrastructure/config/client/discord.ts | 2 +- .../infrastructure/config/client/koito.ts | 2 +- .../infrastructure/config/client/lastfm.ts | 2 +- .../infrastructure/config/client/librefm.ts | 2 +- .../config/client/listenbrainz.ts | 2 +- .../infrastructure/config/client/maloja.ts | 2 +- .../infrastructure/config/client/rocksky.ts | 2 +- .../infrastructure/config/client/tealfm.ts | 2 +- .../common/infrastructure/config/database.ts | 17 +++++++++-------- .../infrastructure/config/source/applemusic.ts | 2 +- .../infrastructure/config/source/azuracast.ts | 2 +- .../infrastructure/config/source/chromecast.ts | 2 +- .../infrastructure/config/source/deezer.ts | 2 +- .../infrastructure/config/source/endpointlfm.ts | 2 +- .../infrastructure/config/source/endpointlz.ts | 2 +- .../infrastructure/config/source/icecast.ts | 2 +- .../infrastructure/config/source/jellyfin.ts | 2 +- .../infrastructure/config/source/jriver.ts | 2 +- .../common/infrastructure/config/source/kodi.ts | 2 +- .../infrastructure/config/source/koito.ts | 2 +- .../infrastructure/config/source/lastfm.ts | 2 +- .../infrastructure/config/source/librefm.ts | 2 +- .../config/source/listenbrainz.ts | 2 +- .../infrastructure/config/source/maloja.ts | 2 +- .../infrastructure/config/source/mopidy.ts | 2 +- .../common/infrastructure/config/source/mpd.ts | 2 +- .../infrastructure/config/source/mpris.ts | 2 +- .../infrastructure/config/source/musiccast.ts | 2 +- .../infrastructure/config/source/musikcube.ts | 2 +- .../common/infrastructure/config/source/plex.ts | 2 +- .../infrastructure/config/source/rocksky.ts | 2 +- .../infrastructure/config/source/sonos.ts | 2 +- .../infrastructure/config/source/spotify.ts | 2 +- .../infrastructure/config/source/subsonic.ts | 2 +- .../infrastructure/config/source/tealfm.ts | 2 +- .../common/infrastructure/config/source/vlc.ts | 2 +- .../config/source/webscrobbler.ts | 2 +- .../infrastructure/config/source/ymbridge.ts | 2 +- .../infrastructure/config/source/ytmusic.ts | 2 +- src/core/Transform.ts | 2 +- 41 files changed, 51 insertions(+), 50 deletions(-) diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 163b3366..3b4abc41 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -420,7 +420,7 @@ export const REFRESH_STALE_DEFAULT = 60; /** A number of seconds */ export const secondsSchema = z.number().meta({ description: 'number of seconds', - example: [60, 3600], + examples: [60, 3600], title: 'Seconds' }); @@ -428,11 +428,11 @@ export const secondsSchema = z.number().meta({ /** A string representing a number and unit of time comptabile with dayjs */ export const durationStringSchema = z.string().meta({ description: 'a string containing a number and a unit of time compatible with dayjs', - example:["1 hour", "4 days"], + examples:["1 hour", "4 days"], title: 'Duration Value' }) -export const durationValueSchema = z.xor([secondsSchema, durationStringSchema]); +export const durationValueSchema = z.xor([secondsSchema, durationStringSchema]).meta({title: 'Duration Value'}); // .meta({ // description: stripIndents`A duration of time as either diff --git a/src/backend/common/infrastructure/config/client/discord.ts b/src/backend/common/infrastructure/config/client/discord.ts index a743ea7e..5a9e2f22 100644 --- a/src/backend/common/infrastructure/config/client/discord.ts +++ b/src/backend/common/infrastructure/config/client/discord.ts @@ -48,7 +48,7 @@ export type DiscordClientConfig = z.infer; export const discordClientAIOConfigSchema = z.object({ ...discordClientConfigSchema.shape, type: z.literal('discord'), -}); +}).meta({title: 'Discord'}); export type DiscordClientAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/client/koito.ts b/src/backend/common/infrastructure/config/client/koito.ts index 19640e05..41d22aad 100644 --- a/src/backend/common/infrastructure/config/client/koito.ts +++ b/src/backend/common/infrastructure/config/client/koito.ts @@ -103,6 +103,6 @@ export type KoitoClientConfig = z.infer; export const koitoClientAIOConfigSchema = z.object({ ...koitoClientConfigSchema.shape, type: z.literal('koito'), -}); +}).meta({title: 'Koito'}); 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 4fc4112d..a69b910e 100644 --- a/src/backend/common/infrastructure/config/client/lastfm.ts +++ b/src/backend/common/infrastructure/config/client/lastfm.ts @@ -73,6 +73,6 @@ export type LastfmClientConfig = z.infer; export const lastfmClientAIOConfigSchema = z.object({ ...lastfmClientConfigSchema.shape, type: z.literal('lastfm'), -}); +}).meta({title: 'Last.fm'}); 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 efc8243c..7ee3a628 100644 --- a/src/backend/common/infrastructure/config/client/librefm.ts +++ b/src/backend/common/infrastructure/config/client/librefm.ts @@ -90,6 +90,6 @@ export type LibrefmClientConfig = z.infer; export const librefmClientAIOConfigSchema = z.object({ ...librefmClientConfigSchema.shape, type: z.literal('librefm'), -}); +}).meta({title: 'Libre.fm'}); 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 ba3f7fd2..ede8c76f 100644 --- a/src/backend/common/infrastructure/config/client/listenbrainz.ts +++ b/src/backend/common/infrastructure/config/client/listenbrainz.ts @@ -61,7 +61,7 @@ export type ListenBrainzClientConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/client/maloja.ts b/src/backend/common/infrastructure/config/client/maloja.ts index b26aaf7f..138cb67a 100644 --- a/src/backend/common/infrastructure/config/client/maloja.ts +++ b/src/backend/common/infrastructure/config/client/maloja.ts @@ -52,6 +52,6 @@ export type MalojaClientConfig = z.infer; export const malojaClientAIOConfigSchema = z.object({ ...malojaClientConfigSchema.shape, type: z.literal('maloja'), -}); +}).meta({title: '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 fd13bb99..b716a0b7 100644 --- a/src/backend/common/infrastructure/config/client/rocksky.ts +++ b/src/backend/common/infrastructure/config/client/rocksky.ts @@ -102,6 +102,6 @@ export type RockSkyClientConfig = z.infer; export const rockSkyClientAIOConfigSchema = z.object({ ...rockSkyClientConfigSchema.shape, type: z.literal('rocksky'), -}); +}).meta({title: '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 b637663a..da27dcb7 100644 --- a/src/backend/common/infrastructure/config/client/tealfm.ts +++ b/src/backend/common/infrastructure/config/client/tealfm.ts @@ -52,7 +52,7 @@ export type TealClientConfig = z.infer; export const tealClientAIOConfigSchema = z.object({ ...tealClientConfigSchema.shape, type: z.literal('tealfm'), -}); +}).meta({title: 'teal.fm'}); export type TealClientAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/database.ts b/src/backend/common/infrastructure/config/database.ts index 5367d480..c48c0ce5 100644 --- a/src/backend/common/infrastructure/config/database.ts +++ b/src/backend/common/infrastructure/config/database.ts @@ -38,7 +38,8 @@ export const rententionGranularDurationValueSchema = z.object({ completed: durationValueSchema.optional(), duped: durationValueSchema.optional() }).meta({ - description: 'Individual duration policies based on the state of the Play' + description: 'Individual duration policies based on the state of the Play', + title: 'Duration Value per Play Type' }); export type RententionGranularDurationValue = z.infer; @@ -115,19 +116,19 @@ export const retentionConfigDurationValueSchema = z.object({ deleteAfter: retentionConfigValueDurationValueSchema.optional().meta({description: 'Delete Plays using these retention policies'}), compactAfter: retentionConfigValueDurationValueSchema.optional().meta({description: 'Compact Plays using these retention policies'}), compact: z.array(compactablePropertySchema).optional().meta({ description: 'The type of data to compact on the Play'}) -}); +}).meta({description: 'Retention policy for Plays stored in the database'}); export type RetentionConfigDurationValue = z.infer; export const retentionConfigDurationSchema = z.object({ - deleteAfter: retentionConfigValueDurationSchema.optional().meta({description: 'test'}), - compactAfter: retentionConfigValueDurationSchema.optional().meta({description: 'test'}), + deleteAfter: retentionConfigValueDurationSchema.optional(), + compactAfter: retentionConfigValueDurationSchema.optional(), compact: z.array(compactablePropertySchema).optional(), }); export type RetentionConfigDuration = z.infer; export const retentionConfigRetentionValueSchema = z.object({ - deleteAfter: retentionConfigValueRetentionValueSchema.optional().meta({description: 'test'}), - compactAfter: retentionConfigValueRetentionValueSchema.optional().meta({description: 'test'}), + deleteAfter: retentionConfigValueRetentionValueSchema.optional(), + compactAfter: retentionConfigValueRetentionValueSchema.optional(), compact: z.array(compactablePropertySchema).optional(), }); export type RetentionConfigRetentionValue = z.infer; @@ -140,8 +141,8 @@ export const retentionConfigSchema = z.union([ export type RetentionConfig = z.infer; export const retentionOptionsSchema = z.object({ - deleteAfter: retentionOptionDurationSchema.meta({description: 'test'}), - compactAfter: retentionOptionRetentionValueSchema.meta({description: 'test'}), + deleteAfter: retentionOptionDurationSchema, + compactAfter: retentionOptionRetentionValueSchema, compact: z.array(compactablePropertySchema), }); diff --git a/src/backend/common/infrastructure/config/source/applemusic.ts b/src/backend/common/infrastructure/config/source/applemusic.ts index 6a406db3..b00ddf6d 100644 --- a/src/backend/common/infrastructure/config/source/applemusic.ts +++ b/src/backend/common/infrastructure/config/source/applemusic.ts @@ -78,6 +78,6 @@ export type AppleMusicSourceConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/azuracast.ts b/src/backend/common/infrastructure/config/source/azuracast.ts index 3b1a6965..a0f49baf 100644 --- a/src/backend/common/infrastructure/config/source/azuracast.ts +++ b/src/backend/common/infrastructure/config/source/azuracast.ts @@ -139,6 +139,6 @@ export type AzuracastSourceConfig = z.infer; export const azuracastSourceAIOConfigSchema = z.object({ ...azuracastSourceConfigSchema.shape, type: z.literal('azuracast') -}); +}).meta({title: "Azuracast"}); export type AzuracastSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/chromecast.ts b/src/backend/common/infrastructure/config/source/chromecast.ts index 3c382ca4..415c352b 100644 --- a/src/backend/common/infrastructure/config/source/chromecast.ts +++ b/src/backend/common/infrastructure/config/source/chromecast.ts @@ -142,6 +142,6 @@ export type ChromecastSourceConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/deezer.ts b/src/backend/common/infrastructure/config/source/deezer.ts index 250d293d..6a509319 100644 --- a/src/backend/common/infrastructure/config/source/deezer.ts +++ b/src/backend/common/infrastructure/config/source/deezer.ts @@ -97,7 +97,7 @@ export type DeezerInternalSourceConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/endpointlfm.ts b/src/backend/common/infrastructure/config/source/endpointlfm.ts index 102d5c4f..bfe6bf47 100644 --- a/src/backend/common/infrastructure/config/source/endpointlfm.ts +++ b/src/backend/common/infrastructure/config/source/endpointlfm.ts @@ -32,6 +32,6 @@ export type LastFMEndpointSourceConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/endpointlz.ts b/src/backend/common/infrastructure/config/source/endpointlz.ts index 443e4bc3..bff08abb 100644 --- a/src/backend/common/infrastructure/config/source/endpointlz.ts +++ b/src/backend/common/infrastructure/config/source/endpointlz.ts @@ -52,6 +52,6 @@ export type ListenbrainzEndpointSourceConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/icecast.ts b/src/backend/common/infrastructure/config/source/icecast.ts index c752b6e5..39714fd7 100644 --- a/src/backend/common/infrastructure/config/source/icecast.ts +++ b/src/backend/common/infrastructure/config/source/icecast.ts @@ -62,6 +62,6 @@ export type IcecastSourceConfig = z.infer; export const icecastSourceAIOConfigSchema = z.object({ ...icecastSourceConfigSchema.shape, type: z.literal('icecast'), -}); +}).meta({title: 'Icecast'}); export type IcecastSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/jellyfin.ts b/src/backend/common/infrastructure/config/source/jellyfin.ts index 621e881f..8a5cab7f 100644 --- a/src/backend/common/infrastructure/config/source/jellyfin.ts +++ b/src/backend/common/infrastructure/config/source/jellyfin.ts @@ -142,6 +142,6 @@ export type JellyApiSourceConfig = z.infer; export const jellyApiSourceAIOConfigSchema = z.object({ ...jellyApiSourceConfigSchema.shape, type: z.literal('jellyfin'), -}); +}).meta({title: 'Jellyfin'}); export type JellyApiSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/jriver.ts b/src/backend/common/infrastructure/config/source/jriver.ts index efbbcf45..481f0d7d 100644 --- a/src/backend/common/infrastructure/config/source/jriver.ts +++ b/src/backend/common/infrastructure/config/source/jriver.ts @@ -57,6 +57,6 @@ export type JRiverSourceConfig = z.infer; export const jRiverSourceAIOConfigSchema = z.object({ ...jRiverSourceConfigSchema.shape, type: z.literal('jriver'), -}); +}).meta({title: 'JRiver'}); export type JRiverSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/kodi.ts b/src/backend/common/infrastructure/config/source/kodi.ts index 3efa1b5f..d501f427 100644 --- a/src/backend/common/infrastructure/config/source/kodi.ts +++ b/src/backend/common/infrastructure/config/source/kodi.ts @@ -56,6 +56,6 @@ export type KodiSourceConfig = z.infer; export const kodiSourceAIOConfigSchema = z.object({ ...kodiSourceConfigSchema.shape, type: z.literal('kodi'), -}); +}).meta({title: 'Kodi'}); export type KodiSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/koito.ts b/src/backend/common/infrastructure/config/source/koito.ts index c8bb1a73..17ab6d1b 100644 --- a/src/backend/common/infrastructure/config/source/koito.ts +++ b/src/backend/common/infrastructure/config/source/koito.ts @@ -32,6 +32,6 @@ export type KoitoSourceConfig = z.infer; export const koitoSourceAIOConfigSchema = z.object({ ...koitoSourceConfigSchema.shape, type: z.literal('koito'), -}); +}).meta({title: "Koito"}); export type KoitoSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/lastfm.ts b/src/backend/common/infrastructure/config/source/lastfm.ts index 5649d493..1782728d 100644 --- a/src/backend/common/infrastructure/config/source/lastfm.ts +++ b/src/backend/common/infrastructure/config/source/lastfm.ts @@ -32,6 +32,6 @@ export type LastfmSourceConfig = z.infer; export const lastFmSouceAIOConfigSchema = z.object({ ...lastfmSourceConfigSchema.shape, type: z.literal('lastfm'), -}); +}).meta({title: "Last.fm"}); export type LastFmSouceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/librefm.ts b/src/backend/common/infrastructure/config/source/librefm.ts index 141fe519..bb7f5682 100644 --- a/src/backend/common/infrastructure/config/source/librefm.ts +++ b/src/backend/common/infrastructure/config/source/librefm.ts @@ -32,6 +32,6 @@ export type LibrefmSourceConfig = z.infer; export const librefmSouceAIOConfigSchema = z.object({ ...librefmSourceConfigSchema.shape, type: z.literal('librefm'), -}); +}).meta({title: "Libre.fm"}); export type LibrefmSouceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/listenbrainz.ts b/src/backend/common/infrastructure/config/source/listenbrainz.ts index cf84e35f..45931b45 100644 --- a/src/backend/common/infrastructure/config/source/listenbrainz.ts +++ b/src/backend/common/infrastructure/config/source/listenbrainz.ts @@ -32,6 +32,6 @@ export type ListenBrainzSourceConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/maloja.ts b/src/backend/common/infrastructure/config/source/maloja.ts index 4ff6ffb2..dac951fa 100644 --- a/src/backend/common/infrastructure/config/source/maloja.ts +++ b/src/backend/common/infrastructure/config/source/maloja.ts @@ -32,6 +32,6 @@ export type MalojaSourceConfig = z.infer; export const malojaSourceAIOConfigSchema = z.object({ ...malojaSourceConfigSchema.shape, type: z.literal('maloja'), -}); +}).meta({title: 'Maloja'}); export type MalojaSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/mopidy.ts b/src/backend/common/infrastructure/config/source/mopidy.ts index d5aed420..e7def4fb 100644 --- a/src/backend/common/infrastructure/config/source/mopidy.ts +++ b/src/backend/common/infrastructure/config/source/mopidy.ts @@ -103,6 +103,6 @@ export type MopidySourceConfig = z.infer; export const mopidySourceAIOConfigSchema = z.object({ ...mopidySourceConfigSchema.shape, type: z.literal('mopidy'), -}); +}).meta({title: 'Mopidy'}); export type MopidySourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/mpd.ts b/src/backend/common/infrastructure/config/source/mpd.ts index a979188c..345a6f5a 100644 --- a/src/backend/common/infrastructure/config/source/mpd.ts +++ b/src/backend/common/infrastructure/config/source/mpd.ts @@ -56,7 +56,7 @@ export type MPDSourceConfig = z.infer; export const mpdSourceAIOConfigSchema = z.object({ ...mpdSourceConfigSchema.shape, type: z.literal('mpd'), -}); +}).meta({title: 'MPD'}); export type MPDSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/mpris.ts b/src/backend/common/infrastructure/config/source/mpris.ts index 1f59c7b7..761ff327 100644 --- a/src/backend/common/infrastructure/config/source/mpris.ts +++ b/src/backend/common/infrastructure/config/source/mpris.ts @@ -66,6 +66,6 @@ export type MPRISSourceConfig = z.infer; export const mprisSourceAIOConfigSchema = z.object({ ...mprisSourceConfigSchema.shape, type: z.literal('mpris'), -}); +}).meta({title: 'MPRIS'}); export type MPRISSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/musiccast.ts b/src/backend/common/infrastructure/config/source/musiccast.ts index 4ffc6ff8..c94b4863 100644 --- a/src/backend/common/infrastructure/config/source/musiccast.ts +++ b/src/backend/common/infrastructure/config/source/musiccast.ts @@ -96,6 +96,6 @@ export type MusicCastSourceConfig = z.infer; export const musicCastSourceAIOConfigSchema = z.object({ ...musicCastSourceConfigSchema.shape, type: z.literal('musiccast'), -}); +}).meta({title: 'Musiccast'}); export type MusicCastSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/musikcube.ts b/src/backend/common/infrastructure/config/source/musikcube.ts index 0e1a9afd..baa807e8 100644 --- a/src/backend/common/infrastructure/config/source/musikcube.ts +++ b/src/backend/common/infrastructure/config/source/musikcube.ts @@ -127,6 +127,6 @@ export type MusikcubeSourceConfig = z.infer; export const musikcubeSourceAIOConfigSchema = z.object({ ...musikcubeSourceConfigSchema.shape, type: z.literal('musikcube'), -}); +}).meta({title: 'Musikcube'}); export type MusikcubeSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/plex.ts b/src/backend/common/infrastructure/config/source/plex.ts index d27f1d4e..9ca97857 100644 --- a/src/backend/common/infrastructure/config/source/plex.ts +++ b/src/backend/common/infrastructure/config/source/plex.ts @@ -87,6 +87,6 @@ export type PlexApiSourceConfig = z.infer; export const plexApiSourceAIOConfigSchema = z.object({ ...plexApiSourceConfigSchema.shape, type: z.literal('plex'), -}); +}).meta({title: 'Plex'}); export type PlexApiSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/rocksky.ts b/src/backend/common/infrastructure/config/source/rocksky.ts index 4b84a64f..8468b110 100644 --- a/src/backend/common/infrastructure/config/source/rocksky.ts +++ b/src/backend/common/infrastructure/config/source/rocksky.ts @@ -40,6 +40,6 @@ export type RockskySourceConfig = z.infer; export const rockskySourceAIOConfigSchema = z.object({ ...rockskySourceConfigSchema.shape, type: z.literal('rocksky'), -}); +}).meta({title: 'Rocksky'}); export type RockskySourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/sonos.ts b/src/backend/common/infrastructure/config/source/sonos.ts index 8b5ecba2..555b48e0 100644 --- a/src/backend/common/infrastructure/config/source/sonos.ts +++ b/src/backend/common/infrastructure/config/source/sonos.ts @@ -62,6 +62,6 @@ export type SonosSourceConfig = z.infer; export const sonosSourceAIOConfigSchema = z.object({ ...sonosSourceConfigSchema.shape, type: z.literal('sonos'), -}); +}).meta({title: 'Sonos'}); export type SonosSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/spotify.ts b/src/backend/common/infrastructure/config/source/spotify.ts index cb7381af..f8c729ec 100644 --- a/src/backend/common/infrastructure/config/source/spotify.ts +++ b/src/backend/common/infrastructure/config/source/spotify.ts @@ -68,6 +68,6 @@ export type SpotifySourceConfig = z.infer; export const spotifySourceAIOConfigSchema = z.object({ ...spotifySourceConfigSchema.shape, type: z.literal('spotify'), -}); +}).meta({title: 'Spotify'}); export type SpotifySourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/subsonic.ts b/src/backend/common/infrastructure/config/source/subsonic.ts index f2c730a5..d676bfe7 100644 --- a/src/backend/common/infrastructure/config/source/subsonic.ts +++ b/src/backend/common/infrastructure/config/source/subsonic.ts @@ -103,6 +103,6 @@ export type SubSonicSourceConfig = z.infer; export const subsonicSourceAIOConfigSchema = z.object({ ...subSonicSourceConfigSchema.shape, type: z.literal('subsonic'), -}); +}).meta({title: 'Subsonic'}); export type SubsonicSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/tealfm.ts b/src/backend/common/infrastructure/config/source/tealfm.ts index 75470aa4..6e888da3 100644 --- a/src/backend/common/infrastructure/config/source/tealfm.ts +++ b/src/backend/common/infrastructure/config/source/tealfm.ts @@ -42,6 +42,6 @@ export type TealSourceConfig = z.infer; export const tealSourceAIOConfigSchema = z.object({ ...tealSourceConfigSchema.shape, type: z.literal('tealfm'), -}); +}).meta({title: 'teal.fm'}); export type TealSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/vlc.ts b/src/backend/common/infrastructure/config/source/vlc.ts index 10a4d992..42ab419d 100644 --- a/src/backend/common/infrastructure/config/source/vlc.ts +++ b/src/backend/common/infrastructure/config/source/vlc.ts @@ -75,7 +75,7 @@ export type VLCSourceConfig = z.infer; export const vlcSourceAIOConfigSchema = z.object({ ...vlcSourceConfigSchema.shape, type: z.literal('vlc'), -}); +}).meta({title: 'VLC'}); export type VLCSourceAIOConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/webscrobbler.ts b/src/backend/common/infrastructure/config/source/webscrobbler.ts index 1b534c14..d37eca78 100644 --- a/src/backend/common/infrastructure/config/source/webscrobbler.ts +++ b/src/backend/common/infrastructure/config/source/webscrobbler.ts @@ -56,6 +56,6 @@ export type WebScrobblerSourceConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/ymbridge.ts b/src/backend/common/infrastructure/config/source/ymbridge.ts index d4adc7e6..26cb4d22 100644 --- a/src/backend/common/infrastructure/config/source/ymbridge.ts +++ b/src/backend/common/infrastructure/config/source/ymbridge.ts @@ -27,6 +27,6 @@ export type YandexMusicBridgeSourceConfig = z.infer; diff --git a/src/backend/common/infrastructure/config/source/ytmusic.ts b/src/backend/common/infrastructure/config/source/ytmusic.ts index d5339e38..45638532 100644 --- a/src/backend/common/infrastructure/config/source/ytmusic.ts +++ b/src/backend/common/infrastructure/config/source/ytmusic.ts @@ -129,6 +129,6 @@ export type YTMusicSourceConfig = z.infer; export const ytMusicSourceAIOConfigSchema = z.object({ ...ytMusicSourceConfigSchema.shape, type: z.literal('ytmusic'), -}); +}).meta({title: 'Youtube Music'}); export type YTMusicSourceAIOConfig = z.infer; diff --git a/src/core/Transform.ts b/src/core/Transform.ts index 0bd9592b..cee1dc68 100644 --- a/src/core/Transform.ts +++ b/src/core/Transform.ts @@ -354,7 +354,7 @@ export type PlayTransformConfig = z.infer; export const playTransformOptionsSchema = z.object({ ...playTransformConfigSchema.shape, log: z.union([z.boolean(), z.literal('all')]).optional(), -}).meta({title: 'Transform Options'}); +}).meta({title: 'Transform Options', description: 'Enhance/correct Play data by applying a transform pipeline'}); export type PlayTransformOptions = z.infer; -- 2.51.2 From 6f7aa6f567b651dacc81be57e75b08b93712d1c4 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 28 Jul 2026 01:20:44 +0000 Subject: [PATCH 27/30] remove unused interface names --- .../infrastructure/config/client/clients.ts | 54 ------------------- .../infrastructure/config/source/sources.ts | 40 +------------- 2 files changed, 1 insertion(+), 93 deletions(-) diff --git a/src/backend/common/infrastructure/config/client/clients.ts b/src/backend/common/infrastructure/config/client/clients.ts index e7e370e7..aff9f492 100644 --- a/src/backend/common/infrastructure/config/client/clients.ts +++ b/src/backend/common/infrastructure/config/client/clients.ts @@ -36,60 +36,6 @@ export const clientAIOConfigSchema = z.union([ export type ClientAIOConfig = z.infer; -/** Used for docusaurus schemas - * We need to show "array of" for each type of config when looking at File Config - * - * This is defined in the AIO config and we *assume* arrays in individual files when parsing in builders - * But we don't have any actual definitions for this that we can pull for generating individual schema files - */ -export const malojaClientConfigsSchema = z.array(malojaClientConfigSchema); - -export type MalojaClientConfigs = z.infer; - -export const lastfmClientConfigsSchema = z.array(lastfmClientConfigSchema); - -export type LastfmClientConfigs = z.infer; - -export const librefmClientConfigsSchema = z.array(librefmClientConfigSchema); - -export type LibrefmClientConfigs = z.infer; - -export const listenBrainzClientConfigsSchema = z.array(listenBrainzClientConfigSchema); - -export type ListenBrainzClientConfigs = z.infer; - -export const koitoClientConfigsSchema = z.array(koitoClientConfigSchema); - -export type KoitoClientConfigs = z.infer; - -export const tealClientConfigsSchema = z.array(tealClientConfigSchema); - -export type TealClientConfigs = z.infer; - -export const rockSkyClientConfigsSchema = z.array(rockSkyClientConfigSchema); - -export type RockSkyClientConfigs = z.infer; - -export const discordClientConfigsSchema = z.array(discordClientConfigSchema); - -export type DiscordClientConfigs = z.infer; - -export const atomicClientInterfaces = [ - 'MalojaClientConfig', - 'LastfmClientConfig', - 'LibrefmClientConfig', - 'ListenBrainzClientConfig', - 'KoitoClientConfig', - 'TealClientConfig', - 'RockSkyClientConfig', - 'DiscordClientConfig' -]; - -export const clientInterfaces = [ - 'AIOClientRelaxedConfig', - ...atomicClientInterfaces -]; - export interface ClientTypeConfigMap extends Record { maloja: MalojaClientConfig, lastfm: LastfmClientConfig, diff --git a/src/backend/common/infrastructure/config/source/sources.ts b/src/backend/common/infrastructure/config/source/sources.ts index 3d7f7655..aa0df628 100644 --- a/src/backend/common/infrastructure/config/source/sources.ts +++ b/src/backend/common/infrastructure/config/source/sources.ts @@ -102,39 +102,6 @@ export const sourceAIOConfigSchema = z.union([ export type SourceAIOConfig = z.infer; -export const atomicSourceInterfaces = [ - 'SpotifySourceConfig', - 'PlexApiSourceConfig', - 'DeezerCompatConfig', - 'ListenbrainzEndpointSourceConfig', - 'LastFMEndpointSourceConfig', - 'IcecastSourceConfig', - 'SubSonicSourceConfig', - 'JellyApiSourceConfig', - 'LastfmSourceConfig', - 'LibrefmSourceConfig', - 'YTMusicSourceConfig', - 'YandexMusicBridgeSourceConfig', - 'MalojaSourceConfig', - 'MPRISSourceConfig', - 'MopidySourceConfig', - 'ListenBrainzSourceConfig', - 'JRiverSourceConfig', - 'KodiSourceConfig', - 'ChromecastSourceConfig', - 'WebScrobblerSourceConfig', - 'MusikcubeSourceConfig', - 'MusicCastSourceConfig', - 'MPDSourceConfig', - 'VLCSourceConfig', - 'AzuracastSourceConfig', - 'KoitoSourceConfig', - 'TealSourceConfig', - 'RockskySourceConfig', - 'SonosSourceConfig', - 'AppleMusicSourceConfig' -]; - export interface SourceTypeConfigMap extends Record { spotify: [SpotifySourceConfig, SpotifySourceAIOConfig], plex: [PlexApiSourceConfig, PlexApiSourceAIOConfig], @@ -202,9 +169,4 @@ export const sourceConfigSchemaMap: { [K in keyof SourceTypeConfigMap]: [z.ZodTy }; export const validateSourceJson = (sourceType: T, json: object): SourceTypeConfigMap[T][0] => sourceConfigSchemaMap[sourceType][0].parse(json); -export const validateSourceAIOJson = (sourceType: T, json: object): SourceTypeConfigMap[T][1] => sourceConfigSchemaMap[sourceType][1].parse(json); - -export const sourceInterfaces = [ - 'AIOSourceRelaxedConfig', - ...atomicSourceInterfaces -]; +export const validateSourceAIOJson = (sourceType: T, json: object): SourceTypeConfigMap[T][1] => sourceConfigSchemaMap[sourceType][1].parse(json); \ No newline at end of file -- 2.51.2 From 2887600aa06533c227ea39bf7bdc3af01ae300a8 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 28 Jul 2026 01:40:22 +0000 Subject: [PATCH 28/30] docs: Clarify language around id/name --- src/backend/common/infrastructure/config/client/index.ts | 6 +++--- src/backend/common/infrastructure/config/common.ts | 7 ++++--- src/backend/common/infrastructure/config/source/index.ts | 5 +++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/backend/common/infrastructure/config/client/index.ts b/src/backend/common/infrastructure/config/client/index.ts index 85ee1a5d..4e1a1d32 100644 --- a/src/backend/common/infrastructure/config/client/index.ts +++ b/src/backend/common/infrastructure/config/client/index.ts @@ -171,13 +171,13 @@ 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. + * Vanity name for this client * * @examples ["MyConfig"] * */ name: z.string().meta({ - description: "Unique identifier for this client.", - examples: ["MyConfig"] + description: "Vanity name for this client.", + examples: ["Foxx's Cool Client"] }), /** * Specific data required to configure this client diff --git a/src/backend/common/infrastructure/config/common.ts b/src/backend/common/infrastructure/config/common.ts index 86ff3825..3f3ed479 100644 --- a/src/backend/common/infrastructure/config/common.ts +++ b/src/backend/common/infrastructure/config/common.ts @@ -22,17 +22,18 @@ export const commonConfigSchema = z.object({ * If no id is given the name of this component will be used. */ id: z.string().optional().meta({ - description: "A UNIQUE identifier for this Source/Client" + description: "A UNIQUE identifier for this Source/Client", + examples: ["fooGlobalA"] }), data: commonDataSchema.optional(), /** - * Should MS use this client/source? Defaults to true + * Should MS parse this config and create a client/source from it? * * @default true * @examples [true] * */ enable: z.boolean().optional().meta({ - description: "Should MS use this client/source?", + description: "Should MS parse this config and create a client/source from it?", default: true, examples: [true] }) diff --git a/src/backend/common/infrastructure/config/source/index.ts b/src/backend/common/infrastructure/config/source/index.ts index 77695a5b..848390be 100644 --- a/src/backend/common/infrastructure/config/source/index.ts +++ b/src/backend/common/infrastructure/config/source/index.ts @@ -194,10 +194,11 @@ export type CommonSourceData = {}; export const commonSourceConfigSchema = z.object({ ...commonConfigSchema.shape, /** - * Unique identifier for this source. + * Vanity anme for this source. * */ name: z.string().optional().meta({ - description: "Unique identifier for this source." + description: "Vanity Name for this source.", + examples: ["Foxx's Cool 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. -- 2.51.2 From d96073282cbae9cd4498610be5106a96ab962c7e Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 28 Jul 2026 01:41:41 +0000 Subject: [PATCH 29/30] feat(cache): Remove deprecated cache configuration Remove metadata/scrobble/auth config that was deprecated in 0.14.0 --- src/backend/common/Cache.ts | 34 +++---------------- src/backend/common/infrastructure/Atomic.ts | 22 ++---------- .../common/infrastructure/config/aioConfig.ts | 2 +- 3 files changed, 9 insertions(+), 49 deletions(-) diff --git a/src/backend/common/Cache.ts b/src/backend/common/Cache.ts index f696573a..b15acb5c 100644 --- a/src/backend/common/Cache.ts +++ b/src/backend/common/Cache.ts @@ -14,7 +14,7 @@ import { getConfigDir } from './index.ts'; import path from 'path'; import { cacheFunctions } from "@foxxmd/regex-buddy-core"; import { fileOrDirectoryIsWriteable } from '../utils/FSUtils.ts'; -import { asCacheConfig, type CacheAuthProvider, type CacheConfig, type CacheConfigOptions, type CacheConfigUser, type CacheScrobbleProvider } from './infrastructure/Atomic.ts'; +import { asCacheConfig, type CacheAuthProvider, type CacheConfig, type CacheConfigOptions, type CacheConfigUser } from './infrastructure/Atomic.ts'; import { Typeson } from 'typeson'; import { builtin } from 'typeson-registry'; import { loggerNoop } from './MaybeLogger.ts'; @@ -89,7 +89,6 @@ export class MSCache { connection: aConn, //...restAuth } = {}, - regex = 200, } = config; this.config = { @@ -104,11 +103,10 @@ export class MSCache { auth: { provider: aProvider, connection: aConn, - }, - regex + } }; - this.regexCache = cacheFunctions(this.config.regex); + this.regexCache = cacheFunctions(200); // for testing we default to in memory const inMemory = new Cacheable({primary: initMemoryCache({lruSize: 500, ttl: '1m'})}); @@ -536,27 +534,11 @@ export const parseUserConfig = (config: CacheConfigUser = {}, parentLogger: Logg // connection: mConn = process.env.CACHE_METADATA_CONN, // //...restMetadata // } = {}, - scrobble: { - provider: sProvider = (process.env.CACHE_SCROBBLE as (CacheScrobbleProvider | undefined) ?? 'file'), - connection = (process.env.CACHE_SCROBBLE_CONN ?? getConfigDir()), - ...restScrobble - } = {}, auth: { provider: aProvider = (process.env.CACHE_AUTH as (CacheAuthProvider | undefined) ?? 'file'), //...restAuth - } = {}, - regex = 200, + } = {} } = config; - - if(config.metadata !== undefined) { - logger.warn('Configuring cache.metadata is no longer supported. Refer to the Caching docs.'); - } - if(config.scrobble !== undefined) { - logger.warn('Configuring cache.scrobble is no longer supported. Refer to the Caching docs.'); - } - if(config.auth?.connection !== undefined) { - logger.warn('Configuring cache.auth.connection is no longer supported. Refer to the Caching docs.'); - } let authConn: string, authProvider = aProvider; @@ -581,15 +563,9 @@ export const parseUserConfig = (config: CacheConfigUser = {}, parentLogger: Logg provider: valkey !== undefined ? 'valkey' : false, connection: valkey, }, - scrobble: { - provider: sProvider, - connection, - ...restScrobble - }, auth: { provider: authProvider, connection: authConn, - }, - regex + } }; } \ No newline at end of file diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index 3b4abc41..ad1ef000 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -12,7 +12,6 @@ import type { ClientType } from "../../../core/Atomic.ts"; import assert from 'assert'; import * as path from 'path'; import * as z from 'zod'; -import { stripIndents } from 'common-tags'; export const __filename = import.meta.filename; export const projectRootDir = path.resolve(__filename, '../../../../../'); @@ -272,31 +271,16 @@ export interface CacheConfigOptions { metadata?: CacheMetadataConfig; scrobble?: CacheScrobbleConfig; auth?: CacheAuthConfig; - /** Number of regex functions to cache (LRU) - * - * @default 200 - */ - regex?: number } export const cacheConfigUserAuthSchema = z.object({ provider: z.union([z.literal('valkey'), z.literal('file')]), -}).catchall(z.any()); +}).meta({description: 'The cache type to use for Auth'}); -// The top-level (and `auth`) catchalls allow deprecated `scrobble`/`metadata` config keys to still be read off -// this type at runtime (see Cache.ts#parseUserConfig) without having them show up in schema docs. export const cacheConfigUserSchema = z.object({ auth: cacheConfigUserAuthSchema.optional(), - valkey: z.string().optional(), - /** Number of regex functions to cache (LRU) - * - * @default 200 - */ - regex: z.number().optional().meta({ - description: "Number of regex functions to cache (LRU)", - default: 200 - }), -}).catchall(z.any()); + valkey: z.string().optional() +}).meta({title: 'Cache Config', description: 'Configuration for Caching'}); export type CacheConfigUser = z.infer; diff --git a/src/backend/common/infrastructure/config/aioConfig.ts b/src/backend/common/infrastructure/config/aioConfig.ts index faade95b..e013b8d1 100644 --- a/src/backend/common/infrastructure/config/aioConfig.ts +++ b/src/backend/common/infrastructure/config/aioConfig.ts @@ -111,7 +111,7 @@ export const aioConfigSchema = z.object({ examples: [false] }), - cache: cacheConfigUserSchema.optional(), + cache: cacheConfigUserSchema.optional().meta({description: 'Configuration for Caching'}), transformers: z.array(transformerCommonConfigSchema).optional(), -- 2.51.2 From 70e4239e526f072c21c8b8b677493f34dde676c0 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Tue, 28 Jul 2026 01:46:26 +0000 Subject: [PATCH 30/30] more cache config cleanup --- src/backend/common/Cache.ts | 29 +-------------------- src/backend/common/infrastructure/Atomic.ts | 4 +-- 2 files changed, 3 insertions(+), 30 deletions(-) diff --git a/src/backend/common/Cache.ts b/src/backend/common/Cache.ts index b15acb5c..1d80e73e 100644 --- a/src/backend/common/Cache.ts +++ b/src/backend/common/Cache.ts @@ -40,14 +40,6 @@ typeson.register({ ] }); -const unsupportedEnvKeys = [ - 'CACHE_AUTH_CONN', - 'CACHE_METADATA', - 'CACHE_SCROBBLE', - 'CACHE_SCROBBLE_CONN', - 'CACHE_AUTH_CONN' -]; - export class MSCache { config: Required @@ -513,30 +505,11 @@ const noopKeyv: KeyvStoreAdapter = { export const parseUserConfig = (config: CacheConfigUser = {}, parentLogger: Logger = loggerNoop): CacheConfigOptions => { const logger = childLogger(parentLogger, 'Cache'); - let valkeyEnvVal: string | undefined = nonEmptyStringOrDefault(process.env.CACHE_VALKEY); - if(valkeyEnvVal === undefined) { - valkeyEnvVal = nonEmptyStringOrDefault(process.env.CACHE_METADATA_CONN); - if(valkeyEnvVal !== undefined) { - logger.warn('ENV CACHE_METADATA_CONN is deprecated! Replace it with CACHE_VALKEY'); - } - } - - for(const key of unsupportedEnvKeys) { - if(nonEmptyStringOrDefault(process.env[key]) !== undefined) { - logger.warn(`ENV ${key} is no longer supported. Refer to the Caching docs.`); - } - } - + const valkeyEnvVal: string | undefined = nonEmptyStringOrDefault(process.env.CACHE_VALKEY); const { valkey = valkeyEnvVal, - // metadata: { - // provider: mProvider = (process.env.CACHE_METADATA as (CacheMetadataProvider | undefined) ?? false), - // connection: mConn = process.env.CACHE_METADATA_CONN, - // //...restMetadata - // } = {}, auth: { provider: aProvider = (process.env.CACHE_AUTH as (CacheAuthProvider | undefined) ?? 'file'), - //...restAuth } = {} } = config; diff --git a/src/backend/common/infrastructure/Atomic.ts b/src/backend/common/infrastructure/Atomic.ts index ad1ef000..5f4aa35c 100644 --- a/src/backend/common/infrastructure/Atomic.ts +++ b/src/backend/common/infrastructure/Atomic.ts @@ -274,12 +274,12 @@ export interface CacheConfigOptions { } export const cacheConfigUserAuthSchema = z.object({ - provider: z.union([z.literal('valkey'), z.literal('file')]), + provider: z.enum(['valkey', 'file']), }).meta({description: 'The cache type to use for Auth'}); export const cacheConfigUserSchema = z.object({ auth: cacheConfigUserAuthSchema.optional(), - valkey: z.string().optional() + valkey: z.string().optional().meta({description: 'The connection string to a valkey server with the syntax `redis://HOST_IP:HOST_PORT`'}) }).meta({title: 'Cache Config', description: 'Configuration for Caching'}); export type CacheConfigUser = z.infer; -- 2.51.2