diff --git a/package-lock.json b/package-lock.json index dfaa6ee6..d91f9951 100644 --- a/package-lock.json +++ b/package-lock.json @@ -84,6 +84,7 @@ "node-object-hash": "^3.1.1", "normalize-url": "^8.0.1", "ntfy": "^1.15.2", + "openapi-fetch": "^0.17.0", "p-event": "^4.2.0", "p-map": "^7.0.4", "p-retry": "^7.1.1", @@ -95,6 +96,7 @@ "rate-limiter-flexible": "^11.2.0", "react-use-timeout": "^1.0.0", "serialize-error": "^13.0.1", + "sharp": "^0.35.5", "spotify-web-api-node": "^5.0.2", "sqlite-up": "^0.5.0", "superagent": "^8.0.9", @@ -2607,6 +2609,564 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz", + "integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz", + "integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz", + "integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz", + "integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz", + "integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz", + "integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz", + "integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz", + "integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz", + "integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz", + "integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz", + "integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz", + "integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz", + "integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz", + "integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz", + "integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz", + "integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz", + "integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz", + "integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz", + "integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz", + "integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz", + "integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz", + "integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-wasm32/node_modules/@emnapi/runtime": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz", + "integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz", + "integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz", + "integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz", + "integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@inquirer/ansi": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-2.0.7.tgz", @@ -9765,7 +10325,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -15954,6 +16513,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-fetch": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.17.0.tgz", + "integrity": "sha512-PsbZR1wAPcG91eEthKhN+Zn92FMHxv+/faECIwjXdxfTODGSGegYv0sc1Olz+HYPvKOuoXfp+0pA2XVt2cI0Ig==", + "license": "MIT", + "dependencies": { + "openapi-typescript-helpers": "^0.1.0" + } + }, + "node_modules/openapi-typescript-helpers": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/openapi-typescript-helpers/-/openapi-typescript-helpers-0.1.0.tgz", + "integrity": "sha512-OKTGPthhivLw/fHz6c3OPtg72vi86qaMlqbJuVJ23qOvQ+53uw1n7HdmkJFibloF7QEjDrDkzJiOJuockM/ljw==", + "license": "MIT" + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -17802,6 +18376,55 @@ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", "license": "ISC" }, + "node_modules/sharp": { + "version": "0.35.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz", + "integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==", + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.5" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.5", + "@img/sharp-darwin-x64": "0.35.5", + "@img/sharp-freebsd-wasm32": "0.35.5", + "@img/sharp-libvips-darwin-arm64": "1.3.4", + "@img/sharp-libvips-darwin-x64": "1.3.4", + "@img/sharp-libvips-linux-arm": "1.3.4", + "@img/sharp-libvips-linux-arm64": "1.3.4", + "@img/sharp-libvips-linux-ppc64": "1.3.4", + "@img/sharp-libvips-linux-riscv64": "1.3.4", + "@img/sharp-libvips-linux-s390x": "1.3.4", + "@img/sharp-libvips-linux-x64": "1.3.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.4", + "@img/sharp-libvips-linuxmusl-x64": "1.3.4", + "@img/sharp-linux-arm": "0.35.5", + "@img/sharp-linux-arm64": "0.35.5", + "@img/sharp-linux-ppc64": "0.35.5", + "@img/sharp-linux-riscv64": "0.35.5", + "@img/sharp-linux-s390x": "0.35.5", + "@img/sharp-linux-x64": "0.35.5", + "@img/sharp-linuxmusl-arm64": "0.35.5", + "@img/sharp-linuxmusl-x64": "0.35.5", + "@img/sharp-webcontainers-wasm32": "0.35.5", + "@img/sharp-win32-arm64": "0.35.5", + "@img/sharp-win32-ia32": "0.35.5", + "@img/sharp-win32-x64": "0.35.5" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index b4100176..44f43ab8 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "node-object-hash": "^3.1.1", "normalize-url": "^8.0.1", "ntfy": "^1.15.2", + "openapi-fetch": "^0.17.0", "p-event": "^4.2.0", "p-map": "^7.0.4", "p-retry": "^7.1.1", @@ -136,6 +137,7 @@ "rate-limiter-flexible": "^11.2.0", "react-use-timeout": "^1.0.0", "serialize-error": "^13.0.1", + "sharp": "^0.35.5", "spotify-web-api-node": "^5.0.2", "sqlite-up": "^0.5.0", "superagent": "^8.0.9", diff --git a/src/backend/common/infrastructure/config/client/tuneshine.ts b/src/backend/common/infrastructure/config/client/tuneshine.ts new file mode 100644 index 00000000..bea37745 --- /dev/null +++ b/src/backend/common/infrastructure/config/client/tuneshine.ts @@ -0,0 +1,70 @@ +import * as z from "zod"; +import {componentTypeSchema} from "../../../../../core/Atomic.ts"; +import {requestRetryOptionsSchema} from "../common.ts"; +import {commonClientConfigSchema, commonClientDataSchema, commonClientOptionsSchema, nowPlayingOptionsSchema, type EnvClientSchema} from "./index.ts"; +import { httpUrl } from "../../../../utils/ZodUtils.ts"; + +export const tuneshineDataSchema = z.object({ + ...requestRetryOptionsSchema.shape, + /** + * IP or hostname for the Tuneshine device + * + * @examples ["192.168.1.100"] + * */ + host: httpUrl.meta({ + description: "IP or hostname for the Tuneshine device", + examples: ["192.168.1.100"] + }), +}); +export type TuneshineData = z.infer; + +export const tuneshineOptionsSchema = z.object({ + ...commonClientOptionsSchema.shape, + ...nowPlayingOptionsSchema.shape, +}); + +const envDataSchema = z.object({ + TUNE_HOST: tuneshineDataSchema.shape.host, +}); + +export const envSchemas: EnvClientSchema = { + env: envDataSchema, + prefix: 'TUNE', + toConfig: (partial) => ({ + configureAs: 'client', + data: { + host: partial.TUNE_HOST, + + } + }) +}; + +export const tuneshineClientDataSchema = tuneshineDataSchema.extend(commonClientDataSchema.shape); + +export type TuneshineClientData = z.infer; + +export const tuneshineClientConfigSchema = z.object({ + ...commonClientConfigSchema.shape, + /** + * Should always be `client` when using Tuneshine as a client + * + * @default client + * @examples ["client"] + * */ + configureAs: componentTypeSchema.optional().meta({ + description: "Should always be `client` when using Tuneshine as a client", + default: "client", + examples: ["client"] + }), + data: tuneshineClientDataSchema, + options: tuneshineOptionsSchema.optional() +}); + +export type TuneshineClientConfig = z.infer; + +export const tuneshineClientAIOConfigSchema = z.object({ + ...tuneshineClientConfigSchema.shape, + type: z.literal('tuneshine'), +}).meta({title: "Tuneshine"}); + +export type TuneshineClientAIOConfig = z.infer; diff --git a/src/backend/common/vendor/tuneshine/TuneshineApiClient.ts b/src/backend/common/vendor/tuneshine/TuneshineApiClient.ts new file mode 100644 index 00000000..c79cb73a --- /dev/null +++ b/src/backend/common/vendor/tuneshine/TuneshineApiClient.ts @@ -0,0 +1,92 @@ +import { normalizeWebAddress } from "../../../utils/NetworkUtils.ts"; +import { SimpleError } from "../../errors/MSErrors.ts"; +import type { AbstractApiOptions } from "../../infrastructure/Atomic.ts"; +import type { TuneshineClientConfig } from "../../infrastructure/config/client/tuneshine.ts"; +import AbstractApiClient from "../AbstractApiClient.ts"; +import type { paths, operations, components } from './tuneshine.d.ts'; +import createClient from "openapi-fetch"; +import type {Client} from "openapi-fetch"; +import sharp, { type SharpInput } from 'sharp'; +import ky from 'ky'; +import type { PlayObject } from "../../../../core/Atomic.ts"; +import { getRoot } from "../../../ioc.ts"; +import type { Cacheable } from "cacheable"; +import { nowPlayingExpirationDuration } from "../../../scrobblers/AbstractScrobbleClient.ts"; + +export class TuneshineApiClient extends AbstractApiClient { + + declare config: TuneshineClientConfig; + cache: Cacheable; + + api!: Client; + + constructor(name: string, config: TuneshineClientConfig, options: AbstractApiOptions & {cache?: Cacheable}) { + super('Tuneshine', name, config, options); + this.cache = options.cache ?? getRoot().items.cache().cacheApi; + } + + public async buildData() { + const { + data: { + host + } = {} + } = this.config; + if (host === undefined) { + throw new SimpleError('host must be defined'); + } + const u = normalizeWebAddress(host); + this.logger.verbose(`Config Host: '${host ?? '(None Given)'}' => Normalized: '${u.url}'`); + this.api = createClient({baseUrl: u.url.toString()}) + } + + async clearNowPlaying() { + const {data, error} = await this.api.DELETE("/image"); + } + + async sendNowPlaying(play: PlayObject) { + let artUrl: string | undefined = undefined; + if(play.meta?.art?.track !== undefined) { + artUrl = play.meta?.art?.track; + } else if(play.meta?.art?.album !== undefined) { + artUrl = play.meta?.art?.album; + } else if(play.meta?.art?.artist !== undefined) { + artUrl = play.meta?.art?.artist; + } + if(artUrl === undefined) { + throw new SimpleError('No art found on Play'); + } + //const postImage = this.api.path('/image').method('post').create(); + const metadata = playToPostImageMetadata(play); + + const {data, error} = await this.api.POST("/image", { + body: { + image: (await this.convertToWebp({url: artUrl})).toString(), + metadata: JSON.stringify(metadata) + } + }); + } + + public async convertToWebp(opts: { url?: string, data?: SharpInput }) { + let input: SharpInput; + if (opts.data !== undefined) { + input = opts.data; + } else if (opts.url !== undefined) { + input = await ky.get(opts.url).arrayBuffer(); + } else { + throw new SimpleError('Must pass either url or data'); + } + return sharp(input) + .resize(64, 64, { withoutEnlargement: true }) + .webp({ nearLossless: true }) + .toBuffer() + } +} + +const playToPostImageMetadata = (play: PlayObject): components['schemas']['ImageUrlRequest'] => ({ + timeoutMs: nowPlayingExpirationDuration({play, position: play.meta.trackProgressPosition }).asMilliseconds(), + trackName: play.data.track, + artistName: play.data.artists === undefined ? undefined : play.data.artists.map(x => x.name).join(' / '), + albumName: play.data.album, + serviceName: play.meta.musicService ?? play.meta.source, + itemId: play.meta.trackId ?? play.uid +}); \ No newline at end of file diff --git a/src/backend/common/vendor/tuneshine/tuneshine-openapi.json b/src/backend/common/vendor/tuneshine/tuneshine-openapi.json new file mode 100644 index 00000000..f757f90a --- /dev/null +++ b/src/backend/common/vendor/tuneshine/tuneshine-openapi.json @@ -0,0 +1,547 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "Tuneshine Device API", + "description": "HTTP API for controlling Tuneshine over your LAN.", + "version": "1.0.1" + }, + "servers": [ + { + "url": "http://{host}", + "variables": { + "host": { + "default": "tuneshine-4064.local" + } + } + } + ], + "paths": { + "/health": { + "get": { + "summary": "Health check", + "operationId": "getHealth", + "responses": { + "200": { + "description": "Device is alive", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "example": "alive" + } + } + } + } + } + } + } + } + }, + "/openapi.json": { + "get": { + "summary": "OpenAPI specification", + "operationId": "getSpec", + "responses": { + "200": { + "description": "This OpenAPI spec as JSON", + "content": { + "application/json": { + + } + } + } + } + } + }, + "/state": { + "get": { + "summary": "Get device state", + "operationId": "getState", + "responses": { + "200": { + "description": "Current device state, including device config and any metadata that's been sent to the device. You can check this endpoint to see what has been sent from the server and add additional functionality that reacts to the current track metadata.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/DeviceState" + } + } + } + } + } + } + }, + "/image": { + "post": { + "summary": "Upload or fetch an image", + "operationId": "postImage", + "description": "Display an image on the device by providing an image URL (application/json POST) or directly including a 64x64 WebP image in the request body (multipart/form-data POST). You can also include metadata about the image (e.g. track and artist name) which will be returned in the /state response. To update the metadata without changing the image, send a POST request with the existing image URL and the new metadata fields.\n\nIf there is an error downloading or decoding the image, you will need to make a subsequent request to /state to check the lastImageError field in the response.", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "image" + ], + "properties": { + "image": { + "type": "string", + "format": "binary", + "description": "WebP image file (max 768 kB). Must be 64x64. Supports animated WebP images." + }, + "metadata": { + "type": "string", + "description": "JSON string with image metadata. See the TrackMetadata schema for supported fields.", + "example": "{\"trackName\":\"Song\",\"artistName\":\"Artist\"}" + } + } + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageUrlRequest" + }, + "example": { + "imageUrl": "http://example.com/image.jpeg", + "trackName": "Song", + "artistName": "Artist", + "albumName": "Album" + } + } + } + }, + "responses": { + "200": { + "description": "Image accepted or metadata updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusOk" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "409": { + "description": "Metadata-only update but no local image exists", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "413": { + "description": "Payload too large", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "summary": "Delete current image", + "operationId": "deleteImage", + "description": "Remove the locally-provided image (shown in localMetadata in the /state endpoint) and revert to the current idle image. If you don't have an image to show but would still like to provide metadata (for example, if your music source is missing an image) you can DELETE the current image and include the new metadata in the DELETE request body as JSON.", + "requestBody": { + "required": false, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TrackMetadata" + } + } + } + }, + "responses": { + "200": { + "description": "Image removed", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusOk" + } + } + } + } + } + } + }, + "/brightness": { + "post": { + "summary": "Set display brightness", + "operationId": "postBrightness", + "description": "If the Tuneshine has its brightness config set on the Tuneshine server, this will overwrite the brightness stored in the database with consideration for the currently active schedule.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "active": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Active brightness (1-100)" + }, + "idle": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Idle brightness (1-100)" + } + }, + "minProperties": 1 + } + } + } + }, + "responses": { + "200": { + "description": "Brightness updated", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/StatusOk" + } + } + } + }, + "400": { + "description": "Validation error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/artwork": { + "get": { + "summary": "Get stored artwork", + "operationId": "getArtwork", + "description": "If you have uploaded artwork with the POST /image multipart/form-data endpoint, and didn’t provide a URL for the image, the Tuneshine will let you fetch the artwork from this endpoint so that it can be displayed by other clients. Since the URL does not change, you may want to use the itemID field provided in the metadata as a cache bust parameter on the url, e.g. /artwork?itemId=12345.", + "responses": { + "200": { + "description": "WebP image data", + "content": { + "image/webp": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "No artwork stored", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "head": { + "summary": "Check if artwork exists", + "operationId": "headArtwork", + "description": "This is necessary for some clients to ensure image loading works correctly.", + "responses": { + "200": { + "description": "Artwork exists" + }, + "404": { + "description": "No artwork stored" + } + } + } + } + }, + "components": { + "schemas": { + "StatusOk": { + "type": "object", + "required": [ + "status" + ], + "additionalProperties": false, + "properties": { + "status": { + "type": "string", + "example": "ok" + } + } + }, + "Error": { + "type": "object", + "required": [ + "message" + ], + "additionalProperties": false, + "properties": { + "message": { + "type": "string" + } + } + }, + "TrackMetadata": { + "type": "object", + "properties": { + "trackName": { + "type": "string", + "description": "Track or song name" + }, + "artistName": { + "type": "string", + "description": "Artist name" + }, + "albumName": { + "type": "string", + "description": "Album name" + }, + "serviceName": { + "type": "string", + "description": "Music service name (e.g. Spotify, Roon, Apple Music)" + }, + "subServiceName": { + "type": "string", + "description": "Sub-service or service variant (e.g. TIDAL on Roon)." + }, + "itemId": { + "type": "string", + "description": "Unique identifier for the track or item within the service. Can be useful to use as a cache-busting query parameter when fetching artwork from the /artwork endpoint." + }, + "serviceIconUrl": { + "type": "string", + "description": "URL or base64 data URL of the service icon. Used by clients (like the Tuneshine mobile app) to show an icon for the service currently in use. Up to 4 kB, good for a small 32x32ish icon." + }, + "zoneName": { + "type": "string", + "description": "Speaker or zone the music is playing on" + }, + "accountName": { + "type": "string", + "description": "Account or user name the music is playing from" + }, + "accountId": { + "type": "string", + "description": "Account or user ID" + }, + "idle": { + "type": "boolean", + "description": "Whether this image is an idle/screensaver image" + }, + "overridable": { + "type": "boolean", + "description": "If true and idle is true, a remote non-idle image will override this local idle image. When the remote image becomes idle again, this local idle image is automatically restored. Defaults to false. This is useful for adding custom behavior when the device is idle." + } + } + }, + "ImageUrlRequest": { + "allOf": [ + { + "$ref": "#/components/schemas/TrackMetadata" + }, + { + "type": "object", + "properties": { + "imageUrl": { + "type": "string", + "format": "uri", + "description": "URL to fetch the image from (must be http://). Supports PNG, JPEG, and WebP. Omit to update metadata on the existing local image (for example, if the song has changed but the album art is the same). You can include this even if doing a multipart/form-data upload, which can be useful if you want to send an optimized image to the Tuneshine but also provide a URL for clients to fetch the image from (e.g. for showing the image in the Tuneshine mobile app).\n\n If only image URL is provided and must be downloaded on-device, the max file size is 768kB. There are additional limitations on image dimensions as the whole image must be decoded in to RAM—for a PNG and WebP it's about 724 x 724, for a JPEG about 5792 x 5792. The larger the image, the longer it will take to decode. Animated WebP images must be scaled to 64x64 or only the first frame will be shown." + }, + "contentType": { + "type": "string", + "enum": [ + "track", + "podcast", + "audiobook", + "radio", + "video" + ], + "description": "Content type" + }, + "animation": { + "type": "string", + "enum": [ + "none", + "dissolve", + "crate", + "crate_to_idle", + "crate_from_idle" + ], + "description": "Override the default transition animation for this image change." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "maximum": 60000, + "default": 10000, + "description": "Timeout in milliseconds for fetching the image from the url" + } + } + } + ] + }, + "ImageMetadata": { + "allOf": [ + { + "$ref": "#/components/schemas/TrackMetadata" + }, + { + "type": "object", + "properties": { + "imageUrl": { + "type": "string", + "description": "URL of the image currently displayed" + }, + "contentType": { + "type": "string", + "enum": [ + "track", + "podcast", + "audiobook", + "radio", + "video" + ], + "description": "Content type of for the item represented by the current image" + }, + "lastImageError": { + "type": "string", + "nullable": true, + "description": "Error code from the last failed image decode, or null if the last decode succeeded" + }, + "overridable": { + "type": "boolean", + "description": "When true, this local idle image can be temporarily overridden by remote non-idle images. Omitted when false." + } + } + } + ] + }, + "DeviceState": { + "type": "object", + "required": [ + "name", + "firmwareVersion", + "hardwareId", + "config", + "imageSource", + "localMetadata", + "remoteMetadata" + ], + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "nullable": true, + "description": "Custom device name, or null if not set" + }, + "firmwareVersion": { + "type": "string", + "description": "Currently running firmware version" + }, + "hardwareId": { + "type": "string", + "description": "Unique hardware identifier for the device. Equal to the device's MAC address." + }, + "config": { + "type": "object", + "required": [ + "brightness", + "animation" + ], + "additionalProperties": false, + "properties": { + "brightness": { + "type": "object", + "required": [ + "base", + "active", + "idle" + ], + "additionalProperties": false, + "properties": { + "base": { + "type": "integer", + "description": "Default brightness used on startup and for unknown content types. Should be the dimmest of the brightness values." + }, + "active": { + "type": "integer", + "description": "Brightness when showing album artwork and other non-idle images." + }, + "idle": { + "type": "integer", + "description": "Brightness used when displaying idle/screensaver images" + } + } + }, + "animation": { + "type": "string", + "description": "Current animation preference: none, dissolve, or crate" + } + } + }, + "imageSource": { + "type": "string", + "enum": [ + "system", + "local", + "remote" + ], + "description": "Source of the currently displayed image. 'system' means no image has been received yet (showing startup splash). 'local' means the image was uploaded via the local HTTP API. 'remote' means the image came from the WebSocket connection." + }, + "localMetadata": { + "nullable": true, + "description": "Metadata for the image set via the local API server (/image POST), or null if no local image is active", + "allOf": [ + { + "$ref": "#/components/schemas/ImageMetadata" + } + ] + }, + "remoteMetadata": { + "nullable": true, + "description": "Metadata for the image set via the WebSocket connection to the Tuneshine server, or null if no remote image is active", + "allOf": [ + { + "$ref": "#/components/schemas/ImageMetadata" + } + ] + } + } + } + } + } +} \ No newline at end of file diff --git a/src/backend/common/vendor/tuneshine/tuneshine.d.ts b/src/backend/common/vendor/tuneshine/tuneshine.d.ts new file mode 100644 index 00000000..4b5d70bd --- /dev/null +++ b/src/backend/common/vendor/tuneshine/tuneshine.d.ts @@ -0,0 +1,490 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/health": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Health check */ + get: operations["getHealth"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/openapi.json": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** OpenAPI specification */ + get: operations["getSpec"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/state": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get device state */ + get: operations["getState"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/image": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload or fetch an image + * @description Display an image on the device by providing an image URL (application/json POST) or directly including a 64x64 WebP image in the request body (multipart/form-data POST). You can also include metadata about the image (e.g. track and artist name) which will be returned in the /state response. To update the metadata without changing the image, send a POST request with the existing image URL and the new metadata fields. + * + * If there is an error downloading or decoding the image, you will need to make a subsequent request to /state to check the lastImageError field in the response. + */ + post: operations["postImage"]; + /** + * Delete current image + * @description Remove the locally-provided image (shown in localMetadata in the /state endpoint) and revert to the current idle image. If you don't have an image to show but would still like to provide metadata (for example, if your music source is missing an image) you can DELETE the current image and include the new metadata in the DELETE request body as JSON. + */ + delete: operations["deleteImage"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/brightness": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Set display brightness + * @description If the Tuneshine has its brightness config set on the Tuneshine server, this will overwrite the brightness stored in the database with consideration for the currently active schedule. + */ + post: operations["postBrightness"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/artwork": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get stored artwork + * @description If you have uploaded artwork with the POST /image multipart/form-data endpoint, and didn’t provide a URL for the image, the Tuneshine will let you fetch the artwork from this endpoint so that it can be displayed by other clients. Since the URL does not change, you may want to use the itemID field provided in the metadata as a cache bust parameter on the url, e.g. /artwork?itemId=12345. + */ + get: operations["getArtwork"]; + put?: never; + post?: never; + delete?: never; + options?: never; + /** + * Check if artwork exists + * @description This is necessary for some clients to ensure image loading works correctly. + */ + head: operations["headArtwork"]; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + StatusOk: { + /** @example ok */ + status: string; + }; + Error: { + message: string; + }; + TrackMetadata: { + /** @description Track or song name */ + trackName?: string; + /** @description Artist name */ + artistName?: string; + /** @description Album name */ + albumName?: string; + /** @description Music service name (e.g. Spotify, Roon, Apple Music) */ + serviceName?: string; + /** @description Sub-service or service variant (e.g. TIDAL on Roon). */ + subServiceName?: string; + /** @description Unique identifier for the track or item within the service. Can be useful to use as a cache-busting query parameter when fetching artwork from the /artwork endpoint. */ + itemId?: string; + /** @description URL or base64 data URL of the service icon. Used by clients (like the Tuneshine mobile app) to show an icon for the service currently in use. Up to 4 kB, good for a small 32x32ish icon. */ + serviceIconUrl?: string; + /** @description Speaker or zone the music is playing on */ + zoneName?: string; + /** @description Account or user name the music is playing from */ + accountName?: string; + /** @description Account or user ID */ + accountId?: string; + /** @description Whether this image is an idle/screensaver image */ + idle?: boolean; + /** @description If true and idle is true, a remote non-idle image will override this local idle image. When the remote image becomes idle again, this local idle image is automatically restored. Defaults to false. This is useful for adding custom behavior when the device is idle. */ + overridable?: boolean; + }; + ImageUrlRequest: components["schemas"]["TrackMetadata"] & { + /** + * Format: uri + * @description URL to fetch the image from (must be http://). Supports PNG, JPEG, and WebP. Omit to update metadata on the existing local image (for example, if the song has changed but the album art is the same). You can include this even if doing a multipart/form-data upload, which can be useful if you want to send an optimized image to the Tuneshine but also provide a URL for clients to fetch the image from (e.g. for showing the image in the Tuneshine mobile app). + * + * If only image URL is provided and must be downloaded on-device, the max file size is 768kB. There are additional limitations on image dimensions as the whole image must be decoded in to RAM—for a PNG and WebP it's about 724 x 724, for a JPEG about 5792 x 5792. The larger the image, the longer it will take to decode. Animated WebP images must be scaled to 64x64 or only the first frame will be shown. + */ + imageUrl?: string; + /** + * @description Content type + * @enum {string} + */ + contentType?: "track" | "podcast" | "audiobook" | "radio" | "video"; + /** + * @description Override the default transition animation for this image change. + * @enum {string} + */ + animation?: "none" | "dissolve" | "crate" | "crate_to_idle" | "crate_from_idle"; + /** + * @description Timeout in milliseconds for fetching the image from the url + * @default 10000 + */ + timeoutMs: number; + }; + ImageMetadata: components["schemas"]["TrackMetadata"] & { + /** @description URL of the image currently displayed */ + imageUrl?: string; + /** + * @description Content type of for the item represented by the current image + * @enum {string} + */ + contentType?: "track" | "podcast" | "audiobook" | "radio" | "video"; + /** @description Error code from the last failed image decode, or null if the last decode succeeded */ + lastImageError?: string | null; + /** @description When true, this local idle image can be temporarily overridden by remote non-idle images. Omitted when false. */ + overridable?: boolean; + }; + DeviceState: { + /** @description Custom device name, or null if not set */ + name: string | null; + /** @description Currently running firmware version */ + firmwareVersion: string; + /** @description Unique hardware identifier for the device. Equal to the device's MAC address. */ + hardwareId: string; + config: { + brightness: { + /** @description Default brightness used on startup and for unknown content types. Should be the dimmest of the brightness values. */ + base: number; + /** @description Brightness when showing album artwork and other non-idle images. */ + active: number; + /** @description Brightness used when displaying idle/screensaver images */ + idle: number; + }; + /** @description Current animation preference: none, dissolve, or crate */ + animation: string; + }; + /** + * @description Source of the currently displayed image. 'system' means no image has been received yet (showing startup splash). 'local' means the image was uploaded via the local HTTP API. 'remote' means the image came from the WebSocket connection. + * @enum {string} + */ + imageSource: "system" | "local" | "remote"; + /** @description Metadata for the image set via the local API server (/image POST), or null if no local image is active */ + localMetadata: components["schemas"]["ImageMetadata"] | null; + /** @description Metadata for the image set via the WebSocket connection to the Tuneshine server, or null if no remote image is active */ + remoteMetadata: components["schemas"]["ImageMetadata"] | null; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + getHealth: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Device is alive */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + /** @example alive */ + status: string; + }; + }; + }; + }; + }; + getSpec: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description This OpenAPI spec as JSON */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + }; + }; + getState: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Current device state, including device config and any metadata that's been sent to the device. You can check this endpoint to see what has been sent from the server and add additional functionality that reacts to the current track metadata. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeviceState"]; + }; + }; + }; + }; + postImage: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": { + /** + * Format: binary + * @description WebP image file (max 768 kB). Must be 64x64. Supports animated WebP images. + */ + image: string; + /** + * @description JSON string with image metadata. See the TrackMetadata schema for supported fields. + * @example {"trackName":"Song","artistName":"Artist"} + */ + metadata?: string; + }; + /** + * @example { + * "imageUrl": "http://example.com/image.jpeg", + * "trackName": "Song", + * "artistName": "Artist", + * "albumName": "Album" + * } + */ + "application/json": components["schemas"]["ImageUrlRequest"]; + }; + }; + responses: { + /** @description Image accepted or metadata updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusOk"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Metadata-only update but no local image exists */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description Payload too large */ + 413: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + deleteImage: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + "application/json": components["schemas"]["TrackMetadata"]; + }; + }; + responses: { + /** @description Image removed */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusOk"]; + }; + }; + }; + }; + postBrightness: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + /** @description Active brightness (1-100) */ + active?: number; + /** @description Idle brightness (1-100) */ + idle?: number; + }; + }; + }; + responses: { + /** @description Brightness updated */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StatusOk"]; + }; + }; + /** @description Validation error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + getArtwork: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description WebP image data */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "image/webp": string; + }; + }; + /** @description No artwork stored */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + }; + }; + headArtwork: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Artwork exists */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description No artwork stored */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; +}