From 8513b387f5a6bbe03a6f9044fbc590a6047183de Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 25 Oct 2025 03:28:57 +0000 Subject: [PATCH 01/51] Implement reportAccountAction, fix bug in auth flow between sonos and bonob --- .devcontainer/devcontainer.json | 3 ++- package.json | 7 ++++--- src/server.ts | 5 +++++ src/smapi.ts | 27 ++++++++++++++++----------- src/smapi_auth.ts | 26 ++++++++++---------------- tests/smapi.test.ts | 17 ++++++++++++++++- tests/smapi_auth.test.ts | 15 ++++----------- 7 files changed, 57 insertions(+), 43 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f306284..f45b469 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -6,10 +6,11 @@ "containerEnv": { // these env vars need to be configured appropriately for your local dev env "BNB_DEV_SONOS_DEVICE_IP": "${localEnv:BNB_DEV_SONOS_DEVICE_IP}", - "BNB_DEV_HOST_IP": "${localEnv:BNB_DEV_HOST_IP}", + "BNB_DEV_URL": "${localEnv:BNB_DEV_URL}", "BNB_DEV_SUBSONIC_URL": "${localEnv:BNB_DEV_SUBSONIC_URL}" }, "remoteUser": "node", + "runArgs": ["-p", "0.0.0.0:4534:4534"], "forwardPorts": [4534], "features": { "ghcr.io/devcontainers/features/docker-in-docker:2": { diff --git a/package.json b/package.json index e7b4e5a..3b7a876 100644 --- a/package.json +++ b/package.json @@ -65,9 +65,10 @@ "scripts": { "clean": "rm -Rf build node_modules", "build": "tsc", - "dev": "BNB_SUBSONIC_CUSTOM_CLIENTS1=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", - "devr": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", - "register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534", + "dev": "BNB_SUBSONIC_CUSTOM_CLIENTS1=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", + "dev80": "BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", + "devr": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", + "register-dev": "ts-node ./src/register.ts ${BNB_DEV_URL}", "test": "jest", "testw": "jest --watch", "gitinfo": "git describe --tags > .gitinfo" diff --git a/src/server.ts b/src/server.ts index 8a90f37..8fad19c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -369,6 +369,11 @@ function server( `); }); + // app.post("/report/:version/timePlayed", async (_, res) => { + // console.log(`!!!!!!!!!!!!!!!!!!!!!!!!!!!! report`) + // return res.status(200).json({ status: "ok" }); + // }), + app.get("/stream/track/:id", async (req, res) => { const id = req.params["id"]!; const trace = uuid(); diff --git a/src/smapi.ts b/src/smapi.ts index a4b0723..f0a178b 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -193,6 +193,8 @@ class SonosSoap { }; } + reportAccountAction = (_: { type: string }) => ({}) + getDeviceAuthToken({ linkCode, }: { @@ -399,7 +401,8 @@ function bindSmapiSoapServiceToExpress( pipe( smapiAuthTokens.verify({ token: credentials.loginToken.token, - key: credentials.loginToken.key, + //todo: remove me + key: "nonsense", }), E.map((serviceToken) => ({ serviceToken, @@ -432,17 +435,17 @@ function bindSmapiSoapServiceToExpress( musicService.refreshToken(authOrFail.expiredToken), TE.map((it) => smapiAuthTokens.issue(it.serviceToken)), TE.map((newToken) => ({ - Fault: { - faultcode: "Client.TokenRefreshRequired", - faultstring: "Token has expired", - detail: { - refreshAuthTokenResult: { - authToken: newToken.token, - privateKey: newToken.key, + Fault: { + faultcode: "Client.TokenRefreshRequired", + faultstring: "Token has expired", + detail: { + refreshAuthTokenResult: { + authToken: newToken.token, + privateKey: newToken.key, + }, }, }, - }, - })), + })), TE.getOrElse(() => T.of(SMAPI_FAULT_LOGIN_UNAUTHORIZED)) )(); } else { @@ -457,6 +460,8 @@ function bindSmapiSoapServiceToExpress( Sonos: { SonosSoap: { getAppLink: () => sonosSoap.getAppLink(), + reportAccountAction: ({ type } : { type: string }) => + sonosSoap.reportAccountAction({ type }), getDeviceAuthToken: ({ linkCode }: { linkCode: string }) => sonosSoap.getDeviceAuthToken({ linkCode }), getLastUpdate: () => ({ @@ -467,7 +472,7 @@ function bindSmapiSoapServiceToExpress( pollInterval: 60, }, }), - refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders) => { + refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders) => { const serviceToken = pipe( auth(soapyHeaders?.credentials), E.fold( diff --git a/src/smapi_auth.ts b/src/smapi_auth.ts index 585bb65..8db73a6 100644 --- a/src/smapi_auth.ts +++ b/src/smapi_auth.ts @@ -1,6 +1,5 @@ import { either as E } from "fp-ts"; import jwt from "jsonwebtoken"; -import { v4 as uuid } from "uuid"; import { b64Decode, b64Encode } from "./b64"; import { Clock } from "./clock"; @@ -115,40 +114,35 @@ export const smapiTokenAsString = (smapiToken: SmapiToken) => export const smapiTokenFromString = (smapiTokenString: string): SmapiToken => JSON.parse(b64Decode(smapiTokenString)); -export const SMAPI_TOKEN_VERSION = 2; +export const SMAPI_TOKEN_VERSION = 3; export class JWTSmapiLoginTokens implements SmapiAuthTokens { private readonly clock: Clock; private readonly secret: string; private readonly expiresIn: string; - private readonly version: number; - private readonly keyGenerator: () => string; + private readonly key: string; constructor( clock: Clock, secret: string, expiresIn: string, - keyGenerator: () => string = uuid, version: number = SMAPI_TOKEN_VERSION ) { this.clock = clock; this.secret = secret; this.expiresIn = expiresIn; - this.version = version; - this.keyGenerator = keyGenerator; + this.key = this.secret + "." + version } - issue = (serviceToken: string) => { - const key = this.keyGenerator(); - return { + issue = (serviceToken: string) => ({ token: jwt.sign( { serviceToken, iat: this.clock.now().unix() }, - this.secret + this.version + key, + this.key, { expiresIn: this.expiresIn } ), - key, - }; - }; + // todo: remove this entirely + key: "nonsense" + }); verify = (smapiToken: SmapiToken): E.Either => { try { @@ -156,7 +150,7 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens { ( jwt.verify( smapiToken.token, - this.secret + this.version + smapiToken.key + this.key ) as any ).serviceToken ); @@ -165,7 +159,7 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens { const serviceToken = ( jwt.verify( smapiToken.token, - this.secret + this.version + smapiToken.key, + this.key, { ignoreExpiration: true } ) as any ).serviceToken; diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 3f64a5f..a910b9f 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -635,7 +635,7 @@ describe("wsdl api", () => { const apiToken = `apiToken-${uuid()}`; const smapiAuthToken: SmapiToken = { token: `smapiAuthToken.token-${uuid()}`, - key: `smapiAuthToken.key-${uuid()}` + key: "nonsense" }; const bonobUrlWithAccessToken = bonobUrl.append({ @@ -705,6 +705,21 @@ describe("wsdl api", () => { }); }); + describe("reportAccountAction", () => { + it("should do something", async () => { + const ws = await createClientAsync(`${service.uri}?wsdl`, { + endpoint: service.uri, + httpClient: supersoap(server), + }); + + const type = "something"; + + const result = await ws.reportAccountActionAsync({ type }); + + expect(result[0]).toEqual(null); + }); + }); + describe("getDeviceAuthToken", () => { describe("when there is a linkCode association", () => { it("should return a device auth token", async () => { diff --git a/tests/smapi_auth.test.ts b/tests/smapi_auth.test.ts index d4db82c..b79d744 100644 --- a/tests/smapi_auth.test.ts +++ b/tests/smapi_auth.test.ts @@ -63,8 +63,7 @@ describe("auth", () => { const expiresIn = "1h"; const secret = `secret-${uuid()}`; - const key = uuid(); - const smapiLoginTokens = new JWTSmapiLoginTokens(clock, secret, expiresIn, () => key); + const smapiLoginTokens = new JWTSmapiLoginTokens(clock, secret, expiresIn); describe("issuing a new token", () => { it("should issue a token that can then be verified", () => { @@ -77,7 +76,7 @@ describe("auth", () => { serviceToken, iat: clock.now().unix(), }, - secret + SMAPI_TOKEN_VERSION + key, + secret + "." + SMAPI_TOKEN_VERSION, { expiresIn } ); @@ -100,16 +99,13 @@ describe("auth", () => { const vXSmapiTokens = new JWTSmapiLoginTokens( clock, secret, - expiresIn, - uuid, - SMAPI_TOKEN_VERSION + expiresIn ); const vXPlus1SmapiTokens = new JWTSmapiLoginTokens( clock, secret, expiresIn, - () => uuid(), SMAPI_TOKEN_VERSION + 1 ); @@ -146,10 +142,7 @@ describe("auth", () => { const smapiToken = smapiLoginTokens.issue(authToken); - const result = smapiLoginTokens.verify({ - ...smapiToken, - key: "some other key", - }); + const result = new JWTSmapiLoginTokens(clock, "different-secret", expiresIn).verify(smapiToken); expect(result).toEqual( E.left(new InvalidTokenError("invalid signature")) ); From 14204a62873b8d7cfabaeebf62bd7c68afe4e7ca Mon Sep 17 00:00:00 2001 From: simojenki Date: Thu, 30 Oct 2025 22:51:24 +0000 Subject: [PATCH 02/51] Dummy implementation of /report/timePlayed as sonos s2 doesnt send the data for some reason, adding endpoints stops 404 however --- .devcontainer/devcontainer.json | 1 + package.json | 4 ++-- src/server.ts | 8 ++++---- src/smapi.ts | 3 +++ tests/server.test.ts | 25 +++++++++++++++++++++++++ 5 files changed, 35 insertions(+), 6 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f45b469..f05c635 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,6 +7,7 @@ // these env vars need to be configured appropriately for your local dev env "BNB_DEV_SONOS_DEVICE_IP": "${localEnv:BNB_DEV_SONOS_DEVICE_IP}", "BNB_DEV_URL": "${localEnv:BNB_DEV_URL}", + "BNB_DEV_LOCAL_URL": "${localEnv:BNB_DEV_LOCAL_URL}", "BNB_DEV_SUBSONIC_URL": "${localEnv:BNB_DEV_SUBSONIC_URL}" }, "remoteUser": "node", diff --git a/package.json b/package.json index 3b7a876..5686c27 100644 --- a/package.json +++ b/package.json @@ -65,9 +65,9 @@ "scripts": { "clean": "rm -Rf build node_modules", "build": "tsc", - "dev": "BNB_SUBSONIC_CUSTOM_CLIENTS1=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", "dev80": "BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", - "devr": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", + "dev": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=false BNB_SONOS_AUTO_REGISTER=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", + "devr": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"${BNB_DEV_LOCAL_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", "register-dev": "ts-node ./src/register.ts ${BNB_DEV_URL}", "test": "jest", "testw": "jest --watch", diff --git a/src/server.ts b/src/server.ts index 8fad19c..a068609 100644 --- a/src/server.ts +++ b/src/server.ts @@ -369,10 +369,10 @@ function server( `); }); - // app.post("/report/:version/timePlayed", async (_, res) => { - // console.log(`!!!!!!!!!!!!!!!!!!!!!!!!!!!! report`) - // return res.status(200).json({ status: "ok" }); - // }), + app.post("/report/timePlayed", async (_, res) => { + return res.status(200).json({ items: [] }); + }), + app.get("/stream/track/:id", async (req, res) => { const id = req.params["id"]!; diff --git a/src/smapi.ts b/src/smapi.ts index f0a178b..8120742 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -536,6 +536,7 @@ function bindSmapiSoapServiceToExpress( ], }; default: + // todo: maybe not throw this? throw `Unsupported type:${type}`; } }), @@ -557,6 +558,7 @@ function bindSmapiSoapServiceToExpress( getMediaMetadataResult: track(urlWithToken(apiKey), it), })); default: + //todo: maybe not throw this? throw `Unsupported type:${type}`; } }), @@ -668,6 +670,7 @@ function bindSmapiSoapServiceToExpress( }, })); default: + // unsupported "artists" throw `Unsupported getExtendedMetadata id=${id}`; } }), diff --git a/tests/server.test.ts b/tests/server.test.ts index 0a7e892..b77e4a4 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1591,6 +1591,31 @@ describe("server", () => { }); }); }); + + describe("/report/timePlayed", () => { + const sonos = { + register: jest.fn(), + }; + const theService = aService({ + name: "We can all live a life of service", + sid: 999, + }); + const server = makeServer( + sonos as unknown as Sonos, + theService, + bonobUrl, + new InMemoryMusicService() + ); + + it("should return empty json as sonos doesnt seem to send any data anyway", async () => { + const res = await request(server) + .post(bonobUrl.append({ pathname: "/report/timePlayed" }).path()) + .send() + .expect(200); + + expect(res.body).toEqual({ items: [] }); + }); + }); }); }); }); From 019d93f8e68e7d8e65cd284396cc91d18cbfde0a Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 31 Oct 2025 08:24:45 +0000 Subject: [PATCH 03/51] Support for S2 scrobble via reporting endpoint --- src/server.ts | 60 ++++++++++++++- src/smapi.ts | 44 ++++++----- tests/server.test.ts | 179 +++++++++++++++++++++++++++++++++++++++---- tests/smapi.test.ts | 2 +- 4 files changed, 246 insertions(+), 39 deletions(-) diff --git a/src/server.ts b/src/server.ts index a068609..d3b3ea4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -20,6 +20,8 @@ import { sonosifyMimeType, ratingFromInt, ratingAsInt, + splitId, + shouldScrobble } from "./smapi"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { MusicService, AuthFailure, AuthSuccess } from "./music_service"; @@ -46,6 +48,14 @@ interface RangeFilter extends Transform { range: (length: number) => string; } +type TimePlayed = { + items: { + mediaUrl: string, + type: "update" | "final" + durationPlayedMillis: number + }[] +} + export function rangeFilterFor(rangeHeader: string): RangeFilter { // if (rangeHeader == undefined) return new PassThrough(); const match = rangeHeader.match(/^bytes=(\d+)-$/); @@ -133,6 +143,7 @@ function server( app.use(morgan("combined")); } app.use(express.urlencoded({ extended: false })); + app.use(express.json()); app.use(express.static(path.resolve(__dirname, "..", "web", "public"))); app.engine("eta", Eta.renderFile); @@ -369,15 +380,56 @@ function server( `); }); - app.post("/report/timePlayed", async (_, res) => { - return res.status(200).json({ items: [] }); - }), + app.post("/report/timePlayed", async (req, res) => { + const serviceToken = pipe( + E.fromNullable("Missing authorization header")(req.headers["authorization"] as string), + E.flatMap((token) => { + return pipe( + smapiAuthTokens.verify({ token, key: "nonsense" }), + E.mapLeft((_) => "Auth token failed to verify") + ) + }), + E.getOrElseW(() => undefined) + ); + if (!serviceToken) { + return res.status(401).send(); + } else { + return musicService + .login(serviceToken) + .then(musicLibrary => { + const scrobbles = (req.body as TimePlayed).items + .filter(it => it.type == 'final') + .map(({ mediaUrl, durationPlayedMillis }) => ({ + ...splitId(decodeURIComponent(new URL(mediaUrl).pathname).split(".")[0]!), + durationPlayedMillis + })) + .map(({ type, typeId, durationPlayedMillis }) => { + return type == "track" ? ({ trackId: typeId, durationPlayedMillis }) : null + }) + .filter((it) => it != null) + .map(({ trackId, durationPlayedMillis }) => + musicLibrary + .track(trackId) + .then(track => { + if(shouldScrobble(track, durationPlayedMillis / 1000)) + return musicLibrary.scrobble(trackId).then(scrobbled => ({ trackId, scrobbled })) + else + return Promise.resolve({ trackId, scrobbled: false }) + }) + ); + return Promise.all(scrobbles) + }) + .then(it => res.status(200).json({ + scrobbled: it.filter(scrobble => scrobble.scrobbled).length + })); + } + }), app.get("/stream/track/:id", async (req, res) => { const id = req.params["id"]!; const trace = uuid(); - + logger.debug( `${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify( req.query diff --git a/src/smapi.ts b/src/smapi.ts index 8120742..ed76360 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -261,6 +261,10 @@ const yyyy = (bonobUrl: URLBuilder, year: Year) => ({ albumArtURI: year.year !== "?" ? iconArtURI(bonobUrl, "yyyy", year.year).href() : iconArtURI(bonobUrl, "music").href(), }); +export const shouldScrobble = (track: Track, playbackTime: number) => ( + (track.duration < 30 && playbackTime >= 10) || + (track.duration >= 30 && playbackTime >= 30)) + const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({ itemType: "playlist", id: `playlist:${playlist.id}`, @@ -350,12 +354,18 @@ export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({ albumArtURI: coverArtURI(bonobUrl, { coverArt: artist.image }).href(), }); -function splitId(id: string) { - const [type, typeId] = id.split(":"); +export const splitId = (id: string) => { + const [type, typeId] = id.split(":") + return { + type: type!!, + typeId: typeId!! + } +} + +export function withSplitId(id: string) { return (t: T) => ({ ...t, - type, - typeId: typeId!, + ...splitId(id) }); } @@ -506,7 +516,7 @@ function bindSmapiSoapServiceToExpress( soapyHeaders: SoapyHeaders ) => login(soapyHeaders?.credentials) - .then(splitId(id)) + .then(withSplitId(id)) .then(({ musicLibrary, credentials, type, typeId }) => { switch (type) { case "internetRadioStation": @@ -546,7 +556,7 @@ function bindSmapiSoapServiceToExpress( soapyHeaders: SoapyHeaders ) => login(soapyHeaders?.credentials) - .then(splitId(id)) + .then(withSplitId(id)) .then(async ({ musicLibrary, apiKey, type, typeId }) => { switch (type) { case "internetRadioStation": @@ -568,7 +578,7 @@ function bindSmapiSoapServiceToExpress( soapyHeaders: SoapyHeaders ) => login(soapyHeaders?.credentials) - .then(splitId(id)) + .then(withSplitId(id)) .then(async ({ musicLibrary, apiKey }) => { switch (id) { case "albums": @@ -613,7 +623,7 @@ function bindSmapiSoapServiceToExpress( soapyHeaders: SoapyHeaders ) => login(soapyHeaders?.credentials) - .then(splitId(id)) + .then(withSplitId(id)) .then(async ({ musicLibrary, apiKey, type, typeId }) => { const paging = { _index: index, _count: count }; switch (type) { @@ -686,7 +696,7 @@ function bindSmapiSoapServiceToExpress( { headers }: Pick ) => login(soapyHeaders?.credentials) - .then(splitId(id)) + .then(withSplitId(id)) .then(({ musicLibrary, apiKey, type, typeId }) => { const paging = { _index: index, _count: count }; const acceptLanguage = headers["accept-language"]; @@ -1045,7 +1055,7 @@ function bindSmapiSoapServiceToExpress( soapyHeaders: SoapyHeaders ) => login(soapyHeaders?.credentials) - .then(splitId(id)) + .then(withSplitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId) ) @@ -1056,7 +1066,7 @@ function bindSmapiSoapServiceToExpress( soapyHeaders: SoapyHeaders ) => login(soapyHeaders?.credentials) - .then(splitId(id)) + .then(withSplitId(id)) .then((it) => ({ ...it, indices: indices.split(",").map((it) => +it), @@ -1073,13 +1083,14 @@ function bindSmapiSoapServiceToExpress( } }) .then((_) => ({ removeFromContainerResult: { updateId: "" } })), + rateItem: async ( { id, rating }: { id: string; rating: number }, _, soapyHeaders: SoapyHeaders ) => login(soapyHeaders?.credentials) - .then(splitId(id)) + .then(withSplitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating))) ) @@ -1091,15 +1102,12 @@ function bindSmapiSoapServiceToExpress( soapyHeaders: SoapyHeaders ) => login(soapyHeaders?.credentials) - .then(splitId(id)) + .then(withSplitId(id)) .then(({ musicLibrary, type, typeId }) => { switch (type) { case "track": - return musicLibrary.track(typeId).then(({ duration }) => { - if ( - (duration < 30 && +seconds >= 10) || - (duration >= 30 && +seconds >= 30) - ) { + return musicLibrary.track(typeId).then(track => { + if (shouldScrobble(track, +seconds)) { return musicLibrary.scrobble(typeId); } else { return Promise.resolve(true); diff --git a/tests/server.test.ts b/tests/server.test.ts index b77e4a4..17094de 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -13,7 +13,7 @@ import makeServer, { import { Device, Sonos, SONOS_DISABLED } from "../src/sonos"; -import { aDevice, aService } from "./builders"; +import { aDevice, aService, aTrack } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import { APITokens, InMemoryAPITokens } from "../src/api_tokens"; import { InMemoryLinkCodes, LinkCodes } from "../src/link_codes"; @@ -1593,27 +1593,174 @@ describe("server", () => { }); describe("/report/timePlayed", () => { - const sonos = { - register: jest.fn(), + const musicService = { + login: jest.fn(), + }; + const musicLibrary = { + track: jest.fn(), + scrobble: jest.fn(), + }; + const smapiAuthTokens = { + verify: jest.fn(), }; - const theService = aService({ - name: "We can all live a life of service", - sid: 999, - }); const server = makeServer( - sonos as unknown as Sonos, - theService, + jest.fn() as unknown as Sonos, + aService(), bonobUrl, - new InMemoryMusicService() + musicService as unknown as MusicService, + { + smapiAuthTokens: smapiAuthTokens as unknown as SmapiAuthTokens + } ); + const authToken = `token-${uuid()}` + const serviceToken = `serviceToken-${uuid()}`; - it("should return empty json as sonos doesnt seem to send any data anyway", async () => { - const res = await request(server) - .post(bonobUrl.append({ pathname: "/report/timePlayed" }).path()) - .send() - .expect(200); + describe("when no auth token is provided", () => { + it("should return a 401", async () => { + await request(server) + .post(bonobUrl.append({ pathname: "/report/timePlayed" }).path()) + .send({ items: [] }) + .expect(401); + + expect(smapiAuthTokens.verify).not.toHaveBeenCalled(); + }); + }); + + describe("when the auth token is not valid", () => { + beforeEach(() => { + smapiAuthTokens.verify.mockReturnValue(E.left("no good")); + }); + + it("should return a 401", async () => { + await request(server) + .post(bonobUrl.append({ pathname: "/report/timePlayed" }).path()) + .send({ items: [] }) + .set('authorization', "not-a-valid-token") + .expect(401); + + expect(smapiAuthTokens.verify).toHaveBeenCalledWith({ token: "not-a-valid-token", key: "nonsense" }); + }); + }); + + describe("when the auth token is valid", () => { + beforeEach(() => { + smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken)); + musicService.login.mockResolvedValue(musicLibrary); + }); + + it("should auth using the provided authorization header", async () => { + const res = await request(server) + .post(bonobUrl.append({ pathname: "/report/timePlayed" }).path()) + .send({ items: [] }) + .set('authorization', authToken); + + expect(res.status).toEqual(200); + expect(smapiAuthTokens.verify).toHaveBeenCalledWith({ token: authToken, key: "nonsense" }); + }); + + describe("and there are no items to report", () => { + it("should report ok", async () => { + const res = await request(server) + .post(bonobUrl.append({ pathname: "/report/timePlayed" }).path()) + .send({ items: [] }) + .set('authorization', authToken) + .expect(200); + + + expect(res.body).toEqual({ scrobbled: 0 }); + expect(musicLibrary.track).not.toHaveBeenCalled(); + expect(musicLibrary.scrobble).not.toHaveBeenCalled(); + }); + }); + + describe("there is only an update", () => { + it("should not scrobble", async () => { + const res = await request(server) + .post(bonobUrl.append({ pathname: "/report/timePlayed" }).path()) + .send({ items: [ + { mediaUrl: "x-sonos-http:track%3xyz.mp3?a=b&c=d", type: "update", durationPlayedMillis: 123000 }, + ]}) + .set('authorization', authToken) + .expect(200); + + expect(res.body).toEqual({ scrobbled: 0 }); + expect(musicLibrary.track).not.toHaveBeenCalled(); + expect(musicLibrary.scrobble).not.toHaveBeenCalled(); + }); + }); + + describe("there is a single final play that has gone > 30s", () => { + it("should scrobble", async () => { + const id = "XYZ" + musicLibrary.track.mockResolvedValue(aTrack({ id, duration: 200 })); + musicLibrary.scrobble.mockResolvedValue(true); + + const res = await request(server) + .post(bonobUrl.append({ pathname: "/report/timePlayed" }).path()) + .send({ items: [ + { mediaUrl: `x-sonos-http:track%3a${id}.mp3?a=b&c=d`, type: "final", durationPlayedMillis: 123000 }, + ]}) + .set('authorization', authToken) + .expect(200); + + expect(res.body).toEqual({ scrobbled: 1 }); + expect(musicLibrary.scrobble).toHaveBeenCalledWith(id); + }); + }); + + describe("there is a single final play that has gone for not long enough to scrobble", () => { + it("should scrobble", async () => { + const id = "XYZ" + musicLibrary.track.mockResolvedValue(aTrack({ id, duration: 200 })); + musicLibrary.scrobble.mockResolvedValue(true); + + const res = await request(server) + .post(bonobUrl.append({ pathname: "/report/timePlayed" }).path()) + .send({ items: [ + { mediaUrl: `x-sonos-http:track%3a${id}.mp3?a=b&c=d`, type: "final", durationPlayedMillis: 29000 }, + ]}) + .set('authorization', authToken) + .expect(200); + + expect(res.body).toEqual({ scrobbled: 0 }); + expect(musicLibrary.scrobble).not.toHaveBeenCalled(); + }); + }); + + describe("there are a number of scrobbles", () => { + it("should scrobble", async () => { + const id1 = "should-scrobble-long-track" + const id2 = "should-not-scrobble-long-track" + const id3 = "should-scrobble-short-track" + const id4 = "should-not-scrobble-short-track" + const id5 = "should-not-scrobble-not-final" + + musicLibrary.track + .mockResolvedValueOnce(aTrack({ id: id1, duration: 200 })) + .mockResolvedValueOnce(aTrack({ id: id2, duration: 200 })) + .mockResolvedValueOnce(aTrack({ id: id3, duration: 20 })) + .mockResolvedValueOnce(aTrack({ id: id4, duration: 20 })); + + musicLibrary.scrobble.mockResolvedValue(true); + + const res = await request(server) + .post(bonobUrl.append({ pathname: "/report/timePlayed" }).path()) + .send({ items: [ + { mediaUrl: `x-sonos-http:track%3a${id1}.mp3?a=b&c=d`, type: "final", durationPlayedMillis: 31000 }, + { mediaUrl: `x-sonos-http:track%3a${id2}.flac?a=b&c=d`, type: "final", durationPlayedMillis: 29000 }, + { mediaUrl: `x-sonos-http:track%3a${id3}.gif?a=b&c=d`, type: "final", durationPlayedMillis: 11000 }, + { mediaUrl: `x-sonos-http:track%3a${id4}.jpg?a=b&c=d`, type: "final", durationPlayedMillis: 3000 }, + { mediaUrl: `x-sonos-http:track%3a${id5}.bob?a=b&c=d`, type: "update", durationPlayedMillis: 29000 }, + ]}) + .set('authorization', authToken) + .expect(200); + + expect(res.body).toEqual({ scrobbled: 2 }); + expect(musicLibrary.scrobble).toHaveBeenCalledWith(id1); + expect(musicLibrary.scrobble).toHaveBeenCalledWith(id3); + }); + }); - expect(res.body).toEqual({ items: [] }); }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index a910b9f..170a34e 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -24,7 +24,7 @@ import { sonosifyMimeType, ratingAsInt, ratingFromInt, - internetRadioStation + internetRadioStation, } from "../src/smapi"; import { keys as i8nKeys } from "../src/i8n"; From b9352c5dc00dfc177972a424c5012c0f2d6888e5 Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 31 Oct 2025 08:52:23 +0000 Subject: [PATCH 04/51] Ability to disable festivals for testing --- src/icon.ts | 3 +++ src/server.ts | 5 +++-- tests/server.test.ts | 4 ++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/icon.ts b/src/icon.ts index 2e739bb..68e1995 100644 --- a/src/icon.ts +++ b/src/icon.ts @@ -68,8 +68,11 @@ export interface Icon { apply(transformer: Transformer): Icon; } + export type Transformer = (icon: Icon) => Icon; +export const no_festivals: Transformer = (icon: Icon) => icon + export function transform(spec: Partial): Transformer { return (icon: Icon) => icon.with({ diff --git a/src/server.ts b/src/server.ts index d3b3ea4..d0f6131 100644 --- a/src/server.ts +++ b/src/server.ts @@ -32,7 +32,7 @@ import { Clock, SystemClock } from "./clock"; import { pipe } from "fp-ts/lib/function"; import { URLBuilder } from "./url_builder"; import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n"; -import { Icon, ICONS, festivals, features } from "./icon"; +import { Icon, ICONS, festivals, features, no_festivals } from "./icon"; import _ from "underscore"; import morgan from "morgan"; import { parse } from "./burn"; @@ -556,6 +556,7 @@ function server( }); app.get("/icon/:type_text/size/:size", (req, res) => { + const apply_festivals = req.query["nofest"] == null const match = (req.params["type_text"] || "")!.match("^([A-Za-z0-9]+)(?:\:([A-Za-z0-9]+))?$") if (!match) return res.status(400).send(); @@ -591,7 +592,7 @@ function server( text: text }) ) - .apply(festivals(clock)) + .apply(apply_festivals ? festivals(clock) : no_festivals) .toString() ) .then(spec.responseFormatter) diff --git a/tests/server.test.ts b/tests/server.test.ts index 17094de..be5fd08 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1479,7 +1479,7 @@ describe("server", () => { foregroundColor: "brightblue", backgroundColor: "brightpink", }) - ).get(`/icon/${type}/size/180`); + ).get(`/icon/${type}/size/180?nofest`); expect(response.status).toEqual(200); const svg = Buffer.from(response.body).toString(); @@ -1576,7 +1576,7 @@ describe("server", () => { describe("svg icon", () => { it(`should return an svg image with the text replaced`, async () => { const response = await request(server()).get( - `/icon/yyyy:${text}/size/60` + `/icon/yyyy:${text}/size/60?nofest` ); expect(response.status).toEqual(200); From e8429a2ab71e9b832517c3311910261f09b1bbe8 Mon Sep 17 00:00:00 2001 From: Simon J Date: Fri, 7 Nov 2025 11:47:02 +1100 Subject: [PATCH 05/51] Node on 22/bookworm, upgrade libs. (#231) Cannot upgrade to node 24 without looking armv7 support --- .devcontainer/Dockerfile | 2 +- .devcontainer/devcontainer.json | 6 +- Dockerfile | 4 +- package-lock.json | 5338 ++++++++++++++++++------------- package.json | 61 +- src/config.ts | 3 +- src/smapi_auth.ts | 5 +- tests/config.test.ts | 2 +- tests/i8n.test.ts | 3 +- tests/server.test.ts | 10 +- 10 files changed, 3232 insertions(+), 2202 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 5572d34..9ef951b 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-bullseye +FROM node:22-bookworm LABEL maintainer=simojenki diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f05c635..3fd52d3 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -22,9 +22,9 @@ "customizations": { "vscode": { "extensions": [ - "esbenp.prettier-vscode", - "redhat.vscode-xml" - ] + "esbenp.prettier-vscode", + "redhat.vscode-xml" + ] } } } diff --git a/Dockerfile b/Dockerfile index 6ad3552..2f67a72 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-bullseye-slim AS build +FROM node:22-bookworm-slim AS build WORKDIR /bonob @@ -36,7 +36,7 @@ RUN apt-get update && \ NODE_ENV=production npm install --omit=dev -FROM node:22-bullseye-slim +FROM node:22-bookworm-slim LABEL maintainer="simojenki" \ org.opencontainers.image.source="https://github.com/simojenki/bonob" \ diff --git a/package-lock.json b/package-lock.json index a44dfee..e8bfcfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,181 +9,103 @@ "version": "0.0.1", "license": "GPL-3.0-only", "dependencies": { - "@svrooij/sonos": "^2.6.0-beta.11", - "@types/express": "^4.17.21", + "@svrooij/sonos": "^2.6.0-beta.12", + "@types/express": "^5.0.5", "@types/fs-extra": "^11.0.4", - "@types/jsonwebtoken": "^9.0.7", - "@types/jws": "^3.2.10", - "@types/morgan": "^1.9.9", - "@types/node": "^20.11.5", + "@types/jsonwebtoken": "^9.0.10", + "@types/jws": "^3.2.11", + "@types/morgan": "^1.9.10", + "@types/node": "^24.10.0", "@types/randomstring": "^1.3.0", "@types/underscore": "^1.13.0", "@types/uuid": "^10.0.0", "@types/xmldom": "^0.1.34", "@xmldom/xmldom": "^0.9.7", - "axios": "^1.7.8", - "dayjs": "^1.11.13", + "axios": "^1.13.1", + "dayjs": "^1.11.19", "eta": "^2.2.0", - "express": "^4.18.3", - "fp-ts": "^2.16.9", - "fs-extra": "^11.2.0", + "express": "^5.1.0", + "fp-ts": "^2.16.11", + "fs-extra": "^11.3.2", "jsonwebtoken": "^9.0.2", "jws": "^4.0.0", - "morgan": "^1.10.0", - "node-html-parser": "^6.1.13", - "randomstring": "^1.3.0", - "sharp": "^0.33.5", - "soap": "^1.1.6", - "ts-md5": "^1.3.1", - "typescript": "^5.7.2", + "morgan": "^1.10.1", + "node-html-parser": "^7.0.1", + "randomstring": "^1.3.1", + "sharp": "^0.34.4", + "soap": "^1.6.0", + "ts-md5": "^2.0.1", + "typescript": "^5.9.3", "underscore": "^1.13.7", "urn-lib": "^2.0.0", - "uuid": "^11.0.3", - "winston": "^3.17.0", + "uuid": "^11.1.0", + "winston": "^3.18.3", "xmldom-ts": "^0.3.1", "xpath": "^0.0.34" }, "devDependencies": { - "@types/chai": "^5.0.1", - "@types/jest": "^29.5.14", + "@types/chai": "^5.2.3", + "@types/jest": "^30.0.0", "@types/mocha": "^10.0.10", - "@types/supertest": "^6.0.2", + "@types/supertest": "^6.0.3", "@types/tmp": "^0.2.6", - "chai": "^5.1.2", + "chai": "^6.2.0", "get-port": "^7.1.0", - "image-js": "^0.35.6", - "jest": "^29.7.0", - "nodemon": "^3.1.7", - "supertest": "^7.0.0", - "tmp": "^0.2.3", - "ts-jest": "^29.2.5", + "image-js": "^0.37.0", + "jest": "^30.2.0", + "nodemon": "^3.1.10", + "npm-check-updates": "^19.1.2", + "supertest": "^7.1.4", + "tmp": "^0.2.5", + "ts-jest": "^29.4.5", "ts-mockito": "^2.6.1", "ts-node": "^10.9.2", "xpath-ts": "^1.3.13" } }, - "node_modules/@ampproject/remapping": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.1.tgz", - "integrity": "sha512-lFMjJTrFL3j7L9yBxwYfCq2k6qqwHyzuUl/XBnif78PWTJYyL/dfowQHWE3sp6U6ZzqWiiIZnpTMO96zhkjwtg==", - "dev": true, - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.0", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/code-frame/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dev": true, - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/compat-data": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", - "integrity": "sha512-uU27kfDRlhfKl+w1U6vp16IuvSLtjAxdArVXPa9BvLkrr7CYIsxH5adpHObeAGY/41+syctUWOZ140a2Rvkgjw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.23.7.tgz", - "integrity": "sha512-+UpDgowcmqe36d4NwqvKsyPMlOLNGMsfMmQ5WGCu+siCe3t3dfe9njrzGfdN4qq+bcNUt0+Vw6haRxBOycs4dw==", - "dev": true, - "dependencies": { - "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-compilation-targets": "^7.23.6", - "@babel/helper-module-transforms": "^7.23.3", - "@babel/helpers": "^7.23.7", - "@babel/parser": "^7.23.6", - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -199,29 +121,32 @@ } }, "node_modules/@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.23.6.tgz", - "integrity": "sha512-9JB548GZoQVmzrFgp8o7KxdgkTGm6xs9DW0o/Pim72UDjzr5ObUQ6ZzYPqA+g9OTS2bBQoctLJrky0RDCAWRgQ==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.23.5", - "@babel/helper-validator-option": "^7.23.5", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -229,63 +154,40 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-environment-visitor": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-environment-visitor/-/helper-environment-visitor-7.22.20.tgz", - "integrity": "sha512-zfedSIzFhat/gFhWfHtgWvlec0nqB9YEIVrpuwjruLlXfUSnA8cJB0miHKwqDnQ7d32aKo2xt88/xZptwxbfhA==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.15" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.23.3.tgz", - "integrity": "sha512-7bBs4ED9OmswdfDzpz4MpWgSrV7FXlc3zIagvLFjS5H+Mk7Snr21vQ6QwrsoCGMfNC4e4LQPdoULEt4ykz0SRQ==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-simple-access": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/helper-validator-identifier": "^7.22.20" + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -295,169 +197,68 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.22.5.tgz", - "integrity": "sha512-uLls06UVKgFG9QD4OeFYLEGteMIAa5kpTPcFL28yuCIIzsf6ZyKZMllKVOCZFhiZ5ptnwX4mtKdWCBE/uT4amg==", - "dev": true, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-simple-access": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.22.5.tgz", - "integrity": "sha512-n0H99E/K+Bika3++WNL17POvo4rKWZ7lZEp1Q+fStVbUi8nxPQEBOlTmCOxW/0JsS56SKKQ+ojAe2pHKJHN35w==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-split-export-declaration": { - "version": "7.22.6", - "resolved": "https://registry.npmjs.org/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.22.6.tgz", - "integrity": "sha512-AsUnxuLhRYsisFiaJwvp1QF+I3KjD5FOxut14q/GzovUe6orHLesW2C7d754kRm53h5gqrz6sFl6sxc4BVtE/g==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.23.5.tgz", - "integrity": "sha512-85ttAOMLsr53VgXkTbkx8oA6YTfT4q7/HzXSLEYmjcSTJPMPQtvq1BD79Byep5xMUYbGRzEpDsjUf3dyp54IKw==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.23.8.tgz", - "integrity": "sha512-KDqYz4PiOWvDFrdHLPhKtCThtIcKVy6avWD2oG4GEvyQ+XDZwHD4YQd+H2vNMnq2rkdxsDkU82T+Vk8U/WXHRQ==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/traverse": "^7.23.7", - "@babel/types": "^7.23.6" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dev": true, - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dev": true, - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dev": true, - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", - "dev": true - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "dev": true, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/@babel/parser": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "dev": true, + "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "@babel/types": "^7.28.5" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/parser": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.6.tgz", - "integrity": "sha512-Z2uID7YJ7oNvAI20O9X0bblw7Qqs8Q2hFy0R9tAfnfLkp5MW0UH9eUvnDSnFwKZ0AvgS1ucqR4KzvVHgnke1VQ==", - "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -470,6 +271,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -482,6 +284,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-bigint/-/plugin-syntax-bigint-7.8.3.tgz", "integrity": "sha512-wnTnFlG+YxQm3vDxpGE57Pj0srRU4sHE/mDkt1qv2YJJSeUAec2ma4WLUnUPeKjyrfntVwe/N6dCXpU+zL3Npg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -494,6 +297,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, @@ -501,11 +305,44 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-class-static-block": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", + "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.27.1.tgz", + "integrity": "sha512-oFT0FrKHgF53f4vOsZGi2Hh3I35PfSmVs4IBFLFj4dnafP+hIWDLg3VyKmUHfLoLHlyxY4C7DGtmHuJgn+IGww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-import-meta": { "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -518,6 +355,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -526,12 +364,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", - "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -545,6 +384,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -557,6 +397,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -569,6 +410,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -581,6 +423,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz", "integrity": "sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -593,6 +436,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -605,6 +449,7 @@ "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -612,11 +457,28 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/plugin-syntax-private-property-in-object": { + "version": "7.14.5", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", + "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.14.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, "node_modules/@babel/plugin-syntax-top-level-await": { "version": "7.14.5", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", "dev": true, + "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -628,12 +490,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.23.3.tgz", - "integrity": "sha512-9EiNjVJOMwCO+43TqoTrgQ8jMwcAd0sWyXi9RPfIsLTj4R2MADDDQXELhffaUx/uJv2AYcxBgPwH6j4TIA4ytQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", + "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -643,61 +506,58 @@ } }, "node_modules/@babel/runtime": { - "version": "7.23.8", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.23.8.tgz", - "integrity": "sha512-Y7KbAP984rn1VGMbGqKmBLio9V7y5Je9GvU4rQPCPinCyNfUcToxIXl06d59URp/F3LwinvODxab5N/G6qggkw==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "dev": true, - "dependencies": { - "regenerator-runtime": "^0.14.0" - }, + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/template": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.22.15.tgz", - "integrity": "sha512-QPErUVm4uyJa60rkI73qneDacvdvzxshT3kksGqlGWYdOTIUOwJ7RDUL8sGqslY1uXWSL6xMFKEXDS3ox2uF0w==", + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/parser": "^7.22.15", - "@babel/types": "^7.22.15" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.7.tgz", - "integrity": "sha512-tY3mM8rH9jM0YHFGyfC0/xf+SB5eKUu7HPj7/k3fpi9dAlsMc5YbQvDi0Sh2QTPXqMhyaAtzAr807TIyfQrmyg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.6", - "@babel/types": "^7.23.6", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.5", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/types": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.6.tgz", - "integrity": "sha512-+uarb83brBzPKN38NX1MkB6vb6+mwvR6amUulqAE7ccQw1pEl+bCia9TbdG1lsnFP7lZySvUn37CHyXQdfTwzg==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -707,7 +567,8 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@colors/colors": { "version": "1.6.0", @@ -741,29 +602,62 @@ } }, "node_modules/@dabh/diagnostics": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.3.tgz", - "integrity": "sha512-hrlQOIi7hAfzsMqlGSFyVucrx38O+j6wiGOf//H2ecvIEqYN4ADBSS2iLMh5UFyDunCNniUIPk/q3riFv45xRA==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@dabh/diagnostics/-/diagnostics-2.0.8.tgz", + "integrity": "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==", + "license": "MIT", "dependencies": { - "colorspace": "1.1.x", + "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, + "node_modules/@emnapi/core": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz", + "integrity": "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, "node_modules/@emnapi/runtime": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.3.1.tgz", - "integrity": "sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz", + "integrity": "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", + "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { "tslib": "^2.4.0" } }, + "node_modules/@img/colour": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz", + "integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz", - "integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", + "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", "cpu": [ "arm64" ], @@ -779,13 +673,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.3" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz", - "integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", + "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", "cpu": [ "x64" ], @@ -801,13 +695,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.3" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz", - "integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", + "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", "cpu": [ "arm64" ], @@ -821,9 +715,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz", - "integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", + "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", "cpu": [ "x64" ], @@ -837,9 +731,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz", - "integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", + "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", "cpu": [ "arm" ], @@ -853,9 +747,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz", - "integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", + "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", "cpu": [ "arm64" ], @@ -868,12 +762,12 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz", - "integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==", + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", + "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", "cpu": [ - "s390x" + "ppc64" ], "license": "LGPL-3.0-or-later", "optional": true, @@ -884,12 +778,12 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz", - "integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==", + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", + "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", "cpu": [ - "x64" + "s390x" ], "license": "LGPL-3.0-or-later", "optional": true, @@ -900,12 +794,28 @@ "url": "https://opencollective.com/libvips" } }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz", - "integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==", + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", + "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", "cpu": [ - "arm64" + "x64" + ], + "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.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", + "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "cpu": [ + "arm64" ], "license": "LGPL-3.0-or-later", "optional": true, @@ -917,9 +827,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz", - "integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==", + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", + "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", "cpu": [ "x64" ], @@ -933,9 +843,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz", - "integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", + "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", "cpu": [ "arm" ], @@ -951,13 +861,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.3" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz", - "integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", + "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", "cpu": [ "arm64" ], @@ -973,13 +883,35 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.2.3" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", + "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.3" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz", - "integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", + "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", "cpu": [ "s390x" ], @@ -995,13 +927,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.2.3" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz", - "integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", + "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", "cpu": [ "x64" ], @@ -1017,13 +949,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.3" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz", - "integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", + "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", "cpu": [ "arm64" ], @@ -1039,13 +971,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz", - "integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", + "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", "cpu": [ "x64" ], @@ -1061,20 +993,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.3" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz", - "integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", + "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.2.0" + "@emnapi/runtime": "^1.5.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -1083,10 +1015,29 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", + "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz", - "integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", + "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", "cpu": [ "ia32" ], @@ -1103,9 +1054,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz", - "integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", + "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", "cpu": [ "x64" ], @@ -1121,11 +1072,30 @@ "url": "https://opencollective.com/libvips" } }, + "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/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "integrity": "sha512-VjeHSlIzpv/NyD3N0YuHfXOPDIixcA1q2ZV98wsMqcYlPmv2n3Yb2lYP9XMElnaFVXg5A7YLTeLu6V84uQDjmQ==", "dev": true, + "license": "ISC", "dependencies": { "camelcase": "^5.3.1", "find-up": "^4.1.0", @@ -1147,59 +1117,61 @@ } }, "node_modules/@jest/console": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", - "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", + "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", + "chalk": "^4.1.2", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/core": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", - "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", + "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/reporters": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/console": "30.2.0", + "@jest/pattern": "30.0.1", + "@jest/reporters": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-changed-files": "^29.7.0", - "jest-config": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-resolve-dependencies": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "jest-watcher": "^29.7.0", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", - "slash": "^3.0.0", - "strip-ansi": "^6.0.0" + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-changed-files": "30.2.0", + "jest-config": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-resolve-dependencies": "30.2.0", + "jest-runner": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "jest-watcher": "30.2.0", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", + "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -1210,111 +1182,150 @@ } } }, + "node_modules/@jest/diff-sequences": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", + "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/environment": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", - "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", + "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "^29.7.0" + "jest-mock": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", "dev": true, + "license": "MIT", "dependencies": { - "expect": "^29.7.0", - "jest-snapshot": "^29.7.0" + "expect": "30.2.0", + "jest-snapshot": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", - "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", + "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", "dev": true, + "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3" + "@jest/get-type": "30.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/fake-timers": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", - "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", + "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", + "@jest/types": "30.2.0", + "@sinonjs/fake-timers": "^13.0.0", "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/get-type": { + "version": "30.1.0", + "resolved": "https://registry.npmjs.org/@jest/get-type/-/get-type-30.1.0.tgz", + "integrity": "sha512-eMbZE2hUnx1WV0pmURZY9XoXPkUYjpc55mb0CrhtdWLtzMQPFvu/rZkTLZFTsdaVQa+Tr4eWAteqcUzoawq/uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/globals": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", - "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", + "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/types": "30.2.0", + "jest-mock": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/pattern": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/pattern/-/pattern-30.0.1.tgz", + "integrity": "sha512-gWp7NfQW27LaBQz3TITS8L7ZCQ0TLvtmI//4OwlQRx4rnWxcPNIYjxZpDcN4+UlGxgm3jS5QPz8IPTCkb59wZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "jest-regex-util": "30.0.1" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", + "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", "dev": true, + "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", + "@jest/console": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", - "chalk": "^4.0.0", - "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", + "chalk": "^4.1.2", + "collect-v8-coverage": "^1.0.2", + "exit-x": "^0.2.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", - "istanbul-lib-source-maps": "^4.0.0", + "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", "slash": "^3.0.0", - "string-length": "^4.0.1", - "strip-ansi": "^6.0.0", + "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -1326,116 +1337,147 @@ } }, "node_modules/@jest/schemas": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", - "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "version": "30.0.5", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-30.0.5.tgz", + "integrity": "sha512-DmdYgtezMkh3cpU8/1uyXakv3tJRcmcXxBOcO0tbaozPwpmh4YMsnWrQm9ZmZMfa5ocbxzbFk6O4bDPEc/iAnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@sinclair/typebox": "^0.34.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/@jest/snapshot-utils": { + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", + "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", "dev": true, + "license": "MIT", "dependencies": { - "@sinclair/typebox": "^0.27.8" + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "natural-compare": "^1.4.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/source-map": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", - "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-30.0.1.tgz", + "integrity": "sha512-MIRWMUUR3sdbP36oyNyhbThLHyJ2eEDClPCiHVbrYAe5g3CHRArIVpBw7cdSB5fr+ofSfIb2Tnsw8iEHL0PYQg==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/trace-mapping": "^0.3.18", - "callsites": "^3.0.0", - "graceful-fs": "^4.2.9" + "@jridgewell/trace-mapping": "^0.3.25", + "callsites": "^3.1.0", + "graceful-fs": "^4.2.11" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/test-result": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", - "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", + "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "@jest/console": "30.2.0", + "@jest/types": "30.2.0", + "@types/istanbul-lib-coverage": "^2.0.6", + "collect-v8-coverage": "^1.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/test-sequencer": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", - "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", + "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", + "@jest/test-result": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/transform": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", - "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", + "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/core": "^7.11.6", - "@jest/types": "^29.6.3", - "@jridgewell/trace-mapping": "^0.3.18", - "babel-plugin-istanbul": "^6.1.1", - "chalk": "^4.0.0", + "@babel/core": "^7.27.4", + "@jest/types": "30.2.0", + "@jridgewell/trace-mapping": "^0.3.25", + "babel-plugin-istanbul": "^7.0.1", + "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "micromatch": "^4.0.4", - "pirates": "^4.0.4", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "micromatch": "^4.0.8", + "pirates": "^4.0.7", "slash": "^3.0.0", - "write-file-atomic": "^4.0.2" + "write-file-atomic": "^5.0.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/types": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", - "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", + "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "@types/istanbul-reports": "^3.0.0", + "@jest/pattern": "30.0.1", + "@jest/schemas": "30.0.5", + "@types/istanbul-lib-coverage": "^2.0.6", + "@types/istanbul-reports": "^3.0.4", "@types/node": "*", - "@types/yargs": "^17.0.8", - "chalk": "^4.0.0" + "@types/yargs": "^17.0.33", + "chalk": "^4.1.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "dev": true, + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" } }, "node_modules/@jridgewell/resolve-uri": { @@ -1447,75 +1489,157 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "dev": true, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==", - "dev": true + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", - "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@paralleldrive/cuid2": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@paralleldrive/cuid2/-/cuid2-2.3.1.tgz", + "integrity": "sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.1.5" + } + }, + "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/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, "node_modules/@rgrove/parse-xml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@rgrove/parse-xml/-/parse-xml-4.1.0.tgz", - "integrity": "sha512-pBiltENdy8SfI0AeR1e5TRpS9/9Gl0eiOEt6ful2jQfzsgvZYWqsKiBWaOCLdocQuk0wS7KOHI37n0C1pnKqTw==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@rgrove/parse-xml/-/parse-xml-4.2.0.tgz", + "integrity": "sha512-UuBOt7BOsKVOkFXRe4Ypd/lADuNIfqJXv8GvHqtXaTYXPPKkj2nS2zPllVsrtRjcomDhIJVBnZwfmlI222WH8g==", + "license": "ISC", "engines": { "node": ">=14.0.0" } }, "node_modules/@sinclair/typebox": { - "version": "0.27.8", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", - "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", - "dev": true + "version": "0.34.41", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.41.tgz", + "integrity": "sha512-6gS8pZzSXdyRHTIqoqSVknxolr1kzfy4/CeDnrzsVz8TTIWUbOBr6gnzOmTYJ3eXQNh4IYHIGi5aIL7sOZ2G/g==", + "dev": true, + "license": "MIT" }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } }, "node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^3.0.0" + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/@so-ric/colorspace": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@so-ric/colorspace/-/colorspace-1.1.6.tgz", + "integrity": "sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==", + "license": "MIT", + "dependencies": { + "color": "^5.0.2", + "text-hex": "1.0.x" } }, "node_modules/@svrooij/sonos": { - "version": "2.6.0-beta.11", - "resolved": "https://registry.npmjs.org/@svrooij/sonos/-/sonos-2.6.0-beta.11.tgz", - "integrity": "sha512-6fmPLRu11OQWFIw2CYyejbo8jJxs59Fs/V9mRqWxRFQEq5gyz0r1deBboVGU3hUuctP6JdwwbpYcsQHj+U7AtQ==", + "version": "2.6.0-beta.12", + "resolved": "https://registry.npmjs.org/@svrooij/sonos/-/sonos-2.6.0-beta.12.tgz", + "integrity": "sha512-cbHeTWjlK3yOiJYrYVDrzgdmdoCz0W6uoUkgZ6Wk/IGj8sZ5XtFl62PIwc//A3TK4SF3E6p4Bs6z28db3mg6xg==", "license": "MIT", "dependencies": { - "@rgrove/parse-xml": "^4.1.0", - "debug": "4.3.4", - "html-entities": "^2.4.0", - "node-fetch": "^2.6.7", + "@rgrove/parse-xml": "^4.2.0", + "debug": "4.4.0", + "html-entities": "^2.5.2", + "node-fetch": "^2.7.0", "typed-emitter": "^2.1.0", - "ws": "^8.12.6" + "ws": "^8.18.1" + } + }, + "node_modules/@svrooij/sonos/node_modules/debug": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", + "integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/@swiftcarrot/color-fns": { @@ -1523,6 +1647,7 @@ "resolved": "https://registry.npmjs.org/@swiftcarrot/color-fns/-/color-fns-3.2.0.tgz", "integrity": "sha512-6SCpc4LwmGGqWHpBY9WaBzJwPF4nfgvFfejOX7Ub0kTehJysFkLUAvGID8zEx39n0pGlfr9pTiQE/7/buC7X5w==", "dev": true, + "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.3" } @@ -1551,11 +1676,23 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", "dev": true, + "license": "MIT", "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", @@ -1565,10 +1702,11 @@ } }, "node_modules/@types/babel__generator": { - "version": "7.6.8", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.6.8.tgz", - "integrity": "sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==", + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/types": "^7.0.0" } @@ -1578,18 +1716,20 @@ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", "dev": true, + "license": "MIT", "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "node_modules/@types/babel__traverse": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.5.tgz", - "integrity": "sha512-WXCyOcRtH37HAUkpXhUduaxdm82b4GSlyTqajXviN4EfiuPgNYR109xMCKvpl6zPIpua0DGlMEDCq+g8EdoheQ==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.20.7" + "@babel/types": "^7.28.2" } }, "node_modules/@types/body-parser": { @@ -1602,13 +1742,14 @@ } }, "node_modules/@types/chai": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.0.1.tgz", - "integrity": "sha512-5T8ajsg3M/FOncpLYW7sdOcD6yf4+722sze/tc4KQV0P8Z2rAr3SAuHCIkYmYpt8VbcQlnz8SxlOlPQYefe4cA==", + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", "dev": true, "license": "MIT", "dependencies": { - "@types/deep-eql": "*" + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" } }, "node_modules/@types/connect": { @@ -1633,21 +1774,20 @@ "license": "MIT" }, "node_modules/@types/express": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", + "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", "license": "MIT", "dependencies": { "@types/body-parser": "*", - "@types/express-serve-static-core": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^1" } }, "node_modules/@types/express-serve-static-core": { - "version": "4.19.6", - "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", - "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.0.tgz", + "integrity": "sha512-jnHMsrd0Mwa9Cf4IdOzbz543y4XJepXrbia2T4b6+spXC2We3t1y6K44D3mR8XMFSXMCf3/l7rCgddfx7UNVBA==", "license": "MIT", "dependencies": { "@types/node": "*", @@ -1660,20 +1800,12 @@ "version": "11.0.4", "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", + "license": "MIT", "dependencies": { "@types/jsonfile": "*", "@types/node": "*" } }, - "node_modules/@types/graceful-fs": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", - "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/http-errors": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", @@ -1704,14 +1836,14 @@ } }, "node_modules/@types/jest": { - "version": "29.5.14", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", - "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-30.0.0.tgz", + "integrity": "sha512-XTYugzhuwqWjws0CVz8QpM36+T+Dz5mTEBKhNs/esGLnCIlGdRy+Dq78NRjd7ls7r8BC8ZRMOrKlkO1hU0JOwA==", "dev": true, "license": "MIT", "dependencies": { - "expect": "^29.0.0", - "pretty-format": "^29.0.0" + "expect": "^30.0.0", + "pretty-format": "^30.0.0" } }, "node_modules/@types/jsonfile": { @@ -1723,18 +1855,19 @@ } }, "node_modules/@types/jsonwebtoken": { - "version": "9.0.7", - "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.7.tgz", - "integrity": "sha512-ugo316mmTYBl2g81zDFnZ7cfxlut3o+/EQdaP7J8QN2kY6lJ22hmQYCK5EHcJHbrW+dkCGSCPgbG8JtYj6qSrg==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", "license": "MIT", "dependencies": { + "@types/ms": "*", "@types/node": "*" } }, "node_modules/@types/jws": { - "version": "3.2.10", - "resolved": "https://registry.npmjs.org/@types/jws/-/jws-3.2.10.tgz", - "integrity": "sha512-cOevhttJmssERB88/+XvZXvsq5m9JLKZNUiGfgjUb5lcPRdV2ZQciU6dU76D/qXXFYpSqkP3PrSg4hMTiafTZw==", + "version": "3.2.11", + "resolved": "https://registry.npmjs.org/@types/jws/-/jws-3.2.11.tgz", + "integrity": "sha512-OOaTrLV6XdF1XvBgMeH1MjNuOaGCrRZWNSIds1AQaRgLdOWlAk2yMsfrJn+ekLgUow3xksWIM231lyFab7mHHw==", "license": "MIT", "dependencies": { "@types/node": "*" @@ -1759,31 +1892,41 @@ "license": "MIT" }, "node_modules/@types/morgan": { - "version": "1.9.9", - "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.9.tgz", - "integrity": "sha512-iRYSDKVaC6FkGSpEVVIvrRGw0DfJMiQzIn3qr2G5B3C//AWkulhXgaBd7tS9/J79GWSYMTHGs7PfI5b3Y8m+RQ==", + "version": "1.9.10", + "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", + "integrity": "sha512-sS4A1zheMvsADRVfT0lYbJ4S9lmsey8Zo2F7cnbYjWHP67Q0AwMYuuzLlkIM2N8gAbb9cubhIVFwcIN2XyYCkA==", + "license": "MIT", "dependencies": { "@types/node": "*" } }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, "node_modules/@types/node": { - "version": "20.11.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.5.tgz", - "integrity": "sha512-g557vgQjUUfN76MZAN/dt1z3dzcUsimuysco0KeluHgrPdJXkP/XdAURgyO2W9fZWHRtRBiVKzKn8vyOAwlG+w==", + "version": "24.10.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", + "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "license": "MIT", + "peer": true, "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~7.16.0" } }, "node_modules/@types/pako": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.3.tgz", - "integrity": "sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q==", - "dev": true + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "dev": true, + "license": "MIT" }, "node_modules/@types/qs": { - "version": "6.9.17", - "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.17.tgz", - "integrity": "sha512-rX4/bPcfmvxHDv0XjfJELTTr+iB+tn032nPILqHm5wbthUUUuVtNGGqzhya9XUxjTP8Fpr0qYgSZZKxGY++svQ==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "license": "MIT" }, "node_modules/@types/randomstring": { @@ -1799,12 +1942,11 @@ "license": "MIT" }, "node_modules/@types/send": { - "version": "0.17.4", - "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.4.tgz", - "integrity": "sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", "license": "MIT", "dependencies": { - "@types/mime": "^1", "@types/node": "*" } }, @@ -1822,7 +1964,8 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@types/superagent": { "version": "8.1.2", @@ -1836,10 +1979,11 @@ } }, "node_modules/@types/supertest": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.2.tgz", - "integrity": "sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@types/supertest/-/supertest-6.0.3.tgz", + "integrity": "sha512-8WzXq62EXFhJ7QsH3Ocb/iKQ/Ty9ZVWnVzoTKc9tyyFRRF3a74Tk2+TLFgaFFw364Ere+npzHKEJ6ga2LzIL7w==", "dev": true, + "license": "MIT", "dependencies": { "@types/methods": "^1.1.4", "@types/superagent": "^8.1.0" @@ -1875,20 +2019,298 @@ "integrity": "sha512-7eZFfxI9XHYjJJuugddV6N5YNeXgQE1lArWOcd1eCOKWb/FGs5SIjacSYuEJuwhsGS3gy4RuZ5EUIcqYscuPDA==", "license": "MIT" }, - "node_modules/@types/yargs": { - "version": "17.0.32", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz", - "integrity": "sha512-xQ67Yc/laOG5uMfX/093MRlGGCIBzZMarVa+gfNKJxWAIgykYpVGkBdbqEzGDDfCrVUj6Hiff4mTZ5BA6TmAog==", + "node_modules/@types/yargs": { + "version": "17.0.34", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.34.tgz", + "integrity": "sha512-KExbHVa92aJpw9WDQvzBaGVE2/Pz+pLZQloT2hjL8IqsZnV62rlPOYvNnLmf/L2dyllfVUOVBj64M0z/46eR2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/yargs-parser": "*" + } + }, + "node_modules/@types/yargs-parser": { + "version": "21.0.3", + "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", + "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "dev": true, + "license": "ISC" + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], "dev": true, + "license": "MIT", + "optional": true, "dependencies": { - "@types/yargs-parser": "*" + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" } }, - "node_modules/@types/yargs-parser": { - "version": "21.0.3", - "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", - "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==", - "dev": true + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] }, "node_modules/@xmldom/is-dom-node": { "version": "1.0.1", @@ -1915,12 +2337,34 @@ "dev": true }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-db": "^1.54.0" }, "engines": { "node": ">= 0.6" @@ -1952,6 +2396,7 @@ "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" }, @@ -1963,12 +2408,16 @@ } }, "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", "dev": true, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, "node_modules/ansi-styles": { @@ -2010,15 +2459,11 @@ "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", "dev": true, + "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -2044,13 +2489,13 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.7.8", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.8.tgz", - "integrity": "sha512-Uu0wb7KNqK2t5K+YQyVCLM76prD5sRFjKHbJYCP1J7JFGEQ6nN7HWn9+04LAeiJ3ji54lgS/gZCH1oxyrf1SPw==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", + "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -2066,110 +2511,102 @@ } }, "node_modules/babel-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", - "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", + "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/transform": "^29.7.0", - "@types/babel__core": "^7.1.14", - "babel-plugin-istanbul": "^6.1.1", - "babel-preset-jest": "^29.6.3", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", + "@jest/transform": "30.2.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.2.0", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", "slash": "^3.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.8.0" + "@babel/core": "^7.11.0 || ^8.0.0-0" } }, "node_modules/babel-plugin-istanbul": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", - "integrity": "sha512-Y1IQok9821cC9onCx5otgFfRm7Lm+I+wwxOx738M/WLPZ9Q42m4IG5W0FNX8WLL2gYMZo3JkuXIH2DOpWM+qwA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-7.0.1.tgz", + "integrity": "sha512-D8Z6Qm8jCvVXtIRkBnqNHX0zJ37rQcFJ9u8WOS6tkYOsRdHBzypCstaxWiu5ZIlqQtviRYbgnRLSoCEvjqcqbA==", "dev": true, + "license": "BSD-3-Clause", + "workspaces": [ + "test/babel-8" + ], "dependencies": { "@babel/helper-plugin-utils": "^7.0.0", "@istanbuljs/load-nyc-config": "^1.0.0", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-instrument": "^5.0.4", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-instrument": "^6.0.2", "test-exclude": "^6.0.0" }, "engines": { - "node": ">=8" - } - }, - "node_modules/babel-plugin-istanbul/node_modules/istanbul-lib-instrument": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-5.2.1.tgz", - "integrity": "sha512-pzqtp31nLv/XFOzXGuvhCb8qhjmTVo5vjVk19XE4CRlSWz0KoeJ3bw9XsA7nOp9YBf4qHjwBxkDzKcME/J29Yg==", - "dev": true, - "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.2.0", - "semver": "^6.3.0" - }, - "engines": { - "node": ">=8" + "node": ">=12" } }, "node_modules/babel-plugin-jest-hoist": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", - "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", + "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/template": "^7.3.3", - "@babel/types": "^7.3.3", - "@types/babel__core": "^7.1.14", - "@types/babel__traverse": "^7.0.6" + "@types/babel__core": "^7.20.5" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/babel-preset-current-node-syntax": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.0.1.tgz", - "integrity": "sha512-M7LQ0bxarkxQoN+vz5aJPsLBn77n8QgTFmo8WK0/44auK2xlCXrYcUxHFxgU7qW5Yzw/CjmLRK2uJzaCd7LvqQ==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-current-node-syntax/-/babel-preset-current-node-syntax-1.2.0.tgz", + "integrity": "sha512-E/VlAEzRrsLEb2+dv8yp3bo4scof3l9nR4lrld+Iy5NyVqgVYUJnDAmunkhPMisRI32Qc4iRiz425d8vM++2fg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/plugin-syntax-async-generators": "^7.8.4", "@babel/plugin-syntax-bigint": "^7.8.3", - "@babel/plugin-syntax-class-properties": "^7.8.3", - "@babel/plugin-syntax-import-meta": "^7.8.3", + "@babel/plugin-syntax-class-properties": "^7.12.13", + "@babel/plugin-syntax-class-static-block": "^7.14.5", + "@babel/plugin-syntax-import-attributes": "^7.24.7", + "@babel/plugin-syntax-import-meta": "^7.10.4", "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.8.3", + "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.8.3", + "@babel/plugin-syntax-numeric-separator": "^7.10.4", "@babel/plugin-syntax-object-rest-spread": "^7.8.3", "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-top-level-await": "^7.8.3" + "@babel/plugin-syntax-private-property-in-object": "^7.14.5", + "@babel/plugin-syntax-top-level-await": "^7.14.5" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.0.0 || ^8.0.0-0" } }, "node_modules/babel-preset-jest": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", - "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", + "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", "dev": true, + "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" + "babel-plugin-jest-hoist": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.11.0 || ^8.0.0-beta.1" } }, "node_modules/balanced-match": { @@ -2178,6 +2615,16 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.23", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.23.tgz", + "integrity": "sha512-616V5YX4bepJFzNyOfce5Fa8fDJMfoxzOIzDCZwaGL8MKVpFrXqfNUoIpRn9YMI5pXf/VKgzjB4htFMsFKKdiQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -2207,78 +2654,62 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", - "dev": true + "dev": true, + "license": "Apache-2.0" }, "node_modules/body-parser": { - "version": "1.20.3", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", - "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", + "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", - "type-is": "~1.6.18", - "unpipe": "1.0.0" + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.0", + "http-errors": "^2.0.0", + "iconv-lite": "^0.6.3", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.0", + "type-is": "^2.0.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, - "node_modules/body-parser/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "node": ">=18" } }, - "node_modules/body-parser/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", "dev": true, + "license": "MIT", "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" } }, "node_modules/browserslist": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.22.2.tgz", - "integrity": "sha512-0UgcrvQmBDvZHFGdYUehrCNIazki7/lUP3kkoi/r3YB2amZbFM9J43ZRkJTXBUZK4gmx56+Sqk9+Vs9mwZx9+A==", + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", "dev": true, "funding": [ { @@ -2294,11 +2725,14 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", + "peer": true, "dependencies": { - "caniuse-lite": "^1.0.30001565", - "electron-to-chromium": "^1.4.601", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" @@ -2337,7 +2771,8 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/bytes": { "version": "3.1.2", @@ -2348,17 +2783,27 @@ "node": ">= 0.8" } }, - "node_modules/call-bind": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.7.tgz", - "integrity": "sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==", + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", "license": "MIT", "dependencies": { - "es-define-property": "^1.0.0", "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "set-function-length": "^1.2.1" + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" }, "engines": { "node": ">= 0.4" @@ -2372,6 +2817,7 @@ "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -2381,14 +2827,15 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001579", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001579.tgz", - "integrity": "sha512-u5AUVkixruKHJjw/pj9wISlcMpgFWzSrczLZbrqBSxukQixmg0SJ5sZTpvaFvxU0HoQKd4yoyAogyrAz9pzJnA==", + "version": "1.0.30001753", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001753.tgz", + "integrity": "sha512-Bj5H35MD/ebaOV4iDLqPEtiliTN29qkGtEHCwawWn4cYm+bPJM2NsaP30vtZcnERClMzp52J4+aw2UNbK4o+zw==", "dev": true, "funding": [ { @@ -2403,29 +2850,24 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/canny-edge-detector": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/canny-edge-detector/-/canny-edge-detector-1.0.0.tgz", "integrity": "sha512-SpewmkHDE1PbJ1/AVAcpvZKOufYpUXT0euMvhb5C4Q83Q9XEOmSXC+yR7jl3F4Ae1Ev6OtQKbFgdcPrOdHjzQg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/chai": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", - "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", + "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", "dev": true, "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/chalk": { @@ -2449,19 +2891,17 @@ "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } }, - "node_modules/check-error": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.1.tgz", - "integrity": "sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==", + "node_modules/cheminfo-types": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/cheminfo-types/-/cheminfo-types-1.8.1.tgz", + "integrity": "sha512-FRcpVkox+cRovffgqNdDFQ1eUav+i/Vq/CUd1hcfEl2bevntFlzznL+jE8g4twl6ElB7gZjCko6pYpXyMn+6dA==", "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } + "license": "MIT" }, "node_modules/chokidar": { "version": "3.5.3", @@ -2491,9 +2931,9 @@ } }, "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz", + "integrity": "sha512-Wdy2Igu8OcBpI2pZePZ5oWjPC38tmDVx5WKUXKwlLYkA0ozo85sLsLvkBbBn/sZaSCMFOGZJ14fvW9t5/d7kdA==", "dev": true, "funding": [ { @@ -2501,21 +2941,24 @@ "url": "https://github.com/sponsors/sibiraj-s" } ], + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/cjs-module-lexer": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", - "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", - "dev": true + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", + "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", + "dev": true, + "license": "MIT" }, "node_modules/cliui": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", @@ -2525,38 +2968,105 @@ "node": ">=12" } }, + "node_modules/cliui/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/cliui/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/cliui/node_modules/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/cliui/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/cliui/node_modules/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/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true, + "license": "MIT", "engines": { "iojs": ">= 1.0.0", "node": ">= 0.12.0" } }, "node_modules/collect-v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", - "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.3.tgz", + "integrity": "sha512-1L5aqIkwPfiodaMgQunkF1zRhNqifHBmtbbbxcr6yVxxBnliw4TDOW6NxpO8DJLgJ16OT+Y4ztZqP6p/FtXnAw==", + "dev": true, + "license": "MIT" }, "node_modules/color": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", - "integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==", + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/color/-/color-5.0.2.tgz", + "integrity": "sha512-e2hz5BzbUPcYlIRHo8ieAhYgoajrJr+hWoceg6E345TPsATMUKqDgzt8fSXZJJbxfpiPzkWyphz8yn8At7q3fA==", + "license": "MIT", "dependencies": { - "color-convert": "^2.0.1", - "color-string": "^1.9.0" + "color-convert": "^3.0.1", + "color-string": "^2.0.0" }, "engines": { - "node": ">=12.5.0" + "node": ">=18" } }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, "dependencies": { "color-name": "~1.1.4" }, @@ -2567,47 +3077,50 @@ "node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true }, "node_modules/color-string": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz", - "integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==", - "dependencies": { - "color-name": "^1.0.0", - "simple-swizzle": "^0.2.2" - } - }, - "node_modules/colorspace": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", - "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/color-string/-/color-string-2.1.2.tgz", + "integrity": "sha512-RxmjYxbWemV9gKu4zPgiZagUxbH3RQpEIO77XoSSX0ivgABDZ+h8Zuash/EMFLTI4N9QgFPOJ6JQpPZKFxa+dA==", + "license": "MIT", "dependencies": { - "color": "^3.1.3", - "text-hex": "1.0.x" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" } }, - "node_modules/colorspace/node_modules/color": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", - "dependencies": { - "color-convert": "^1.9.3", - "color-string": "^1.6.0" + "node_modules/color-string/node_modules/color-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.0.2.tgz", + "integrity": "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==", + "license": "MIT", + "engines": { + "node": ">=12.20" } }, - "node_modules/colorspace/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "node_modules/color/node_modules/color-convert": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-3.1.2.tgz", + "integrity": "sha512-UNqkvCDXstVck3kdowtOTWROIJQwafjOfXSmddoDrXo4cewMKmusCeF22Q24zvjR8nwWib/3S/dfyzPItPEiJg==", + "license": "MIT", "dependencies": { - "color-name": "1.1.3" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" } }, - "node_modules/colorspace/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" + "node_modules/color/node_modules/color-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-2.0.2.tgz", + "integrity": "sha512-9vEt7gE16EW7Eu7pvZnR0abW9z6ufzhXxGXZEVU9IqPdlsUiMwJeJfRtq0zePUmnbHGT9zajca7mX8zgoayo4A==", + "license": "MIT", + "engines": { + "node": ">=12.20" + } }, "node_modules/combined-stream": { "version": "1.0.8", @@ -2637,9 +3150,10 @@ "dev": true }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.0.tgz", + "integrity": "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg==", + "license": "MIT", "dependencies": { "safe-buffer": "5.2.1" }, @@ -2672,9 +3186,13 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz", - "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/cookiejar": { "version": "2.1.4", @@ -2683,27 +3201,6 @@ "dev": true, "license": "MIT" }, - "node_modules/create-jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", - "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", - "dev": true, - "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "exit": "^0.1.2", - "graceful-fs": "^4.2.9", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "prompts": "^2.0.1" - }, - "bin": { - "create-jest": "bin/create-jest.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/create-require": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", @@ -2711,10 +3208,11 @@ "dev": true }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -2751,17 +3249,18 @@ } }, "node_modules/dayjs": { - "version": "1.11.13", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", - "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", "license": "MIT" }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -2773,10 +3272,11 @@ } }, "node_modules/dedent": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz", - "integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==", + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", + "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", "dev": true, + "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, @@ -2786,39 +3286,14 @@ } } }, - "node_modules/deep-eql": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.1.tgz", - "integrity": "sha512-nwQCf6ne2gez3o1MxWifqkciwt0zhl0LO1/UwVu4uMBuPmflWM4oQ70XMqHqnBJA+nhzncaqL9HVL6KkHJ28lw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/define-data-property": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0", - "es-errors": "^1.3.0", - "gopd": "^1.0.1" - }, "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">=0.10.0" } }, "node_modules/delayed-stream": { @@ -2846,20 +3321,10 @@ "minimalistic-assert": "^1.0.0" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.3.tgz", - "integrity": "sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2870,6 +3335,7 @@ "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -2897,15 +3363,6 @@ "node": ">=0.3.1" } }, - "node_modules/diff-sequences": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", - "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, "node_modules/dom-serializer": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", @@ -2957,6 +3414,27 @@ "url": "https://github.com/fb55/domutils?sponsor=1" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "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", @@ -2970,33 +3448,19 @@ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" }, - "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.4.643", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.643.tgz", - "integrity": "sha512-QHscvvS7gt155PtoRC0dR2ilhL8E9LHhfTQEq1uD5AL0524rBLAwpAREFH06f87/e45B9XkR6Ki5dbhbCsVEIg==", - "dev": true + "version": "1.5.244", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.244.tgz", + "integrity": "sha512-OszpBN7xZX4vWMPJwB9illkN/znA8M36GQqQxi6MNy9axWxhOfJyZZJtSLQCpEFLHP2xK33BiWx9aIuIEXVCcw==", + "dev": true, + "license": "ISC" }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=12" }, @@ -3005,15 +3469,17 @@ } }, "node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true + "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/enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", - "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" + "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==", + "license": "MIT" }, "node_modules/encodeurl": { "version": "2.0.0", @@ -3024,31 +3490,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -3061,22 +3502,20 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "dev": true, + "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -3090,11 +3529,39 @@ "node": ">= 0.4" } }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", - "integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -3110,6 +3577,7 @@ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3119,6 +3587,7 @@ "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", "dev": true, + "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", "esvalidate": "bin/esvalidate.js" @@ -3153,6 +3622,7 @@ "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, + "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", @@ -3171,91 +3641,103 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, - "node_modules/exit": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/exit/-/exit-0.1.2.tgz", - "integrity": "sha512-Zk/eNKV2zbjpKzrsQ+n1G6poVbErQxJ0LBOJXaKZ1EViLzH+hrLu9cdXI4zw9dBQJslwBEpbQ2P1oS7nDxs6jQ==", + "node_modules/exit-x": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", + "integrity": "sha512-+I6B/IkJc1o/2tiURyz/ivu/O0nKNEArIUB5O7zBrlDVJr22SCLH3xTeEry428LvFhRzIA1g8izguxJ/gbNcVQ==", "dev": true, + "license": "MIT", "engines": { "node": ">= 0.8.0" } }, "node_modules/expect": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", - "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", + "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/expect-utils": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0" + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/express": { - "version": "4.21.1", - "resolved": "https://registry.npmjs.org/express/-/express-4.21.1.tgz", - "integrity": "sha512-YSFlK1Ee0/GC8QaO91tHcDxJiE/X4FbpAyQWkxAvG6AXCuR65YzK8ua6D9hvi/TzUfZMpc+BwuM1IPw8fmQBiQ==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", - "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "0.1.10", - "proxy-addr": "~2.0.7", - "qs": "6.13.0", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", - "setprototypeof": "1.2.0", - "statuses": "2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", + "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.0", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", "engines": { - "node": ">= 0.10.0" + "node": ">= 0.6" } }, - "node_modules/express/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "node_modules/express/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", + "license": "MIT", "dependencies": { - "ms": "2.0.0" + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" } }, - "node_modules/express/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" - }, "node_modules/fast-bmp": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fast-bmp/-/fast-bmp-2.0.1.tgz", "integrity": "sha512-MOSG2rHYJCjIfL3/Llseuj39yl5U3d3XLtWFLFm5ZSTublGEXyvNcwi4Npyv6nzDPRSbAP53rvVRUswgftWCcQ==", "dev": true, + "license": "MIT", "dependencies": { "iobuffer": "^5.1.0" } @@ -3265,6 +3747,7 @@ "resolved": "https://registry.npmjs.org/fast-jpeg/-/fast-jpeg-1.0.1.tgz", "integrity": "sha512-nyoYDzmdxgLOBfEhJGwYRsRLqGKziG/wic0SMct17dTVHkseTPvNwHCfihE47tcpGA1cTJO2MNsYYHezmkuA6w==", "dev": true, + "license": "MIT", "dependencies": { "iobuffer": "^2.1.0", "tiff": "^2.0.0" @@ -3274,13 +3757,15 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-2.1.0.tgz", "integrity": "sha512-0XZfU0STJ6NVHBZdMRPjF7jtkDEC5f4AxM/n5DSZOu11SQ+7tAl1csuEnEPoSPYWdaGZ/HOfn5Q837IEHddL2w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/fast-jpeg/node_modules/tiff": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/tiff/-/tiff-2.1.0.tgz", "integrity": "sha512-Q4zLT4+Csn/ZhFVacYCAl+w/1J51NW/m2y2yx7Qxp/bsHYOEsK7+5JOID2kfk+EvsaF0LbA6ccAkqiuXOmAbYw==", "dev": true, + "license": "MIT", "dependencies": { "iobuffer": "^2.1.0" } @@ -3295,15 +3780,17 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/fast-list/-/fast-list-1.0.3.tgz", "integrity": "sha512-Lm56Ci3EqefHNdIneRFuzhpPcpVVBz9fgqVmG3UQIxAefJv1mEYsZ1WQLTWqmdqeGEwbI2t6fbZgp9TqTYARuA==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/fast-png": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.2.0.tgz", - "integrity": "sha512-fO4DewoEd9WwuP8DQcfj8Tlc88Jno6lJAjlDYzvJSqMIZwxUpRT4zuzPXgqygjJqngBdCbeQRaL/FVz3InExhA==", + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", "dev": true, + "license": "MIT", "dependencies": { - "@types/pako": "^2.0.0", + "@types/pako": "^2.0.3", "iobuffer": "^5.3.2", "pako": "^2.1.0" } @@ -3334,7 +3821,8 @@ "version": "4.0.4", "resolved": "https://registry.npmjs.org/fft.js/-/fft.js-4.0.4.tgz", "integrity": "sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/file-type": { "version": "10.11.0", @@ -3345,44 +3833,12 @@ "node": ">=6" } }, - "node_modules/filelist": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", - "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "minimatch": "^5.0.1" - } - }, - "node_modules/filelist/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/filelist/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", "dev": true, + "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" }, @@ -3391,38 +3847,22 @@ } }, "node_modules/finalhandler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", - "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.0.tgz", + "integrity": "sha512-/t88Ty3d5JWQbWYgaOGCCYfXRwV1+be02WqYYlL6h0lEiUAMPM8o8qKGO01YIkOHzka2up08wvgYD0mDiI+q3Q==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "2.4.1", - "parseurl": "~1.3.3", - "statuses": "2.0.1", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { "node": ">= 0.8" } }, - "node_modules/finalhandler/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/finalhandler/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -3461,13 +3901,46 @@ } } }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/foreground-child/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -3475,14 +3948,18 @@ } }, "node_modules/formidable": { - "version": "3.5.1", - "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.1.tgz", - "integrity": "sha512-WJWKelbRHN41m5dumb0/k8TeAx7Id/y3a+Z7QfhxP/htI9Js5zYaEDtG8uMgG0vM0lOlqnmjE99/kfpOYi/0Og==", + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/formidable/-/formidable-3.5.4.tgz", + "integrity": "sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==", + "license": "MIT", "dependencies": { + "@paralleldrive/cuid2": "^2.2.2", "dezalgo": "^1.0.4", - "hexoid": "^1.0.0", "once": "^1.4.0" }, + "engines": { + "node": ">=14.0.0" + }, "funding": { "url": "https://ko-fi.com/tunnckoCore/commissions" } @@ -3496,24 +3973,25 @@ } }, "node_modules/fp-ts": { - "version": "2.16.9", - "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.9.tgz", - "integrity": "sha512-+I2+FnVB+tVaxcYyQkHUq7ZdKScaBlX53A41mxQtpIccsfyv8PzdzP7fzp2AY832T4aoK6UZ5WRX/ebGd8uZuQ==", + "version": "2.16.11", + "resolved": "https://registry.npmjs.org/fp-ts/-/fp-ts-2.16.11.tgz", + "integrity": "sha512-LaI+KaX2NFkfn1ZGHoKCmcfv7yrZsC3b8NtWsTVQeHkq4F27vI5igUuO53sxqDEa2gNQMHFPmpojDw/1zmUK7w==", "license": "MIT" }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/fs-extra": { - "version": "11.2.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.2.0.tgz", - "integrity": "sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==", + "version": "11.3.2", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", + "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -3527,7 +4005,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/fsevents": { "version": "2.3.3", @@ -3565,30 +4044,27 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, + "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -3602,6 +4078,7 @@ "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" } @@ -3619,6 +4096,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -3631,20 +4121,21 @@ } }, "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", "dev": true, + "license": "ISC", "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" + "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" }, - "engines": { - "node": "*" + "bin": { + "glob": "dist/esm/bin.mjs" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -3662,22 +4153,39 @@ "node": ">= 6" } }, - "node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, "engines": { - "node": ">=4" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3688,6 +4196,28 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -3701,24 +4231,14 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/has-own/-/has-own-1.0.1.tgz", "integrity": "sha512-RDKhzgQTQfMaLvIFhjahU+2gGnRBK6dYOd5Gd9BzkmnBneOCRYjRC003RIMrdAbH52+l+CnMS4bBCXGer8tEhg==", - "dev": true - }, - "node_modules/has-property-descriptors": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "license": "MIT", - "dependencies": { - "es-define-property": "^1.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } + "deprecated": "This project is not maintained. Use Object.hasOwn() instead.", + "dev": true, + "license": "MIT" }, - "node_modules/has-proto": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", - "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -3727,11 +4247,14 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, "engines": { "node": ">= 0.4" }, @@ -3740,9 +4263,10 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", "dependencies": { "function-bind": "^1.1.2" }, @@ -3758,18 +4282,10 @@ "he": "bin/he" } }, - "node_modules/hexoid": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/hexoid/-/hexoid-1.0.0.tgz", - "integrity": "sha512-QFLV0taWQOZtvIRIAdBChesmogZrtuXvVWsFHZTk2SU+anspqZ2vMnoLg7IE1+Uk16N19APic1BuF8bC8c2m5g==", - "engines": { - "node": ">=8" - } - }, "node_modules/html-entities": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.4.0.tgz", - "integrity": "sha512-igBTJcNNNhvZFRtm8uA6xMY6xYleeDwn3PeBCkDz7tHttv4F2hsDI2aPgNERWzvRcNYHNT3ymRaQzllmXj4YsQ==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz", + "integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==", "funding": [ { "type": "github", @@ -3779,13 +4295,15 @@ "type": "patreon", "url": "https://patreon.com/mdevils" } - ] + ], + "license": "MIT" }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/http-errors": { "version": "2.0.0", @@ -3803,22 +4321,32 @@ "node": ">= 0.8" } }, + "node_modules/http-errors/node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/human-signals": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true, + "license": "Apache-2.0", "engines": { "node": ">=10.17.0" } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" @@ -3831,9 +4359,9 @@ "dev": true }, "node_modules/image-js": { - "version": "0.35.6", - "resolved": "https://registry.npmjs.org/image-js/-/image-js-0.35.6.tgz", - "integrity": "sha512-2qRaowXOBUIT7Ia842BUFDoBo/Jr0FHlbfssx/awbQUtc399kJWfFf0xE5hIG62ybaQiwutL2e1ocUzGtYxASw==", + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/image-js/-/image-js-0.37.0.tgz", + "integrity": "sha512-t/6CE+H0mT9TKFhlm1h84e9Y1W/ZNAgLUvYH+ogWSZOMUWXLdtUPVJH3EppCCR+EgwSqxbcuiVlPqDPEa9dNtw==", "dev": true, "license": "MIT", "dependencies": { @@ -3843,7 +4371,7 @@ "fast-bmp": "^2.0.1", "fast-jpeg": "^1.0.1", "fast-list": "^1.0.3", - "fast-png": "^6.1.0", + "fast-png": "^6.2.0", "has-own": "^1.0.1", "image-type": "^4.1.0", "is-array-type": "^1.0.0", @@ -3880,10 +4408,11 @@ } }, "node_modules/import-local": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.1.0.tgz", - "integrity": "sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", "dev": true, + "license": "MIT", "dependencies": { "pkg-dir": "^4.2.0", "resolve-cwd": "^3.0.0" @@ -3903,6 +4432,7 @@ "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.8.19" } @@ -3911,7 +4441,9 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dev": true, + "license": "ISC", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -3923,10 +4455,11 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, "node_modules/iobuffer": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.3.2.tgz", - "integrity": "sha512-kO3CjNfLZ9t+tHxAMd+Xk4v3D/31E91rMs1dHrm7ikEQrlZ8mLDbQ4z3tZfDM48zOkReas2jx8MWSAmN9+c8Fw==", - "dev": true + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "dev": true, + "license": "MIT" }, "node_modules/ipaddr.js": { "version": "1.9.1", @@ -3940,19 +4473,22 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz", "integrity": "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-array-type": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-array-type/-/is-array-type-1.0.0.tgz", "integrity": "sha512-LLwKQdMAO/XUkq4XTed1VYqwR2OahiwkBg+yUtZT88LXX4MLXP28qGsVfSNVP8X0wc7fzDhcZD3nns/IK8UfKw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/is-binary-path": { "version": "2.1.0", @@ -3966,18 +4502,6 @@ "node": ">=8" } }, - "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3992,6 +4516,7 @@ "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" }, @@ -4004,6 +4529,7 @@ "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -4013,6 +4539,7 @@ "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -4034,6 +4561,7 @@ "resolved": "https://registry.npmjs.org/is-integer/-/is-integer-1.0.7.tgz", "integrity": "sha512-RPQc/s9yBHSvpi+hs9dYiJ2cuFeU6x3TyyIp8O2H6SKEltIvJOzRj9ToyvcStDvPR/pS4rxgr1oBFajQjZ2Szg==", "dev": true, + "license": "WTFPL OR ISC", "dependencies": { "is-finite": "^1.0.0" } @@ -4043,10 +4571,17 @@ "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -4062,7 +4597,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", @@ -4074,14 +4610,15 @@ } }, "node_modules/istanbul-lib-instrument": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.1.tgz", - "integrity": "sha512-EAMEJBsYuyyztxMxW3g7ugGPkrZsV57v0Hmv3mm1uQsmB+QnZuepg731CRaIgeUVSdmsTngOkSnauNF8p7FIhA==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@babel/core": "^7.12.3", - "@babel/parser": "^7.14.7", - "@istanbuljs/schema": "^0.1.2", + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", "istanbul-lib-coverage": "^3.2.0", "semver": "^7.5.4" }, @@ -4089,26 +4626,12 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-instrument/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -4116,17 +4639,12 @@ "node": ">=10" } }, - "node_modules/istanbul-lib-instrument/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", @@ -4137,24 +4655,26 @@ } }, "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "istanbul-lib-coverage": "^3.0.0" }, "engines": { "node": ">=10" } }, "node_modules/istanbul-reports": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", - "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -4163,41 +4683,40 @@ "node": ">=8" } }, - "node_modules/jake": { - "version": "10.9.2", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", - "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "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": "Apache-2.0", + "license": "BlueOak-1.0.0", "dependencies": { - "async": "^3.2.3", - "chalk": "^4.0.2", - "filelist": "^1.0.4", - "minimatch": "^3.1.2" + "@isaacs/cliui": "^8.0.2" }, - "bin": { - "jake": "bin/cli.js" + "funding": { + "url": "https://github.com/sponsors/isaacs" }, - "engines": { - "node": ">=10" + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/jest": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", - "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", + "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, + "license": "MIT", + "peer": true, "dependencies": { - "@jest/core": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" + "@jest/core": "30.2.0", + "@jest/types": "30.2.0", + "import-local": "^3.2.0", + "jest-cli": "30.2.0" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -4209,73 +4728,75 @@ } }, "node_modules/jest-changed-files": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", - "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", + "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", "dev": true, + "license": "MIT", "dependencies": { - "execa": "^5.0.0", - "jest-util": "^29.7.0", + "execa": "^5.1.1", + "jest-util": "30.2.0", "p-limit": "^3.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-circus": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", - "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", + "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.2.0", + "@jest/expect": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", + "chalk": "^4.1.2", "co": "^4.6.0", - "dedent": "^1.0.0", - "is-generator-fn": "^2.0.0", - "jest-each": "^29.7.0", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", + "dedent": "^1.6.0", + "is-generator-fn": "^2.1.0", + "jest-each": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-runtime": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "p-limit": "^3.1.0", - "pretty-format": "^29.7.0", - "pure-rand": "^6.0.0", + "pretty-format": "30.2.0", + "pure-rand": "^7.0.0", "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "stack-utils": "^2.0.6" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-cli": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", - "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", - "dev": true, - "dependencies": { - "@jest/core": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "create-jest": "^29.7.0", - "exit": "^0.1.2", - "import-local": "^3.0.2", - "jest-config": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "yargs": "^17.3.1" + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", + "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/core": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "exit-x": "^0.2.2", + "import-local": "^3.2.0", + "jest-config": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "yargs": "^17.7.2" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" @@ -4287,204 +4808,211 @@ } }, "node_modules/jest-config": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", - "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@jest/test-sequencer": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-jest": "^29.7.0", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "deepmerge": "^4.2.2", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-circus": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-runner": "^29.7.0", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "micromatch": "^4.0.4", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", + "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.27.4", + "@jest/get-type": "30.1.0", + "@jest/pattern": "30.0.1", + "@jest/test-sequencer": "30.2.0", + "@jest/types": "30.2.0", + "babel-jest": "30.2.0", + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "deepmerge": "^4.3.1", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-circus": "30.2.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-runner": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "micromatch": "^4.0.8", "parse-json": "^5.2.0", - "pretty-format": "^29.7.0", + "pretty-format": "30.2.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "peerDependencies": { "@types/node": "*", + "esbuild-register": ">=3.4.0", "ts-node": ">=9.0.0" }, "peerDependenciesMeta": { "@types/node": { "optional": true }, + "esbuild-register": { + "optional": true + }, "ts-node": { "optional": true } } }, "node_modules/jest-diff": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", - "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", + "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "diff-sequences": "^29.6.3", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/diff-sequences": "30.0.1", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-docblock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", - "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-30.2.0.tgz", + "integrity": "sha512-tR/FFgZKS1CXluOQzZvNH3+0z9jXr3ldGSD8bhyuxvlVUwbeLOGynkunvlTMxchC5urrKndYiwCFC0DLVjpOCA==", "dev": true, + "license": "MIT", "dependencies": { - "detect-newline": "^3.0.0" + "detect-newline": "^3.1.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-each": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", - "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", + "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", - "jest-util": "^29.7.0", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "chalk": "^4.1.2", + "jest-util": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", - "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", + "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "jest-mock": "30.2.0", + "jest-util": "30.2.0", + "jest-validate": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-get-type": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", - "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", - "dev": true, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", - "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", + "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "@types/graceful-fs": "^4.1.3", + "@jest/types": "30.2.0", "@types/node": "*", - "anymatch": "^3.0.3", - "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.9", - "jest-regex-util": "^29.6.3", - "jest-util": "^29.7.0", - "jest-worker": "^29.7.0", - "micromatch": "^4.0.4", + "anymatch": "^3.1.3", + "fb-watchman": "^2.0.2", + "graceful-fs": "^4.2.11", + "jest-regex-util": "30.0.1", + "jest-util": "30.2.0", + "jest-worker": "30.2.0", + "micromatch": "^4.0.8", "walker": "^1.0.8" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" }, "optionalDependencies": { - "fsevents": "^2.3.2" + "fsevents": "^2.3.3" } }, "node_modules/jest-leak-detector": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", - "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", + "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", "dev": true, + "license": "MIT", "dependencies": { - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.1.0", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", - "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", + "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", "dev": true, + "license": "MIT", "dependencies": { - "chalk": "^4.0.0", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "jest-diff": "30.2.0", + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", - "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.12.13", - "@jest/types": "^29.6.3", - "@types/stack-utils": "^2.0.0", - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "micromatch": "^4.0.4", - "pretty-format": "^29.7.0", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", + "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@jest/types": "30.2.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "micromatch": "^4.0.8", + "pretty-format": "30.2.0", "slash": "^3.0.0", - "stack-utils": "^2.0.3" + "stack-utils": "^2.0.6" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-mock": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", + "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.2.0", "@types/node": "*", - "jest-util": "^29.7.0" + "jest-util": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-pnp-resolver": { @@ -4492,6 +5020,7 @@ "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" }, @@ -4505,208 +5034,210 @@ } }, "node_modules/jest-regex-util": { - "version": "29.6.3", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", - "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-30.0.1.tgz", + "integrity": "sha512-jHEQgBXAgc+Gh4g0p3bCevgRCVRkB4VB70zhoAE48gxeSr1hfUOsM/C2WoJgVL7Eyg//hudYENbm3Ne+/dRVVA==", "dev": true, + "license": "MIT", "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", - "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", - "dev": true, - "dependencies": { - "chalk": "^4.0.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-pnp-resolver": "^1.2.2", - "jest-util": "^29.7.0", - "jest-validate": "^29.7.0", - "resolve": "^1.20.0", - "resolve.exports": "^2.0.0", - "slash": "^3.0.0" + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", + "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.2.0", + "jest-validate": "30.2.0", + "slash": "^3.0.0", + "unrs-resolver": "^1.7.11" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", - "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", + "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", "dev": true, + "license": "MIT", "dependencies": { - "jest-regex-util": "^29.6.3", - "jest-snapshot": "^29.7.0" + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", - "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", + "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/console": "^29.7.0", - "@jest/environment": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/console": "30.2.0", + "@jest/environment": "30.2.0", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", + "chalk": "^4.1.2", "emittery": "^0.13.1", - "graceful-fs": "^4.2.9", - "jest-docblock": "^29.7.0", - "jest-environment-node": "^29.7.0", - "jest-haste-map": "^29.7.0", - "jest-leak-detector": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-resolve": "^29.7.0", - "jest-runtime": "^29.7.0", - "jest-util": "^29.7.0", - "jest-watcher": "^29.7.0", - "jest-worker": "^29.7.0", + "exit-x": "^0.2.2", + "graceful-fs": "^4.2.11", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.2.0", + "jest-haste-map": "30.2.0", + "jest-leak-detector": "30.2.0", + "jest-message-util": "30.2.0", + "jest-resolve": "30.2.0", + "jest-runtime": "30.2.0", + "jest-util": "30.2.0", + "jest-watcher": "30.2.0", + "jest-worker": "30.2.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runtime": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", - "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", - "dev": true, - "dependencies": { - "@jest/environment": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/globals": "^29.7.0", - "@jest/source-map": "^29.6.3", - "@jest/test-result": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", + "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "30.2.0", + "@jest/fake-timers": "30.2.0", + "@jest/globals": "30.2.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", - "cjs-module-lexer": "^1.0.0", - "collect-v8-coverage": "^1.0.0", - "glob": "^7.1.3", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-regex-util": "^29.6.3", - "jest-resolve": "^29.7.0", - "jest-snapshot": "^29.7.0", - "jest-util": "^29.7.0", + "chalk": "^4.1.2", + "cjs-module-lexer": "^2.1.0", + "collect-v8-coverage": "^1.0.2", + "glob": "^10.3.10", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.2.0", + "jest-message-util": "30.2.0", + "jest-mock": "30.2.0", + "jest-regex-util": "30.0.1", + "jest-resolve": "30.2.0", + "jest-snapshot": "30.2.0", + "jest-util": "30.2.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", - "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", - "dev": true, - "dependencies": { - "@babel/core": "^7.11.6", - "@babel/generator": "^7.7.2", - "@babel/plugin-syntax-jsx": "^7.7.2", - "@babel/plugin-syntax-typescript": "^7.7.2", - "@babel/types": "^7.3.3", - "@jest/expect-utils": "^29.7.0", - "@jest/transform": "^29.7.0", - "@jest/types": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0", - "chalk": "^4.0.0", - "expect": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-diff": "^29.7.0", - "jest-get-type": "^29.6.3", - "jest-matcher-utils": "^29.7.0", - "jest-message-util": "^29.7.0", - "jest-util": "^29.7.0", - "natural-compare": "^1.4.0", - "pretty-format": "^29.7.0", - "semver": "^7.5.3" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" - } - }, - "node_modules/jest-snapshot/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", + "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", "dev": true, + "license": "MIT", "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" + "@babel/core": "^7.27.4", + "@babel/generator": "^7.27.5", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.27.1", + "@babel/types": "^7.27.3", + "@jest/expect-utils": "30.2.0", + "@jest/get-type": "30.1.0", + "@jest/snapshot-utils": "30.2.0", + "@jest/transform": "30.2.0", + "@jest/types": "30.2.0", + "babel-preset-current-node-syntax": "^1.2.0", + "chalk": "^4.1.2", + "expect": "30.2.0", + "graceful-fs": "^4.2.11", + "jest-diff": "30.2.0", + "jest-matcher-utils": "30.2.0", + "jest-message-util": "30.2.0", + "jest-util": "30.2.0", + "pretty-format": "30.2.0", + "semver": "^7.7.2", + "synckit": "^0.11.8" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, - "engines": { - "node": ">=10" - } - }, - "node_modules/jest-snapshot/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true + "engines": { + "node": ">=10" + } }, "node_modules/jest-util": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", - "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", + "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", + "@jest/types": "30.2.0", "@types/node": "*", - "chalk": "^4.0.0", - "ci-info": "^3.2.0", - "graceful-fs": "^4.2.9", - "picomatch": "^2.2.3" + "chalk": "^4.1.2", + "ci-info": "^4.2.0", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, + "node_modules/jest-util/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/jest-validate": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", - "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", + "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^29.6.3", - "camelcase": "^6.2.0", - "chalk": "^4.0.0", - "jest-get-type": "^29.6.3", + "@jest/get-type": "30.1.0", + "@jest/types": "30.2.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "^29.7.0" + "pretty-format": "30.2.0" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-validate/node_modules/camelcase": { @@ -4714,6 +5245,7 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -4722,37 +5254,40 @@ } }, "node_modules/jest-watcher": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", - "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", + "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/test-result": "30.2.0", + "@jest/types": "30.2.0", "@types/node": "*", - "ansi-escapes": "^4.2.1", - "chalk": "^4.0.0", + "ansi-escapes": "^4.3.2", + "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "^29.7.0", - "string-length": "^4.0.1" + "jest-util": "30.2.0", + "string-length": "^4.0.2" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-worker": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", - "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", + "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", - "jest-util": "^29.7.0", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.2.0", "merge-stream": "^2.0.0", - "supports-color": "^8.0.0" + "supports-color": "^8.1.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-worker/node_modules/supports-color": { @@ -4760,6 +5295,7 @@ "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" }, @@ -4791,19 +5327,22 @@ "version": "1.8.0", "resolved": "https://registry.npmjs.org/js-quantities/-/js-quantities-1.8.0.tgz", "integrity": "sha512-swDw9RJpXACAWR16vAKoSojAsP6NI7cZjjnjKqhOyZSdybRUdmPr071foD3fejUKSU2JMHz99hflWkRWvfLTpQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/js-yaml": { "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", "dev": true, + "license": "MIT", "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -4813,22 +5352,24 @@ } }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", "dev": true, + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/json5": { "version": "2.2.3", @@ -4857,6 +5398,7 @@ "version": "9.0.2", "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "license": "MIT", "dependencies": { "jws": "^3.2.2", "lodash.includes": "^4.3.0", @@ -4937,30 +5479,24 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", "dependencies": { "jwa": "^2.0.0", "safe-buffer": "^5.0.1" } }, - "node_modules/kleur": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", - "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", - "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" + "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==", + "license": "MIT" }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -4969,7 +5505,8 @@ "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/locate-path": { "version": "5.0.0", @@ -5046,20 +5583,12 @@ "node": ">= 12.0.0" } }, - "node_modules/loupe": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.1.0.tgz", - "integrity": "sha512-qKl+FrLXUhFuHUoDJG7f8P8gEMHq9NFS0c6ghXG1J0rldmZFQZoNVv/vyirE9qwCIhWZDsvEFd1sbFu3GvRQFg==", - "dev": true, - "dependencies": { - "get-func-name": "^2.0.1" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dev": true, + "license": "ISC", "dependencies": { "yallist": "^3.0.2" } @@ -5069,6 +5598,7 @@ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^7.5.3" }, @@ -5079,26 +5609,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/make-dir/node_modules/semver": { - "version": "7.5.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", - "integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -5106,12 +5622,6 @@ "node": ">=10" } }, - "node_modules/make-dir/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/make-error": { "version": "1.3.6", "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", @@ -5127,26 +5637,39 @@ "tmpl": "1.0.5" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/median-quickselect": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/median-quickselect/-/median-quickselect-1.0.1.tgz", "integrity": "sha512-/QL9ptNuLsdA68qO+2o10TKCyu621zwwTFdLvtu8rzRNKsn8zvuGoq/vDxECPyELFG8wu+BpyoMR9BnsJqfVZQ==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -5161,17 +5684,20 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dev": true, + "license": "MIT", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -5179,15 +5705,16 @@ } }, "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", + "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", + "dev": true, "license": "MIT", "bin": { "mime": "cli.js" }, "engines": { - "node": ">=4" + "node": ">=4.0.0" } }, "node_modules/mime-db": { @@ -5214,6 +5741,7 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -5235,11 +5763,32 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, "node_modules/ml-array-max": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-1.2.4.tgz", "integrity": "sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ==", "dev": true, + "license": "MIT", "dependencies": { "is-any-array": "^2.0.0" } @@ -5249,6 +5798,7 @@ "resolved": "https://registry.npmjs.org/ml-array-median/-/ml-array-median-1.1.6.tgz", "integrity": "sha512-V6bV6bTPFRX8v5CaAx/7fuRXC39LLTHfPSVZZafdNaqNz2PFL5zEA7gesjv8dMXh+gwPeUMtB5QPovlTBaa4sw==", "dev": true, + "license": "MIT", "dependencies": { "is-any-array": "^2.0.0", "median-quickselect": "^1.0.1" @@ -5259,6 +5809,7 @@ "resolved": "https://registry.npmjs.org/ml-array-min/-/ml-array-min-1.2.3.tgz", "integrity": "sha512-VcZ5f3VZ1iihtrGvgfh/q0XlMobG6GQ8FsNyQXD3T+IlstDv85g8kfV0xUG1QPRO/t21aukaJowDzMTc7j5V6Q==", "dev": true, + "license": "MIT", "dependencies": { "is-any-array": "^2.0.0" } @@ -5268,6 +5819,7 @@ "resolved": "https://registry.npmjs.org/ml-array-rescale/-/ml-array-rescale-1.3.7.tgz", "integrity": "sha512-48NGChTouvEo9KBctDfHC3udWnQKNKEWN0ziELvY3KG25GR5cA8K8wNVzracsqSW1QEkAXjTNx+ycgAv06/1mQ==", "dev": true, + "license": "MIT", "dependencies": { "is-any-array": "^2.0.0", "ml-array-max": "^1.2.4", @@ -5279,6 +5831,7 @@ "resolved": "https://registry.npmjs.org/ml-convolution/-/ml-convolution-0.2.0.tgz", "integrity": "sha512-km5f81jFVnEWG0eFEKAwt00X3xGUIAcUqZZlUk+w0q2sZOz1vkEYhIKOXAlmaEi9rnrTknxW//Ttm399zPzDPg==", "dev": true, + "license": "MIT", "dependencies": { "fft.js": "^4.0.3", "next-power-of-two": "^1.0.0" @@ -5288,25 +5841,29 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/ml-disjoint-set/-/ml-disjoint-set-1.0.0.tgz", "integrity": "sha512-UcEzgvRzVhsKpT66syfdhaK8R+av6GxDFmU37t+6WClT/kHDIN6OMRfO7OPwQIV8+L8FSc2E6lNKpvdqf6OgLw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ml-distance-euclidean": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ml-distance-euclidean/-/ml-distance-euclidean-2.0.0.tgz", "integrity": "sha512-yC9/2o8QF0A3m/0IXqCTXCzz2pNEzvmcE/9HFKOZGnTjatvBbsn4lWYJkxENkA4Ug2fnYl7PXQxnPi21sgMy/Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ml-fft": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/ml-fft/-/ml-fft-1.3.5.tgz", "integrity": "sha512-laAATDyUuWPbIlX57thIds41wqFLsB+Zl7i1yrLRo/4CFg+hFaF9Xle8InblQseyiaVtt1KSlDG+6lgUMPOj3g==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ml-kernel": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/ml-kernel/-/ml-kernel-3.0.0.tgz", "integrity": "sha512-R+ZR0Kl5xJ7vnxtlDqjZ26xVk7mAw7ctK4NlzRHviBFXxp7keC9+hWirMOdzi2DOQA0t6CaRwjElZ6SdirOmow==", "dev": true, + "license": "MIT", "dependencies": { "ml-distance-euclidean": "^2.0.0", "ml-kernel-gaussian": "^2.0.2", @@ -5320,6 +5877,7 @@ "resolved": "https://registry.npmjs.org/ml-kernel-gaussian/-/ml-kernel-gaussian-2.0.2.tgz", "integrity": "sha512-5MBrH2g9MBO53I6mcyXvMhyOLsmO2w21+26A1ZV/vYoxqpsov2PWkT8bhdFCEe0kgDupmAb6u81iOID/rhnarA==", "dev": true, + "license": "MIT", "dependencies": { "ml-distance-euclidean": "^2.0.0" } @@ -5328,19 +5886,22 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/ml-kernel-polynomial/-/ml-kernel-polynomial-2.0.1.tgz", "integrity": "sha512-aGDNRPHDiKeJmBxB0L9wTxKNLfp5JytbdRIo5K+FTcmFjkWDe3YZPo6R6wBB5mxaJ5eqTRawzeV4RoIWHbakyQ==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ml-kernel-sigmoid": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/ml-kernel-sigmoid/-/ml-kernel-sigmoid-1.0.1.tgz", "integrity": "sha512-mSbYOSbNQ7GsUAGrHuUHNsLgM3bZGpXkotw/FBdKZD9YMXfVOgQb1LvvvVeSlOR/ZdmX23qqaV0RnKSYWBF8og==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/ml-matrix": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/ml-matrix/-/ml-matrix-6.11.0.tgz", - "integrity": "sha512-7jr9NmFRkaUxbKslfRu3aZOjJd2LkSitCGv+QH9PF0eJoEG7jIpjXra1Vw8/kgao8+kHCSsJONG6vfWmXQ+/Eg==", + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/ml-matrix/-/ml-matrix-6.12.1.tgz", + "integrity": "sha512-TJ+8eOFdp+INvzR4zAuwBQJznDUfktMtOB6g/hUcGh3rcyjxbz4Te57Pgri8Q9bhSQ7Zys4IYOGhFdnlgeB6Lw==", "dev": true, + "license": "MIT", "dependencies": { "is-any-array": "^2.0.1", "ml-array-rescale": "^1.3.7" @@ -5351,6 +5912,7 @@ "resolved": "https://registry.npmjs.org/ml-matrix-convolution/-/ml-matrix-convolution-0.4.3.tgz", "integrity": "sha512-B4AATOjxDw4J0oVcoeYHsXrhMr31x9SWhVKZjWucDU+brwXLR0enMdqb1OuRy/REdpL5/iSshA46sS2B1dO2OQ==", "dev": true, + "license": "MIT", "dependencies": { "ml-fft": "1.3.5", "ml-stat": "^1.2.0" @@ -5361,6 +5923,7 @@ "resolved": "https://registry.npmjs.org/ml-regression/-/ml-regression-5.0.0.tgz", "integrity": "sha512-mBn0LpfEWV3Dk0dj+8PRNUqIHvO87rUY0PmCUTYv3MKfECx7TtlKyeacJeOBLZ4YAVixX8U5hn4HwRL6TpTYaw==", "dev": true, + "license": "MIT", "dependencies": { "ml-kernel": "^3.0.0", "ml-matrix": "^6.1.2", @@ -5379,18 +5942,53 @@ "resolved": "https://registry.npmjs.org/ml-regression-base/-/ml-regression-base-2.1.6.tgz", "integrity": "sha512-yTckvEc8szc6VrUTJSgAClShvCoPZdNt8pmyRe8aGsIWGjg6bYFotp9mDUwAB0snvKAbQWd6A4trL/PDCASLug==", "dev": true, + "license": "MIT", "dependencies": { "is-any-array": "^2.0.0" } }, "node_modules/ml-regression-exponential": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ml-regression-exponential/-/ml-regression-exponential-2.1.0.tgz", - "integrity": "sha512-6ZgGbzIkXnONfGGUU0LjIb9qb35WzVqdAFSX8vFr8UEhgXhfgEws9pGrBJu19VBEh7ZTtttcPObI3aoBscq4Kg==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ml-regression-exponential/-/ml-regression-exponential-2.1.3.tgz", + "integrity": "sha512-TE7xIlsHqKdLIgJ5d2rYVrGTd+NkjSBH8bLf1umLFiQI5hR7mUPmMoX+LalVJMd8NhlOwQzufOHqaPewMJ7S6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ml-regression-base": "^3.0.0", + "ml-regression-simple-linear": "^3.0.0" + } + }, + "node_modules/ml-regression-exponential/node_modules/ml-regression-base": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ml-regression-base/-/ml-regression-base-3.0.0.tgz", + "integrity": "sha512-qkQWvNk8VU1LIytjid/+YHOSx8GnEU9dCUPsAQ8AzCh4saijrsni/XA6x7r+N1UrHMDHeSEUBtRZTsl2syyu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheminfo-types": "^1.7.2", + "is-any-array": "^2.0.1" + } + }, + "node_modules/ml-regression-exponential/node_modules/ml-regression-simple-linear": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ml-regression-simple-linear/-/ml-regression-simple-linear-3.0.1.tgz", + "integrity": "sha512-SF2oxA+034Co9GVQSFuS3vtACaRAFrEwHi9oX6VTaSY/KtXxseL3d4GApj4jWXMoAgrP7VMoIO1PH0RoZaMR1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheminfo-types": "^1.7.3", + "ml-regression-base": "^4.0.0" + } + }, + "node_modules/ml-regression-exponential/node_modules/ml-regression-simple-linear/node_modules/ml-regression-base": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/ml-regression-base/-/ml-regression-base-4.0.0.tgz", + "integrity": "sha512-V2VjB+K/BcgXaX450xvYw36TLOB+piD9G1pHU3VE+ggQUApsVGkYco6UMQykFOwBydHnDTbOiybH/lwrkqFT4g==", "dev": true, + "license": "MIT", "dependencies": { - "ml-regression-base": "^2.1.3", - "ml-regression-simple-linear": "^2.0.3" + "cheminfo-types": "^1.7.3", + "is-any-array": "^2.0.1" } }, "node_modules/ml-regression-multivariate-linear": { @@ -5398,6 +5996,7 @@ "resolved": "https://registry.npmjs.org/ml-regression-multivariate-linear/-/ml-regression-multivariate-linear-2.0.4.tgz", "integrity": "sha512-/vShPAlP+mB7P2mC5TuXwObSJNl/UBI71/bszt9ilTg6yLKy6btDLpAYyJNa6t+JnL5a7q+Yy4dCltfpvqXRIw==", "dev": true, + "license": "MIT", "dependencies": { "ml-matrix": "^6.10.1" } @@ -5407,6 +6006,7 @@ "resolved": "https://registry.npmjs.org/ml-regression-polynomial/-/ml-regression-polynomial-2.2.0.tgz", "integrity": "sha512-WxFsEmi6oLxgq9TeaVoAA+vVUJFp1kGarX6WWClR8OmlanoIW5iLMnaeXfQcYuH8xNq4R1Cax2N9hYYmeWWkLg==", "dev": true, + "license": "MIT", "dependencies": { "ml-matrix": "^6.8.0", "ml-regression-base": "^2.1.3" @@ -5417,6 +6017,7 @@ "resolved": "https://registry.npmjs.org/ml-regression-power/-/ml-regression-power-2.0.0.tgz", "integrity": "sha512-u8O9Fy45+OeYm/4ZBcNDn5w3w+MHc6kZz/AWSJIwmJcyjz6PRkTZnNfgGYdVKwKKDlAOS7G/AFvMKSTWRNO4RQ==", "dev": true, + "license": "MIT", "dependencies": { "ml-regression-base": "^2.0.1", "ml-regression-simple-linear": "^2.0.2" @@ -5427,6 +6028,7 @@ "resolved": "https://registry.npmjs.org/ml-regression-robust-polynomial/-/ml-regression-robust-polynomial-2.0.1.tgz", "integrity": "sha512-WkxA224Cil1G3Ug/T1O8H/2IDADlca21oC5WDplcM+gQRTqtueT/Su4ubH70tG6s79XHM046HfO8xQSpDQxqqg==", "dev": true, + "license": "MIT", "dependencies": { "ml-matrix": "^6.8.0", "ml-regression-base": "^2.1.3" @@ -5437,6 +6039,7 @@ "resolved": "https://registry.npmjs.org/ml-regression-simple-linear/-/ml-regression-simple-linear-2.0.5.tgz", "integrity": "sha512-7DBYru8GvWLaYo4LUF9vU2DjzHuM6i6WGnVbEP9wq8nUFUZ2DlwN46m8Z/hNhTSR7+3T+RvhaSY+OqdBpaz8zw==", "dev": true, + "license": "MIT", "dependencies": { "ml-regression-base": "^2.0.1" } @@ -5446,6 +6049,7 @@ "resolved": "https://registry.npmjs.org/ml-regression-theil-sen/-/ml-regression-theil-sen-2.0.0.tgz", "integrity": "sha512-RO//tYzo69XbWDO5LIPdGp8ef1MSTPPJY0bXNlmOLMSay7YR9FQqtNgqn29T9DSYTa863VAafRlCeXwDQNXkBw==", "dev": true, + "license": "MIT", "dependencies": { "ml-array-median": "^1.1.1", "ml-regression-base": "^2.0.1" @@ -5455,24 +6059,27 @@ "version": "1.3.3", "resolved": "https://registry.npmjs.org/ml-stat/-/ml-stat-1.3.3.tgz", "integrity": "sha512-F6plydFIKFZA+7j/pRsRrfRu4nwsruQvYD9QxHWc4hFUdASVznsKUL2hgAwgMVizY/P0+b1L9bVQexKES5y/uw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/monotone-chain-convex-hull": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/monotone-chain-convex-hull/-/monotone-chain-convex-hull-1.1.0.tgz", "integrity": "sha512-iZGaoO2qtqIWaAfscTtsH2LolE06U4JzTw8AgtjT/yzYIA0aoAHDdwBtsesnQXfVRvS375Wu0Y1+FqdI5Y22GA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/morgan": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.0.tgz", - "integrity": "sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/morgan/-/morgan-1.10.1.tgz", + "integrity": "sha512-223dMRJtI/l25dJKWpgij2cMtywuG/WiUKXdvwfbhGKBhy1puASqXwFzmWZ7+K73vUPoR7SS2Qz2cI/g9MKw0A==", + "license": "MIT", "dependencies": { "basic-auth": "~2.0.1", "debug": "2.6.9", "depd": "~2.0.0", "on-finished": "~2.3.0", - "on-headers": "~1.0.2" + "on-headers": "~1.1.0" }, "engines": { "node": ">= 0.8.0" @@ -5503,35 +6110,63 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", "engines": { "node": ">= 0.6" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/new-array": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/new-array/-/new-array-1.0.0.tgz", "integrity": "sha512-K5AyFYbuHZ4e/ti52y7k18q8UHsS78FlRd85w2Fmsd6AkuLipDihPflKC0p3PN5i8II7+uHxo+CtkLiJDfmS5A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/next-power-of-two": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/next-power-of-two/-/next-power-of-two-1.0.0.tgz", "integrity": "sha512-+z6QY1SxkDk6CQJAeaIZKmcNubBCRP7J8DMQUBglz/sSkNsZoJ1kULjqk9skNPPplzs4i9PFhYrvNDdtQleF/A==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/node-fetch": { "version": "2.7.0", @@ -5553,9 +6188,9 @@ } }, "node_modules/node-html-parser": { - "version": "6.1.13", - "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", - "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.1.tgz", + "integrity": "sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==", "license": "MIT", "dependencies": { "css-select": "^5.1.0", @@ -5569,15 +6204,16 @@ "dev": true }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", - "dev": true + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" }, "node_modules/nodemon": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.7.tgz", - "integrity": "sha512-hLj7fuMow6f0lbB0cD14Lz2xNjwsyruH251Pk4t/yIitCFJbmY1myuLlHm/q06aST4jg6EgAh74PIBBrRqpVAQ==", + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", + "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", "dev": true, "license": "MIT", "dependencies": { @@ -5666,11 +6302,27 @@ "node": ">=0.10.0" } }, + "node_modules/npm-check-updates": { + "version": "19.1.2", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-19.1.2.tgz", + "integrity": "sha512-FNeFCVgPOj0fz89hOpGtxP2rnnRHR7hD2E8qNU8SMWfkyDZXA/xpgjsL3UMLSo3F/K13QvJDnbxPngulNDDo/g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "ncu": "build/cli.js", + "npm-check-updates": "build/cli.js" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=8.12.1" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.0.0" }, @@ -5690,9 +6342,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.3", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", - "integrity": "sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "license": "MIT", "engines": { "node": ">= 0.4" @@ -5714,9 +6366,10 @@ } }, "node_modules/on-headers": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.0.2.tgz", - "integrity": "sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -5742,6 +6395,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" }, @@ -5757,6 +6411,7 @@ "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", "dev": true, + "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" }, @@ -5803,17 +6458,26 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "dev": true + "dev": true, + "license": "(MIT AND Zlib)" }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/code-frame": "^7.0.0", "error-ex": "^1.3.1", @@ -5850,6 +6514,7 @@ "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -5859,36 +6524,51 @@ "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true - }, - "node_modules/path-to-regexp": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.10.tgz", - "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.0.tgz", - "integrity": "sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==", + "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": ">= 14.16" + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/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/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", - "dev": true + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" }, "node_modules/picomatch": { "version": "2.3.1", @@ -5903,10 +6583,11 @@ } }, "node_modules/pirates": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", - "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", "dev": true, + "license": "MIT", "engines": { "node": ">= 6" } @@ -5916,6 +6597,7 @@ "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", "dev": true, + "license": "MIT", "dependencies": { "find-up": "^4.0.0" }, @@ -5924,17 +6606,18 @@ } }, "node_modules/pretty-format": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", - "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "version": "30.2.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", + "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/schemas": "^29.6.3", - "ansi-styles": "^5.0.0", - "react-is": "^18.0.0" + "@jest/schemas": "30.0.5", + "ansi-styles": "^5.2.0", + "react-is": "^18.3.1" }, "engines": { - "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/pretty-format/node_modules/ansi-styles": { @@ -5942,6 +6625,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -5949,19 +6633,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/prompts": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", - "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", - "dev": true, - "dependencies": { - "kleur": "^3.0.3", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -5986,9 +6657,9 @@ "dev": true }, "node_modules/pure-rand": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.0.4.tgz", - "integrity": "sha512-LA0Y9kxMYv47GIPJy6MI84fqTd2HmYZI83W/kM/SkKfDlajnZYfmXFTxkbY+xSBPkLJxltMa9hIkmdc29eguMA==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", "dev": true, "funding": [ { @@ -5999,15 +6670,16 @@ "type": "opencollective", "url": "https://opencollective.com/fast-check" } - ] + ], + "license": "MIT" }, "node_modules/qs": { - "version": "6.13.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", - "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -6017,16 +6689,21 @@ } }, "node_modules/randombytes": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.3.tgz", - "integrity": "sha512-lDVjxQQFoCG1jcrP06LNo2lbWp4QTShEXnhActFBwYuHprllQV6VUpwreApsYqCgD+N1mHoqJ/BI/4eV4R2GYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "^5.1.0" + } }, "node_modules/randomstring": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/randomstring/-/randomstring-1.3.0.tgz", - "integrity": "sha512-gY7aQ4i1BgwZ8I1Op4YseITAyiDiajeZOPQUbIq9TPGPhUm5FX59izIaOpmKbME1nmnEiABf28d9K2VSii6BBg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/randomstring/-/randomstring-1.3.1.tgz", + "integrity": "sha512-lgXZa80MUkjWdE7g2+PZ1xDLzc7/RokXVEQOv5NN2UOTChW1I8A9gha5a9xYBOqgaSoI6uJikDmCU8PyRdArRQ==", + "license": "MIT", "dependencies": { - "randombytes": "2.0.3" + "randombytes": "2.1.0" }, "bin": { "randomstring": "bin/randomstring" @@ -6045,25 +6722,42 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.1.tgz", + "integrity": "sha512-9G8cA+tuMS75+6G/TzW8OtLzmBDMo8p1JRxN5AZ+LAp8uxGA8V8GZm4GQ4/N5QNQEnLmg6SS7wyuSmbKepiKqA==", "license": "MIT", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.7.0", "unpipe": "1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/react-is": { - "version": "18.2.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz", - "integrity": "sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==", - "dev": true + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "dev": true, + "license": "MIT" }, "node_modules/readable-stream": { "version": "3.6.2", @@ -6091,43 +6785,22 @@ "node": ">=8.10.0" } }, - "node_modules/regenerator-runtime": { - "version": "0.14.1", - "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", - "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", - "dev": true - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.8", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", - "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", - "dev": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-cwd": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", "dev": true, + "license": "MIT", "dependencies": { "resolve-from": "^5.0.0" }, @@ -6144,15 +6817,6 @@ "node": ">=8" } }, - "node_modules/resolve.exports": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", - "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/robust-orientation": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/robust-orientation/-/robust-orientation-1.2.1.tgz", @@ -6196,10 +6860,27 @@ "integrity": "sha512-AvLExwpaqUqD1uwLU6MwzzfRdaI6VEZsyvQ3IAQ0ZJ08v1H+DTyqskrf2ZJyh0BDduFVLN7H04Zmc+qTiahhAw==", "dev": true }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/rxjs": { - "version": "7.8.1", - "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz", - "integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==", + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "license": "Apache-2.0", "optional": true, "dependencies": { "tslib": "^2.1.0" @@ -6255,89 +6936,61 @@ } }, "node_modules/send": { - "version": "0.19.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", - "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.0.tgz", + "integrity": "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~1.0.2", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "2.4.1", - "range-parser": "~1.2.1", - "statuses": "2.0.1" + "debug": "^4.3.5", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "mime-types": "^3.0.1", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/send/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "license": "MIT", - "dependencies": { - "ms": "2.0.0" + "node": ">= 18" } }, - "node_modules/send/node_modules/debug/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "license": "MIT" - }, - "node_modules/send/node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "node_modules/send/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.6" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/serve-static": { - "version": "1.16.2", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", - "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", + "node_modules/send/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "0.19.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 0.6" } }, - "node_modules/set-function-length": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "node_modules/serve-static": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-61g9pCh0Vnh7IutZjtLGGpTA355+OPn2TyDv/6ivP2h/AdAVX9azsoxmg2/M6nZeQZNYBEwIcsne1mJd9oQItQ==", "license": "MIT", "dependencies": { - "define-data-property": "^1.1.4", - "es-errors": "^1.3.0", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.2" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.4" + "node": ">= 18" } }, "node_modules/setprototypeof": { @@ -6347,15 +7000,15 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.33.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz", - "integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==", + "version": "0.34.4", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", + "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { - "color": "^4.2.3", - "detect-libc": "^2.0.3", - "semver": "^7.6.3" + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.0", + "semver": "^7.7.2" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -6364,31 +7017,34 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.33.5", - "@img/sharp-darwin-x64": "0.33.5", - "@img/sharp-libvips-darwin-arm64": "1.0.4", - "@img/sharp-libvips-darwin-x64": "1.0.4", - "@img/sharp-libvips-linux-arm": "1.0.5", - "@img/sharp-libvips-linux-arm64": "1.0.4", - "@img/sharp-libvips-linux-s390x": "1.0.4", - "@img/sharp-libvips-linux-x64": "1.0.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", - "@img/sharp-libvips-linuxmusl-x64": "1.0.4", - "@img/sharp-linux-arm": "0.33.5", - "@img/sharp-linux-arm64": "0.33.5", - "@img/sharp-linux-s390x": "0.33.5", - "@img/sharp-linux-x64": "0.33.5", - "@img/sharp-linuxmusl-arm64": "0.33.5", - "@img/sharp-linuxmusl-x64": "0.33.5", - "@img/sharp-wasm32": "0.33.5", - "@img/sharp-win32-ia32": "0.33.5", - "@img/sharp-win32-x64": "0.33.5" + "@img/sharp-darwin-arm64": "0.34.4", + "@img/sharp-darwin-x64": "0.34.4", + "@img/sharp-libvips-darwin-arm64": "1.2.3", + "@img/sharp-libvips-darwin-x64": "1.2.3", + "@img/sharp-libvips-linux-arm": "1.2.3", + "@img/sharp-libvips-linux-arm64": "1.2.3", + "@img/sharp-libvips-linux-ppc64": "1.2.3", + "@img/sharp-libvips-linux-s390x": "1.2.3", + "@img/sharp-libvips-linux-x64": "1.2.3", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", + "@img/sharp-libvips-linuxmusl-x64": "1.2.3", + "@img/sharp-linux-arm": "0.34.4", + "@img/sharp-linux-arm64": "0.34.4", + "@img/sharp-linux-ppc64": "0.34.4", + "@img/sharp-linux-s390x": "0.34.4", + "@img/sharp-linux-x64": "0.34.4", + "@img/sharp-linuxmusl-arm64": "0.34.4", + "@img/sharp-linuxmusl-x64": "0.34.4", + "@img/sharp-wasm32": "0.34.4", + "@img/sharp-win32-arm64": "0.34.4", + "@img/sharp-win32-ia32": "0.34.4", + "@img/sharp-win32-x64": "0.34.4" } }, "node_modules/sharp/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "license": "ISC", "bin": { "semver": "bin/semver.js" @@ -6402,6 +7058,7 @@ "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", "dev": true, + "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" }, @@ -6414,20 +7071,75 @@ "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", + "call-bound": "^1.0.2", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -6442,19 +7154,6 @@ "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", "dev": true }, - "node_modules/simple-swizzle": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", - "integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==", - "dependencies": { - "is-arrayish": "^0.3.1" - } - }, - "node_modules/simple-swizzle/node_modules/is-arrayish": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz", - "integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==" - }, "node_modules/simple-update-notifier": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz", @@ -6500,12 +7199,6 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -6516,49 +7209,26 @@ } }, "node_modules/soap": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/soap/-/soap-1.1.6.tgz", - "integrity": "sha512-em3PDqr5kQjzDRkWRQ4JMCPg32uMonSdLds0QgRJrJBLid1/LHdhUgQuPxJA6SFV1/58Wu7HWIypmW+vqmUPlw==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/soap/-/soap-1.6.0.tgz", + "integrity": "sha512-koOlNMAONSSVP38WakXEWz3WaYFupJJ08eicwrIvQsv9k2Qwz5JLLS6COqJVpIVCwffcqf8InMs+NYPw1bLOjA==", "license": "MIT", "dependencies": { - "axios": "^1.7.7", - "axios-ntlm": "^1.4.2", - "debug": "^4.3.6", - "formidable": "^3.5.1", + "axios": "^1.12.2", + "axios-ntlm": "^1.4.6", + "debug": "^4.4.3", + "formidable": "^3.5.4", "get-stream": "^6.0.1", "lodash": "^4.17.21", "sax": "^1.4.1", "strip-bom": "^3.0.0", "whatwg-mimetype": "4.0.0", - "xml-crypto": "^6.0.0" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/soap/node_modules/debug": { - "version": "4.3.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", - "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" + "xml-crypto": "^6.1.2" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=14.17.0" } }, - "node_modules/soap/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/soap/node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -6572,6 +7242,7 @@ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" } @@ -6581,6 +7252,7 @@ "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", "dev": true, + "license": "MIT", "dependencies": { "buffer-from": "^1.0.0", "source-map": "^0.6.0" @@ -6590,7 +7262,8 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true + "dev": true, + "license": "BSD-3-Clause" }, "node_modules/stack-trace": { "version": "0.0.10", @@ -6605,6 +7278,7 @@ "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", "dev": true, + "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" }, @@ -6613,9 +7287,9 @@ } }, "node_modules/statuses": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", - "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", "license": "MIT", "engines": { "node": ">= 0.8" @@ -6635,6 +7309,7 @@ "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", "dev": true, + "license": "MIT", "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" @@ -6643,11 +7318,54 @@ "node": ">=10" } }, + "node_modules/string-length/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-length/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": { + "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/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", @@ -6657,11 +7375,59 @@ "node": ">=8" } }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "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/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "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" }, @@ -6669,11 +7435,22 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -6683,6 +7460,7 @@ "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -6692,6 +7470,7 @@ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -6700,48 +7479,35 @@ } }, "node_modules/superagent": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", - "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==", + "version": "10.2.3", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", + "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", "dev": true, "license": "MIT", "dependencies": { - "component-emitter": "^1.3.0", + "component-emitter": "^1.3.1", "cookiejar": "^2.1.4", - "debug": "^4.3.4", + "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.0", - "formidable": "^3.5.1", + "form-data": "^4.0.4", + "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", - "qs": "^6.11.0" + "qs": "^6.11.2" }, "engines": { "node": ">=14.18.0" } }, - "node_modules/superagent/node_modules/mime": { - "version": "2.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", - "integrity": "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==", - "dev": true, - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/supertest": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.0.0.tgz", - "integrity": "sha512-qlsr7fIC0lSddmA3tzojvzubYxvlGtzumcdHgPwbFWMISQwL22MhM2Y3LNt+6w9Yyx7559VW5ab70dgphm8qQA==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", + "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", "dev": true, "license": "MIT", "dependencies": { "methods": "^1.1.2", - "superagent": "^9.0.1" + "superagent": "^10.2.3" }, "engines": { "node": ">=14.18.0" @@ -6759,16 +7525,20 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "node_modules/synckit": { + "version": "0.11.11", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", + "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, "engines": { - "node": ">= 0.4" + "node": "^14.18.0 || >=16.0.0" }, "funding": { - "url": "https://github.com/sponsors/ljharb" + "url": "https://opencollective.com/synckit" } }, "node_modules/test-exclude": { @@ -6776,6 +7546,7 @@ "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", "dev": true, + "license": "ISC", "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^7.1.4", @@ -6785,25 +7556,49 @@ "node": ">=8" } }, + "node_modules/test-exclude/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", - "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==" + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" }, "node_modules/tiff": { "version": "5.0.3", "resolved": "https://registry.npmjs.org/tiff/-/tiff-5.0.3.tgz", "integrity": "sha512-R0WckwRGhawWDNdha8iPQCjHyOiaEEmfFjhmalUVCIEELsON7Y/XO3eeGmBkoCXQp0Gg2nmTozN92Z4hlwbsow==", "dev": true, + "license": "MIT", "dependencies": { "iobuffer": "^5.0.4", "pako": "^2.0.4" } }, "node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "dev": true, "license": "MIT", "engines": { @@ -6816,20 +7611,12 @@ "integrity": "sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==", "dev": true }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", "dev": true, + "license": "MIT", "dependencies": { "is-number": "^7.0.0" }, @@ -6888,20 +7675,20 @@ } }, "node_modules/ts-jest": { - "version": "29.2.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", - "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", + "version": "29.4.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", + "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", "dev": true, "license": "MIT", "dependencies": { "bs-logger": "^0.2.6", - "ejs": "^3.1.10", "fast-json-stable-stringify": "^2.1.0", - "jest-util": "^29.0.0", + "handlebars": "^4.7.8", "json5": "^2.2.3", "lodash.memoize": "^4.1.2", "make-error": "^1.3.6", - "semver": "^7.6.3", + "semver": "^7.7.3", + "type-fest": "^4.41.0", "yargs-parser": "^21.1.1" }, "bin": { @@ -6912,10 +7699,11 @@ }, "peerDependencies": { "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.0.0", + "@jest/transform": "^29.0.0 || ^30.0.0", + "@jest/types": "^29.0.0 || ^30.0.0", + "babel-jest": "^29.0.0 || ^30.0.0", + "jest": "^29.0.0 || ^30.0.0", + "jest-util": "^29.0.0 || ^30.0.0", "typescript": ">=4.3 <6" }, "peerDependenciesMeta": { @@ -6933,13 +7721,16 @@ }, "esbuild": { "optional": true + }, + "jest-util": { + "optional": true } } }, "node_modules/ts-jest/node_modules/semver": { - "version": "7.6.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", - "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -6949,12 +7740,26 @@ "node": ">=10" } }, + "node_modules/ts-jest/node_modules/type-fest": { + "version": "4.41.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", + "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/ts-md5": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-1.3.1.tgz", - "integrity": "sha512-DiwiXfwvcTeZ5wCE0z+2A9EseZsztaiZtGrtSaY5JOD7ekPnR/GoIVD5gXZAlK9Na9Kvpo9Waz5rW64WKAWApg==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-2.0.1.tgz", + "integrity": "sha512-yF35FCoEOFBzOclSkMNEUbFQZuv89KEQ+5Xz03HrMSGUGB1+r+El+JiGOFwsP4p9RFNzwlrydYoTLvPOuICl9w==", + "license": "MIT", "engines": { - "node": ">=12" + "node": ">=18" } }, "node_modules/ts-mockito": { @@ -6971,6 +7776,7 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, + "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7032,6 +7838,7 @@ "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -7041,6 +7848,7 @@ "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" }, @@ -7049,13 +7857,35 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "mime-db": "^1.54.0" }, "engines": { "node": ">= 0.6" @@ -7065,15 +7895,17 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz", "integrity": "sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==", + "license": "MIT", "optionalDependencies": { "rxjs": "*" } }, "node_modules/typescript": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.2.tgz", - "integrity": "sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg==", + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7082,6 +7914,20 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -7095,9 +7941,10 @@ "license": "MIT" }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" }, "node_modules/universalify": { "version": "2.0.1", @@ -7116,10 +7963,45 @@ "node": ">= 0.8" } }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", "dev": true, "funding": [ { @@ -7135,9 +8017,10 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.2.0", + "picocolors": "^1.1.1" }, "bin": { "update-browserslist-db": "cli.js" @@ -7157,18 +8040,10 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/uuid": { - "version": "11.0.3", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.0.3.tgz", - "integrity": "sha512-d0z310fCWv5dJwnX1Y/MncBAqGMKEzlBb1AOf7z9K8ALnd0utBX/msg/fA0+sbyN1ihbMsLhrBlnl1ak7Wa0rg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -7185,10 +8060,11 @@ "dev": true }, "node_modules/v8-to-istanbul": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.2.0.tgz", - "integrity": "sha512-/EH/sDgxU2eGxajKdwLCDmQ4FWq+kpi3uCmBGpw1xJtnAxEjlD8j8PEiGWpCIMIs3ciNAgH0d3TTJiUkYzyZjA==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", "dev": true, + "license": "ISC", "dependencies": { "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", @@ -7219,7 +8095,8 @@ "version": "0.2.0", "resolved": "https://registry.npmjs.org/web-worker-manager/-/web-worker-manager-0.2.0.tgz", "integrity": "sha512-WmGabA4GLth1ju9VLm/oMDcPMhMngHoBSdY1OMhrEJvNsPl7z2p+7RBOXjEi5zlP0dK+Shd3Wm+BdD5WZrNYBA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/webidl-conversions": { "version": "3.0.1", @@ -7249,6 +8126,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -7260,13 +8138,13 @@ } }, "node_modules/winston": { - "version": "3.17.0", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", - "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", + "version": "3.18.3", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", + "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", - "@dabh/diagnostics": "^2.0.2", + "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", @@ -7295,11 +8173,38 @@ "node": ">= 12.0.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "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/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", @@ -7312,28 +8217,101 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/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/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/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/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/write-file-atomic": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", - "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-5.0.1.tgz", + "integrity": "sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", - "signal-exit": "^3.0.7" + "signal-exit": "^4.0.1" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/write-file-atomic/node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", "engines": { "node": ">=10.0.0" }, @@ -7351,9 +8329,9 @@ } }, "node_modules/xml-crypto": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.0.0.tgz", - "integrity": "sha512-L3RgnkaDrHaYcCnoENv4Idzt1ZRj5U1z1BDH98QdDTQfssScx8adgxhd9qwyYo+E3fXbQZjEQH7aiXHLVgxGvw==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz", + "integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==", "license": "MIT", "dependencies": { "@xmldom/is-dom-node": "^1.0.1", @@ -7365,9 +8343,9 @@ } }, "node_modules/xml-crypto/node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -7386,6 +8364,7 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/xmldom-ts/-/xmldom-ts-0.3.1.tgz", "integrity": "sha512-dmEBAK3Msm+BPVZOiwhXCyM0/q3BeiI4eoAPj2Us1nDhsPPhePtZ5RkgEdngNQQFp3j6QFKMLHlBIRUxdpomcQ==", + "license": "MIT", "engines": { "node": ">=0.1" } @@ -7410,6 +8389,7 @@ "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", "dev": true, + "license": "ISC", "engines": { "node": ">=10" } @@ -7418,13 +8398,15 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true + "dev": true, + "license": "ISC" }, "node_modules/yargs": { "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, + "license": "MIT", "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", @@ -7447,6 +8429,51 @@ "node": ">=12" } }, + "node_modules/yargs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/yargs/node_modules/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/yargs/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/yn": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", @@ -7461,6 +8488,7 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, diff --git a/package.json b/package.json index 5686c27..aa23174 100644 --- a/package.json +++ b/package.json @@ -6,54 +6,55 @@ "author": "simojenki ", "license": "GPL-3.0-only", "dependencies": { - "@svrooij/sonos": "^2.6.0-beta.11", - "@types/express": "^4.17.21", + "@svrooij/sonos": "^2.6.0-beta.12", + "@types/express": "^5.0.5", "@types/fs-extra": "^11.0.4", - "@types/jsonwebtoken": "^9.0.7", - "@types/jws": "^3.2.10", - "@types/morgan": "^1.9.9", - "@types/node": "^20.11.5", + "@types/jsonwebtoken": "^9.0.10", + "@types/jws": "^3.2.11", + "@types/morgan": "^1.9.10", + "@types/node": "^24.10.0", "@types/randomstring": "^1.3.0", "@types/underscore": "^1.13.0", "@types/uuid": "^10.0.0", "@types/xmldom": "^0.1.34", "@xmldom/xmldom": "^0.9.7", - "axios": "^1.7.8", - "dayjs": "^1.11.13", + "axios": "^1.13.1", + "dayjs": "^1.11.19", "eta": "^2.2.0", - "express": "^4.18.3", - "fp-ts": "^2.16.9", - "fs-extra": "^11.2.0", + "express": "^5.1.0", + "fp-ts": "^2.16.11", + "fs-extra": "^11.3.2", "jsonwebtoken": "^9.0.2", "jws": "^4.0.0", - "morgan": "^1.10.0", - "node-html-parser": "^6.1.13", - "randomstring": "^1.3.0", - "sharp": "^0.33.5", - "soap": "^1.1.6", - "ts-md5": "^1.3.1", - "typescript": "^5.7.2", + "morgan": "^1.10.1", + "node-html-parser": "^7.0.1", + "randomstring": "^1.3.1", + "sharp": "^0.34.4", + "soap": "^1.6.0", + "ts-md5": "^2.0.1", + "typescript": "^5.9.3", "underscore": "^1.13.7", "urn-lib": "^2.0.0", - "uuid": "^11.0.3", - "winston": "^3.17.0", + "uuid": "^11.1.0", + "winston": "^3.18.3", "xmldom-ts": "^0.3.1", "xpath": "^0.0.34" }, "devDependencies": { - "@types/chai": "^5.0.1", - "@types/jest": "^29.5.14", + "@types/chai": "^5.2.3", + "@types/jest": "^30.0.0", "@types/mocha": "^10.0.10", - "@types/supertest": "^6.0.2", + "@types/supertest": "^6.0.3", "@types/tmp": "^0.2.6", - "chai": "^5.1.2", + "chai": "^6.2.0", "get-port": "^7.1.0", - "image-js": "^0.35.6", - "jest": "^29.7.0", - "nodemon": "^3.1.7", - "supertest": "^7.0.0", - "tmp": "^0.2.3", - "ts-jest": "^29.2.5", + "image-js": "^0.37.0", + "jest": "^30.2.0", + "nodemon": "^3.1.10", + "npm-check-updates": "^19.1.2", + "supertest": "^7.1.4", + "tmp": "^0.2.5", + "ts-jest": "^29.4.5", "ts-mockito": "^2.6.1", "ts-node": "^10.9.2", "xpath-ts": "^1.3.13" diff --git a/src/config.ts b/src/config.ts index 4ed7f6f..bbf4ecc 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,7 @@ import { hostname } from "os"; import logger from "./logger"; import url from "./url_builder"; +import { StringValue } from 'ms' export const WORD = /^\w+$/; export const COLOR = /^#?\w+$/; @@ -76,7 +77,7 @@ export default function () { port, bonobUrl: url(bonobUrl), secret: bnbEnvVar("SECRET", { default: "bonob" })!, - authTimeout: bnbEnvVar("AUTH_TIMEOUT", { default: "1h" })!, + authTimeout: bnbEnvVar("AUTH_TIMEOUT", { default: "1h" })!, icons: { foregroundColor: bnbEnvVar("ICON_FOREGROUND_COLOR", { validationPattern: COLOR, diff --git a/src/smapi_auth.ts b/src/smapi_auth.ts index 8db73a6..105138f 100644 --- a/src/smapi_auth.ts +++ b/src/smapi_auth.ts @@ -2,6 +2,7 @@ import { either as E } from "fp-ts"; import jwt from "jsonwebtoken"; import { b64Decode, b64Encode } from "./b64"; import { Clock } from "./clock"; +import { StringValue } from 'ms' export type SmapiFault = { Fault: { faultcode: string; faultstring: string } }; export type SmapiRefreshTokenResultFault = SmapiFault & { @@ -119,13 +120,13 @@ export const SMAPI_TOKEN_VERSION = 3; export class JWTSmapiLoginTokens implements SmapiAuthTokens { private readonly clock: Clock; private readonly secret: string; - private readonly expiresIn: string; + private readonly expiresIn: StringValue; private readonly key: string; constructor( clock: Clock, secret: string, - expiresIn: string, + expiresIn: StringValue, version: number = SMAPI_TOKEN_VERSION ) { this.clock = clock; diff --git a/tests/config.test.ts b/tests/config.test.ts index 6324832..cae32ac 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -70,7 +70,7 @@ describe("envVar", () => { envVar("bnb-var", { validationPattern: /^foobar$/, }) - ).toThrowError( + ).toThrow( `Invalid value specified for 'bnb-var', must match ${/^foobar$/}` ); }); diff --git a/tests/i8n.test.ts b/tests/i8n.test.ts index 5f7cbf4..47fa604 100644 --- a/tests/i8n.test.ts +++ b/tests/i8n.test.ts @@ -183,11 +183,10 @@ describe("i8n", () => { describe("when the lang exists but the KEY doesnt", () => { it("should blow up", () => { - expect(() => i8n("foo")("en-US")("foobar123" as KEY)).toThrowError( + expect(() => i8n("foo")("en-US")("foobar123" as KEY)).toThrow( "No translation found for en-US:foobar123" ); }); }); - }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index be5fd08..3bc77c5 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -41,7 +41,7 @@ describe("rangeFilterFor", () => { ]; for (let range in cases) { - expect(() => rangeFilterFor(range)).toThrowError( + expect(() => rangeFilterFor(range)).toThrow( `Unsupported range: ${range}` ); } @@ -71,7 +71,7 @@ describe("rangeFilterFor", () => { describe("-900", () => { it("should fail", () => { - expect(() => rangeFilterFor("bytes=-900")).toThrowError( + expect(() => rangeFilterFor("bytes=-900")).toThrow( "Unsupported range: bytes=-900" ); }); @@ -79,7 +79,7 @@ describe("rangeFilterFor", () => { describe("100-200", () => { it("should fail", () => { - expect(() => rangeFilterFor("bytes=100-200")).toThrowError( + expect(() => rangeFilterFor("bytes=100-200")).toThrow( "Unsupported range: bytes=100-200" ); }); @@ -87,7 +87,7 @@ describe("rangeFilterFor", () => { describe("100-200, 400-500", () => { it("should fail", () => { - expect(() => rangeFilterFor("bytes=100-200, 400-500")).toThrowError( + expect(() => rangeFilterFor("bytes=100-200, 400-500")).toThrow( "Unsupported range: bytes=100-200, 400-500" ); }); @@ -103,7 +103,7 @@ describe("rangeFilterFor", () => { ]; for (let range in cases) { - expect(() => rangeFilterFor(range)).toThrowError( + expect(() => rangeFilterFor(range)).toThrow( `Unsupported range: ${range}` ); } From 8143455fa4cf322e5e49c31bd8d83aa866cbc452 Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 7 Nov 2025 01:31:32 +0000 Subject: [PATCH 06/51] Rollback ts-md5 upgrade as breaks startup --- package-lock.json | 16 +++++----------- package.json | 2 +- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/package-lock.json b/package-lock.json index e8bfcfc..957898c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -34,7 +34,7 @@ "randomstring": "^1.3.1", "sharp": "^0.34.4", "soap": "^1.6.0", - "ts-md5": "^2.0.1", + "ts-md5": "^1.3.1", "typescript": "^5.9.3", "underscore": "^1.13.7", "urn-lib": "^2.0.0", @@ -94,7 +94,6 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1911,7 +1910,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -2726,7 +2724,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.19", "caniuse-lite": "^1.0.30001751", @@ -4705,7 +4702,6 @@ "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@jest/core": "30.2.0", "@jest/types": "30.2.0", @@ -7754,12 +7750,12 @@ } }, "node_modules/ts-md5": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-2.0.1.tgz", - "integrity": "sha512-yF35FCoEOFBzOclSkMNEUbFQZuv89KEQ+5Xz03HrMSGUGB1+r+El+JiGOFwsP4p9RFNzwlrydYoTLvPOuICl9w==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-1.3.1.tgz", + "integrity": "sha512-DiwiXfwvcTeZ5wCE0z+2A9EseZsztaiZtGrtSaY5JOD7ekPnR/GoIVD5gXZAlK9Na9Kvpo9Waz5rW64WKAWApg==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/ts-mockito": { @@ -7776,7 +7772,6 @@ "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", "dev": true, - "peer": true, "dependencies": { "@cspotcode/source-map-support": "^0.8.0", "@tsconfig/node10": "^1.0.7", @@ -7905,7 +7900,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index aa23174..7218629 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "randomstring": "^1.3.1", "sharp": "^0.34.4", "soap": "^1.6.0", - "ts-md5": "^2.0.1", + "ts-md5": "^1.3.1", "typescript": "^5.9.3", "underscore": "^1.13.7", "urn-lib": "^2.0.0", From 200092ac358674e9de4f1d96a1b195d9a3a4537f Mon Sep 17 00:00:00 2001 From: Simon J Date: Mon, 10 Nov 2025 10:36:31 +1100 Subject: [PATCH 07/51] Update README for CF documentation (#232) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index f8b9878..3168c44 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,10 @@ And then configure the 'bonob+audio/mpeg' player in your subsonic server. ![Spotify-ish](https://github.com/simojenki/bonob/blob/master/docs/images/spotify-ish.png?raw=true) +## Notes on using Cloudflare/cloudflared tunnels. +As discussed [here](https://github.com/simojenki/bonob/issues/101#issuecomment-1471635855) and [here](https://github.com/simojenki/bonob/issues/205#issuecomment-3461453809), there is an issue playing tracks via cloudflare. Until otherwise resolved the current 'solution' is to "disable CF proxy feature and leave DNS-only for bonob.example.com record". (Note you may need to wait some time for DNS caches to propogate) + + ## Credits - Icons courtesy of: [Navidrome](https://www.navidrome.org/), [Vectornator](https://www.vectornator.io/icons), and @jicho From 4499a3e2d00d09308b4557e7b31bef818e75fe3d Mon Sep 17 00:00:00 2001 From: Simon J Date: Mon, 10 Nov 2025 16:07:50 +1100 Subject: [PATCH 08/51] Ability to specify login page style: classic, navidrome-ish, wkulhanek (#230) --- package.json | 2 +- src/app.ts | 3 +- src/config.ts | 15 + src/server.ts | 18 +- tests/config.test.ts | 16 + tests/server.test.ts | 193 ++-- .../android-icon-192x192-D_ka5daf.png | Bin 0 -> 9876 bytes web/public/navidrome-ish/index-B3wIDoCy.css | 1 + web/views/login.eta | 13 - web/views/login/classic/login.eta | 19 + web/views/login/classic/success.eta | 5 + web/views/login/navidrome-ish/layout.eta | 851 ++++++++++++++++++ web/views/login/navidrome-ish/login.eta | 54 ++ web/views/login/navidrome-ish/success.eta | 26 + web/views/login/wkulhanek/layout.eta | 193 ++++ web/views/login/wkulhanek/login.eta | 30 + web/views/login/wkulhanek/success.eta | 9 + 17 files changed, 1333 insertions(+), 115 deletions(-) create mode 100644 web/public/navidrome-ish/android-icon-192x192-D_ka5daf.png create mode 100644 web/public/navidrome-ish/index-B3wIDoCy.css delete mode 100644 web/views/login.eta create mode 100644 web/views/login/classic/login.eta create mode 100644 web/views/login/classic/success.eta create mode 100644 web/views/login/navidrome-ish/layout.eta create mode 100644 web/views/login/navidrome-ish/login.eta create mode 100644 web/views/login/navidrome-ish/success.eta create mode 100644 web/views/login/wkulhanek/layout.eta create mode 100644 web/views/login/wkulhanek/login.eta create mode 100644 web/views/login/wkulhanek/success.eta diff --git a/package.json b/package.json index 7218629..96bf7f8 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "scripts": { "clean": "rm -Rf build node_modules", "build": "tsc", - "dev80": "BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", + "dev80": "BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", "dev": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=false BNB_SONOS_AUTO_REGISTER=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", "devr": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"${BNB_DEV_LOCAL_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", "register-dev": "ts-node ./src/register.ts ${BNB_DEV_URL}", diff --git a/src/app.ts b/src/app.ts index da16958..25c096e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -95,7 +95,8 @@ const app = server( logRequests: config.logRequests, version, smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout), - externalImageResolver: artistImageFetcher + externalImageResolver: artistImageFetcher, + loginTheme: config.loginTheme } ); diff --git a/src/config.ts b/src/config.ts index bbf4ecc..71fcfc7 100644 --- a/src/config.ts +++ b/src/config.ts @@ -59,6 +59,20 @@ const asBoolean = (value: string) => value == "true"; const asInt = (value: string) => Number.parseInt(value); +export const DEFAULT_LOGIN_THEME = "classic" +const VALID_LOGIN_THEMES = [DEFAULT_LOGIN_THEME, "navidrome-ish", "wkulhanek"] + +const cleanLoginTheme = (value: string) => { + if(VALID_LOGIN_THEMES.includes(value)) { + return value + } else { + logger.error( + `Invalid valid of '${value}' for BNB_LOGIN_THEME, defaulting to '${DEFAULT_LOGIN_THEME}'` + ); + return DEFAULT_LOGIN_THEME + } +} + export default function () { const port = bnbEnvVar("PORT", { default: 4534, parser: asInt })!; const bonobUrl = bnbEnvVar("URL", { @@ -106,5 +120,6 @@ export default function () { scrobbleTracks: bnbEnvVar("SCROBBLE_TRACKS", { default: true, parser: asBoolean }), reportNowPlaying: bnbEnvVar("REPORT_NOW_PLAYING", { default: true, parser: asBoolean }), + loginTheme: bnbEnvVar("LOGIN_THEME", { default: "classic", parser: cleanLoginTheme }), }; } diff --git a/src/server.ts b/src/server.ts index d0f6131..f41c6a3 100644 --- a/src/server.ts +++ b/src/server.ts @@ -33,6 +33,7 @@ import { pipe } from "fp-ts/lib/function"; import { URLBuilder } from "./url_builder"; import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n"; import { Icon, ICONS, festivals, features, no_festivals } from "./icon"; +import { DEFAULT_LOGIN_THEME } from './config' import _ from "underscore"; import morgan from "morgan"; import { parse } from "./burn"; @@ -102,6 +103,7 @@ export type ServerOpts = { version: string; smapiAuthTokens: SmapiAuthTokens; externalImageResolver: ImageFetcher; + loginTheme: string; }; const DEFAULT_SERVER_OPTS: ServerOpts = { @@ -118,6 +120,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = { "1m" ), externalImageResolver: axiosImageFetcher, + loginTheme: DEFAULT_LOGIN_THEME }; function server( @@ -133,6 +136,7 @@ function server( const smapiAuthTokens = serverOpts.smapiAuthTokens; const apiTokens = serverOpts.apiTokens(); const clock = serverOpts.clock; + const loginTheme = serverOpts.loginTheme || "classic" const startUpTime = dayjs(); @@ -230,20 +234,23 @@ function server( app.get(LOGIN_ROUTE, (req, res) => { const lang = langFor(req); - res.render("login", { + res.render(`login/${loginTheme}/login`, { lang, linkCode: req.query.linkCode, loginRoute: bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname(), }); }); + app.post(LOGIN_ROUTE, async (req, res) => { const lang = langFor(req); const { username, password, linkCode } = req.body; if (!linkCodes.has(linkCode)) { - return res.status(400).render("failure", { + return res.status(400).render(`login/${loginTheme}/login`, { lang, + status: "fail", message: lang("invalidLinkCode"), + loginRoute: bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname(), }); } else { return pipe( @@ -254,18 +261,21 @@ function server( TE.match( (e: AuthFailure) => ({ status: 403, - template: "failure", + template: `login/${loginTheme}/login`, params: { lang, + status: "fail", message: lang("loginFailed"), cause: e.message, + linkCode: linkCode, + loginRoute: bonobUrl.append({ pathname: LOGIN_ROUTE }).pathname(), }, }), (success: AuthSuccess) => { linkCodes.associate(linkCode, success); return { status: 200, - template: "success", + template: `login/${loginTheme}/success`, params: { lang, message: lang("loginSuccessful"), diff --git a/tests/config.test.ts b/tests/config.test.ts index cae32ac..cc7673a 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -244,6 +244,22 @@ describe("config", () => { }); }); + describe("login theme", () => { + it("should default to classic", () => { + expect(config().loginTheme).toEqual("classic"); + }); + + it(`should be overridable to navidrome-ish using BNB_LOGIN_THEME`, () => { + process.env["BNB_LOGIN_THEME"] = "navidrome-ish"; + expect(config().loginTheme).toEqual("navidrome-ish"); + }); + + it(`should be fall back to classic if invalid value is provided`, () => { + process.env["BNB_LOGIN_THEME"] = "not-valid"; + expect(config().loginTheme).toEqual("classic"); + }); + }); + describe("secret", () => { it("should default to bonob", () => { expect(config().secret).toEqual("bonob"); diff --git a/tests/server.test.ts b/tests/server.test.ts index 3bc77c5..c2a090a 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -609,118 +609,119 @@ describe("server", () => { now: jest.fn(), }; - const server = makeServer( - sonos as unknown as Sonos, - theService, - bonobUrl, - musicService as unknown as MusicService, - { - linkCodes: () => linkCodes as unknown as LinkCodes, - apiTokens: () => apiTokens as unknown as APITokens, - clock, - } - ); - - it("should return the login page", async () => { - sonos.register.mockResolvedValue(true); + [ + { loginTheme: null, msg: lang("logInToBonob") }, + { loginTheme: "classic", msg: lang("logInToBonob") }, + { loginTheme: "wkulhanek", msg: lang("logInToBonob") }, + { loginTheme: "navidrome-ish", msg: "Navidrome (via bonob)" }, + ].forEach( ({ loginTheme, msg }) => { + describe(`when the login theme is ${loginTheme}`, () => { + const server = makeServer( + sonos as unknown as Sonos, + theService, + bonobUrl, + musicService as unknown as MusicService, + { + linkCodes: () => linkCodes as unknown as LinkCodes, + apiTokens: () => apiTokens as unknown as APITokens, + clock, + loginTheme: loginTheme || undefined + }, + ); + + it("should return the login page", async () => { + sonos.register.mockResolvedValue(true); - const res = await request(server) - .get(bonobUrl.append({ pathname: "/login" }).path()) - .set("accept-language", acceptLanguage) - .send(); + const res = await request(server) + .get(bonobUrl.append({ pathname: "/login" }).path()) + .set("accept-language", acceptLanguage) + .send(); - expect(res.status).toEqual(200); - expect(res.text).toMatch(`${lang("login")}`); - expect(res.text).toMatch( - `

${lang("logInToBonob")}

` - ); - expect(res.text).toMatch( - `` - ); - expect(res.text).toMatch( - `` - ); - expect(res.text).toMatch( - `` - ); - }); + expect(res.status).toEqual(200); + expect(res.text).toMatch(`${lang("login")}`); + expect(res.text).toMatch(msg); + }); - describe("when the credentials are valid", () => { - it("should return 200 ok and have associated linkCode with user", async () => { - const username = "jane"; - const password = "password100"; - const linkCode = `linkCode-${uuid()}`; - const authSuccess = { - serviceToken: `serviceToken-${uuid()}`, - userId: `${username}-uid`, - nickname: `${username}-nickname`, - }; + describe("when the credentials are valid", () => { + it("should return 200 ok and have associated linkCode with user", async () => { + const username = "jane"; + const password = "password100"; + const linkCode = `linkCode-${uuid()}`; + const authSuccess = { + serviceToken: `serviceToken-${uuid()}`, + userId: `${username}-uid`, + nickname: `${username}-nickname`, + }; - linkCodes.has.mockReturnValue(true); - musicService.generateToken.mockReturnValue(TE.right(authSuccess)) - linkCodes.associate.mockReturnValue(true); + linkCodes.has.mockReturnValue(true); + musicService.generateToken.mockReturnValue(TE.right(authSuccess)) + linkCodes.associate.mockReturnValue(true); - const res = await request(server) - .post(bonobUrl.append({ pathname: "/login" }).pathname()) - .set("accept-language", acceptLanguage) - .type("form") - .send({ username, password, linkCode }) - .expect(200); + const res = await request(server) + .post(bonobUrl.append({ pathname: "/login" }).pathname()) + .set("accept-language", acceptLanguage) + .type("form") + .send({ username, password, linkCode }) + .expect(200); - expect(res.text).toContain(lang("loginSuccessful")); + expect(res.text).toContain(lang("loginSuccessful")); - expect(musicService.generateToken).toHaveBeenCalledWith({ - username, - password, + expect(musicService.generateToken).toHaveBeenCalledWith({ + username, + password, + }); + expect(linkCodes.has).toHaveBeenCalledWith(linkCode); + expect(linkCodes.associate).toHaveBeenCalledWith( + linkCode, + authSuccess + ); + }); }); - expect(linkCodes.has).toHaveBeenCalledWith(linkCode); - expect(linkCodes.associate).toHaveBeenCalledWith( - linkCode, - authSuccess - ); - }); - }); - describe("when credentials are invalid", () => { - it("should return 403 with message", async () => { - const username = "userDoesntExist"; - const password = "password"; - const linkCode = uuid(); - const message = `Invalid user:${username}`; + describe("when credentials are invalid", () => { + it("should return 403 with message", async () => { + const username = "userDoesntExist"; + const password = "password"; + const linkCode = uuid(); + const message = `Invalid user:${username}`; - linkCodes.has.mockReturnValue(true); - musicService.generateToken.mockReturnValue(TE.left(new AuthFailure(message))) + linkCodes.has.mockReturnValue(true); + musicService.generateToken.mockReturnValue(TE.left(new AuthFailure(message))) - const res = await request(server) - .post(bonobUrl.append({ pathname: "/login" }).pathname()) - .set("accept-language", acceptLanguage) - .type("form") - .send({ username, password, linkCode }) - .expect(403); - - expect(res.text).toContain(lang("loginFailed")); - expect(res.text).toContain(message); - }); - }); + const res = await request(server) + .post(bonobUrl.append({ pathname: "/login" }).pathname()) + .set("accept-language", acceptLanguage) + .type("form") + .send({ username, password, linkCode }) + .expect(403); - describe("when linkCode is invalid", () => { - it("should return 400 with message", async () => { - const username = "jane"; - const password = "password100"; - const linkCode = "someLinkCodeThatDoesntExist"; + expect(res.text).toContain(lang("loginFailed")); + expect(res.text).toContain(message); + }); + }); - linkCodes.has.mockReturnValue(false); + describe("when linkCode is invalid", () => { + it("should return 400 with message", async () => { + const username = "jane"; + const password = "password100"; + const linkCode = "someLinkCodeThatDoesntExist"; - const res = await request(server) - .post(bonobUrl.append({ pathname: "/login" }).pathname()) - .set("accept-language", acceptLanguage) - .type("form") - .send({ username, password, linkCode }) - .expect(400); + linkCodes.has.mockReturnValue(false); - expect(res.text).toContain(lang("invalidLinkCode")); + const res = await request(server) + .post(bonobUrl.append({ pathname: "/login" }).pathname()) + .set("accept-language", acceptLanguage) + .type("form") + .send({ username, password, linkCode }) + .expect(400); + + expect(res.text).toContain(lang("invalidLinkCode")); + }); + }); }); - }); + }) + + }); describe("/stream", () => { diff --git a/web/public/navidrome-ish/android-icon-192x192-D_ka5daf.png b/web/public/navidrome-ish/android-icon-192x192-D_ka5daf.png new file mode 100644 index 0000000000000000000000000000000000000000..07c10ba2bdbc4ddc40008e7eeb7f347645ddc754 GIT binary patch literal 9876 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 bY3^pt;_6|WcOgXlp#+eZR*|ZgFbn%X`6Jp) literal 0 HcmV?d00001 diff --git a/web/public/navidrome-ish/index-B3wIDoCy.css b/web/public/navidrome-ish/index-B3wIDoCy.css new file mode 100644 index 0000000..aaba9b2 --- /dev/null +++ b/web/public/navidrome-ish/index-B3wIDoCy.css @@ -0,0 +1 @@ +body{margin:0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,Helvetica Neue,sans-serif;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}code{font-family:source-code-pro,Menlo,Monaco,Consolas,Courier New,monospace}.rc-slider{z-index:78}@keyframes closeWindow{0%{opacity:1}to{opacity:0}}.ril__outer{background-color:#000000d9;outline:none;top:0;left:0;right:0;bottom:0;z-index:1000;width:100%;height:100%;-ms-content-zooming:none;-ms-user-select:none;-ms-touch-select:none;touch-action:none}.ril__outerClosing{opacity:0}.ril__inner{position:absolute;top:0;left:0;right:0;bottom:0}.ril__image,.ril__imagePrev,.ril__imageNext{position:absolute;top:0;right:0;bottom:0;left:0;margin:auto;max-width:none;-ms-content-zooming:none;-ms-user-select:none;-ms-touch-select:none;touch-action:none}.ril__imageDiscourager{background-repeat:no-repeat;background-position:center;background-size:contain}.ril__navButtons{border:none;position:absolute;top:0;bottom:0;width:20px;height:34px;padding:40px 30px;margin:auto;cursor:pointer;opacity:.7}.ril__navButtons:hover{opacity:1}.ril__navButtons:active{opacity:.7}.ril__navButtonPrev{left:0;background:#0003 url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjM0Ij48cGF0aCBkPSJtIDE5LDMgLTIsLTIgLTE2LDE2IDE2LDE2IDEsLTEgLTE1LC0xNSAxNSwtMTUgeiIgZmlsbD0iI0ZGRiIvPjwvc3ZnPg==) no-repeat center}.ril__navButtonNext{right:0;background:#0003 url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjM0Ij48cGF0aCBkPSJtIDEsMyAyLC0yIDE2LDE2IC0xNiwxNiAtMSwtMSAxNSwtMTUgLTE1LC0xNSB6IiBmaWxsPSIjRkZGIi8+PC9zdmc+) no-repeat center}.ril__downloadBlocker{position:absolute;top:0;left:0;right:0;bottom:0;background-image:url(data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7);background-size:cover}.ril__caption,.ril__toolbar{background-color:#00000080;position:absolute;left:0;right:0;display:flex;justify-content:space-between}.ril__caption{bottom:0;max-height:150px;overflow:auto}.ril__captionContent{padding:10px 20px;color:#fff}.ril__toolbar{top:0;height:50px}.ril__toolbarSide{height:50px;margin:0}.ril__toolbarLeftSide{padding-left:20px;padding-right:0;flex:0 1 auto;overflow:hidden;text-overflow:ellipsis}.ril__toolbarRightSide{padding-left:0;padding-right:20px;flex:0 0 auto}.ril__toolbarItem{display:inline-block;line-height:50px;padding:0;color:#fff;font-size:120%;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.ril__toolbarItemChild{vertical-align:middle}.ril__builtinButton{width:40px;height:35px;cursor:pointer;border:none;opacity:.7}.ril__builtinButton:hover{opacity:1}.ril__builtinButton:active{outline:none}.ril__builtinButtonDisabled{cursor:default;opacity:.5}.ril__builtinButtonDisabled:hover{opacity:.5}.ril__closeButton{background:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgd2lkdGg9IjIwIiBoZWlnaHQ9IjIwIj48cGF0aCBkPSJtIDEsMyAxLjI1LC0xLjI1IDcuNSw3LjUgNy41LC03LjUgMS4yNSwxLjI1IC03LjUsNy41IDcuNSw3LjUgLTEuMjUsMS4yNSAtNy41LC03LjUgLTcuNSw3LjUgLTEuMjUsLTEuMjUgNy41LC03LjUgLTcuNSwtNy41IHoiIGZpbGw9IiNGRkYiLz48L3N2Zz4=) no-repeat center}.ril__zoomInButton{background:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEgMTlsNi02Ii8+PHBhdGggZD0iTTkgOGg2Ii8+PHBhdGggZD0iTTEyIDV2NiIvPjwvZz48Y2lyY2xlIGN4PSIxMiIgY3k9IjgiIHI9IjciIGZpbGw9Im5vbmUiIHN0cm9rZT0iI2ZmZiIgc3Ryb2tlLXdpZHRoPSIyIi8+PC9zdmc+) no-repeat center}.ril__zoomOutButton{background:url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyMCIgaGVpZ2h0PSIyMCI+PGcgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEgMTlsNi02Ii8+PHBhdGggZD0iTTkgOGg2Ii8+PC9nPjxjaXJjbGUgY3g9IjEyIiBjeT0iOCIgcj0iNyIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIiLz48L3N2Zz4=) no-repeat center}.ril__outerAnimating{animation-name:closeWindow}@keyframes pointFade{0%,19.999%,to{opacity:0}20%{opacity:1}}.ril__loadingCircle{width:60px;height:60px;position:relative}.ril__loadingCirclePoint{width:100%;height:100%;position:absolute;left:0;top:0}.ril__loadingCirclePoint:before{content:"";display:block;margin:0 auto;width:11%;height:30%;background-color:#fff;border-radius:30%;animation:pointFade .8s infinite ease-in-out both}.ril__loadingCirclePoint:nth-of-type(1){transform:rotate(0)}.ril__loadingCirclePoint:nth-of-type(1):before,.ril__loadingCirclePoint:nth-of-type(7):before{animation-delay:-.8s}.ril__loadingCirclePoint:nth-of-type(2){transform:rotate(30deg)}.ril__loadingCirclePoint:nth-of-type(8){transform:rotate(210deg)}.ril__loadingCirclePoint:nth-of-type(2):before,.ril__loadingCirclePoint:nth-of-type(8):before{animation-delay:-666ms}.ril__loadingCirclePoint:nth-of-type(3){transform:rotate(60deg)}.ril__loadingCirclePoint:nth-of-type(9){transform:rotate(240deg)}.ril__loadingCirclePoint:nth-of-type(3):before,.ril__loadingCirclePoint:nth-of-type(9):before{animation-delay:-533ms}.ril__loadingCirclePoint:nth-of-type(4){transform:rotate(90deg)}.ril__loadingCirclePoint:nth-of-type(10){transform:rotate(270deg)}.ril__loadingCirclePoint:nth-of-type(4):before,.ril__loadingCirclePoint:nth-of-type(10):before{animation-delay:-.4s}.ril__loadingCirclePoint:nth-of-type(5){transform:rotate(120deg)}.ril__loadingCirclePoint:nth-of-type(11){transform:rotate(300deg)}.ril__loadingCirclePoint:nth-of-type(5):before,.ril__loadingCirclePoint:nth-of-type(11):before{animation-delay:-266ms}.ril__loadingCirclePoint:nth-of-type(6){transform:rotate(150deg)}.ril__loadingCirclePoint:nth-of-type(12){transform:rotate(330deg)}.ril__loadingCirclePoint:nth-of-type(6):before,.ril__loadingCirclePoint:nth-of-type(12):before{animation-delay:-133ms}.ril__loadingCirclePoint:nth-of-type(7){transform:rotate(180deg)}.ril__loadingCirclePoint:nth-of-type(13){transform:rotate(360deg)}.ril__loadingCirclePoint:nth-of-type(7):before,.ril__loadingCirclePoint:nth-of-type(13):before{animation-delay:0ms}.ril__loadingContainer{position:absolute;top:0;right:0;bottom:0;left:0}.ril__imagePrev .ril__loadingContainer,.ril__imageNext .ril__loadingContainer{display:none}.ril__errorContainer{position:absolute;top:0;right:0;bottom:0;left:0;display:flex;align-items:center;justify-content:center;color:#fff}.ril__imagePrev .ril__errorContainer,.ril__imageNext .ril__errorContainer{display:none}.ril__loadingContainer__icon{color:#fff;position:absolute;top:50%;left:50%;transform:translate(-50%) translateY(-50%)}.rc-slider{border-radius:6px;height:14px;padding:5px 0;position:relative;touch-action:none;width:100%}.rc-slider,.rc-slider *{-webkit-tap-highlight-color:rgba(0,0,0,0);box-sizing:border-box}.rc-slider-rail{background-color:#e9e9e9;width:100%}.rc-slider-rail,.rc-slider-track{border-radius:6px;height:4px;position:absolute}.rc-slider-track{background-color:#abe2fb;left:0}.rc-slider-handle{background-color:#fff;border:2px solid #96dbfa;border-radius:50%;cursor:pointer;cursor:-webkit-grab;cursor:grab;height:14px;margin-top:-5px;position:absolute;touch-action:pan-x;width:14px}.rc-slider-handle-dragging.rc-slider-handle-dragging.rc-slider-handle-dragging{border-color:#57c5f7;box-shadow:0 0 0 5px #96dbfa}.rc-slider-handle:focus{outline:none}.rc-slider-handle-click-focused:focus{border-color:#96dbfa;box-shadow:unset}.rc-slider-handle:hover{border-color:#57c5f7}.rc-slider-handle:active{border-color:#57c5f7;box-shadow:0 0 5px #57c5f7;cursor:-webkit-grabbing;cursor:grabbing}.rc-slider-mark{font-size:12px;left:0;position:absolute;top:18px;width:100%}.rc-slider-mark-text{color:#999;cursor:pointer;display:inline-block;position:absolute;text-align:center;vertical-align:middle}.rc-slider-mark-text-active{color:#666}.rc-slider-step{background:transparent;height:4px;position:absolute;width:100%}.rc-slider-dot{background-color:#fff;border:2px solid #e9e9e9;border-radius:50%;bottom:-2px;cursor:pointer;height:8px;margin-left:-4px;position:absolute;vertical-align:middle;width:8px}.rc-slider-dot-active{border-color:#96dbfa}.rc-slider-dot-reverse{margin-right:-4px}.rc-slider-disabled{background-color:#e9e9e9}.rc-slider-disabled .rc-slider-track{background-color:#ccc}.rc-slider-disabled .rc-slider-dot,.rc-slider-disabled .rc-slider-handle{background-color:#fff;border-color:#ccc;box-shadow:none;cursor:not-allowed}.rc-slider-disabled .rc-slider-dot,.rc-slider-disabled .rc-slider-mark-text{cursor:not-allowed!important}.rc-slider-vertical{height:100%;padding:0 5px;width:14px}.rc-slider-vertical .rc-slider-rail{height:100%;width:4px}.rc-slider-vertical .rc-slider-track{bottom:0;left:5px;width:4px}.rc-slider-vertical .rc-slider-handle{margin-left:-5px;touch-action:pan-y}.rc-slider-vertical .rc-slider-mark{height:100%;left:18px;top:0}.rc-slider-vertical .rc-slider-step{height:100%;width:4px}.rc-slider-vertical .rc-slider-dot{left:2px;margin-bottom:-4px}.rc-slider-vertical .rc-slider-dot:first-child,.rc-slider-vertical .rc-slider-dot:last-child{margin-bottom:-4px}.rc-slider-tooltip-zoom-down-appear,.rc-slider-tooltip-zoom-down-enter,.rc-slider-tooltip-zoom-down-leave{animation-duration:.3s;animation-fill-mode:both;animation-play-state:paused;display:block!important}.rc-slider-tooltip-zoom-down-appear.rc-slider-tooltip-zoom-down-appear-active,.rc-slider-tooltip-zoom-down-enter.rc-slider-tooltip-zoom-down-enter-active{animation-name:rcSliderTooltipZoomDownIn;animation-play-state:running}.rc-slider-tooltip-zoom-down-leave.rc-slider-tooltip-zoom-down-leave-active{animation-name:rcSliderTooltipZoomDownOut;animation-play-state:running}.rc-slider-tooltip-zoom-down-appear,.rc-slider-tooltip-zoom-down-enter{animation-timing-function:cubic-bezier(.23,1,.32,1);transform:scale(0)}.rc-slider-tooltip-zoom-down-leave{animation-timing-function:cubic-bezier(.755,.05,.855,.06)}@keyframes rcSliderTooltipZoomDownIn{0%{opacity:0;transform:scale(0);transform-origin:50% 100%}to{transform:scale(1);transform-origin:50% 100%}}@keyframes rcSliderTooltipZoomDownOut{0%{transform:scale(1);transform-origin:50% 100%}to{opacity:0;transform:scale(0);transform-origin:50% 100%}}.rc-slider-tooltip{left:-9999px;position:absolute;top:-9999px;visibility:visible}.rc-slider-tooltip,.rc-slider-tooltip *{-webkit-tap-highlight-color:rgba(0,0,0,0);box-sizing:border-box}.rc-slider-tooltip-hidden{display:none}.rc-slider-tooltip-placement-top{padding:4px 0 8px}.rc-slider-tooltip-inner{background-color:#6c6c6c;border-radius:6px;box-shadow:0 0 4px #d9d9d9;color:#fff;font-size:12px;height:24px;line-height:1;min-width:24px;padding:6px 2px;text-align:center;text-decoration:none}.rc-slider-tooltip-arrow{border-color:transparent;border-style:solid;height:0;position:absolute;width:0}.rc-slider-tooltip-placement-top .rc-slider-tooltip-arrow{border-top-color:#6c6c6c;border-width:4px 4px 0;bottom:4px;left:50%;margin-left:-4px}.rc-switch{background-color:#ccc;border:1px solid #ccc;border-radius:20px;box-sizing:border-box;cursor:pointer;display:inline-block;height:22px;line-height:20px;padding:0;position:relative;transition:all .3s cubic-bezier(.35,0,.25,1);vertical-align:middle;width:44px}.rc-switch-inner{color:#fff;font-size:12px;left:24px;position:absolute;top:0}.rc-switch:after{animation-duration:.3s;animation-name:rcSwitchOff;animation-timing-function:cubic-bezier(.35,0,.25,1);background-color:#fff;border-radius:50%;box-shadow:0 2px 5px #00000042;content:" ";cursor:pointer;height:18px;left:2px;position:absolute;top:1px;transform:scale(1);transition:left .3s cubic-bezier(.35,0,.25,1);width:18px}.rc-switch:hover:after{animation-name:rcSwitchOn;transform:scale(1.1)}.rc-switch:focus{box-shadow:0 0 0 2px #d5f1fd;outline:none}.rc-switch-checked{background-color:#87d068;border:1px solid #87d068}.rc-switch-checked .rc-switch-inner{left:6px}.rc-switch-checked:after{left:22px}.rc-switch-disabled{background:#ccc;border-color:#ccc;cursor:no-drop}.rc-switch-disabled:after{animation-name:none;background:#9e9e9e;cursor:no-drop}.rc-switch-disabled:hover:after{animation-name:none;transform:scale(1)}.rc-switch-label{display:inline-block;font-size:14px;line-height:20px;padding-left:10px;pointer-events:none;-webkit-user-select:text;user-select:text;vertical-align:middle;white-space:normal}@keyframes rcSwitchOn{0%{transform:scale(1)}50%{transform:scale(1.25)}to{transform:scale(1.1)}}@keyframes rcSwitchOff{0%{transform:scale(1.1)}to{transform:scale(1)}}.react-jinke-music-player-main:focus{outline:none}.react-jinke-music-player-main li,.react-jinke-music-player-main ul{list-style-type:none;margin:0;padding:0}.react-jinke-music-player-main *{box-sizing:border-box}.react-jinke-music-player-main .text-center{text-align:center}.react-jinke-music-player-main .hidden{display:none!important}.react-jinke-music-player-main .loading{animation:audioLoading 1s linear infinite;display:inline-flex}.react-jinke-music-player-main .loading svg{color:#31c27c;font-size:24px}.react-jinke-music-player-main .translate{animation:translate .35s cubic-bezier(.43,-.1,.16,1.1) forwards}.react-jinke-music-player-main .scale{animation:scaleTo .35s cubic-bezier(.43,-.1,.16,1.1) forwards}@keyframes playing{to{transform:rotateX(1turn)}}@keyframes coverReset{to{transform:rotate(0)}}@keyframes audioLoading{0%{transform:rotate(0)}to{transform:rotate(1turn)}}@keyframes scale{0%{transform:scale(0)}50%{opacity:.6;transform:scale(1.5)}to{opacity:0;transform:scale(2)}}@keyframes scaleTo{0%{opacity:0;transform:scale(0)}to{opacity:1;transform:scale(1)}}@keyframes scaleFrom{0%{opacity:1;transform:scale(1)}to{opacity:0;transform:scale(0)}}@keyframes imgRotate{0%{transform:rotate(0)}to{transform:rotate(1turn)}}@keyframes fromTo{0%{transform:scale(1) translate3d(0,110%,0)}to{transform:scale(1) translateZ(0)}}@keyframes fromOut{0%{transform:scale(1) translateZ(0)}to{transform:scale(1) translate3d(0,110%,0)}}@keyframes fromDown{0%{transform:scale(1) translate3d(0,-110%,0)}to{transform:scale(1) translateZ(0)}}@keyframes translate{0%{opacity:0;transform:translate3d(100%,0,0)}to{opacity:1;transform:translateZ(0)}}@keyframes remove{0%{opacity:1;transform:translateZ(0)}to{opacity:0;transform:translate3d(-100%,0,0)}}.react-jinke-music-player-main .img-rotate-pause{animation-play-state:paused!important}.react-jinke-music-player-main .img-rotate-reset{animation:coverReset .35s cubic-bezier(.43,-.1,.16,1.1) forwards!important}.react-jinke-music-player-mobile{background-color:#000000bf;bottom:0;color:#fff;display:flex;flex-direction:column;justify-content:space-between;left:0;overflow:hidden;padding:20px;position:fixed;right:0;top:0;width:100%;z-index:999}.react-jinke-music-player-mobile>.group{flex:1 1 auto}.react-jinke-music-player-mobile .show{animation:mobile-bg-show .35s cubic-bezier(.43,-.1,.16,1.1) forwards}.react-jinke-music-player-mobile .hide{animation:mobile-bg-hide .35s cubic-bezier(.43,-.1,.16,1.1) forwards}.react-jinke-music-player-mobile-play-model-tip{align-items:center;background-color:#31c27c;box-shadow:0 2px 20px #0000001a;color:#fff;display:flex;height:35px;left:0;line-height:35px;padding:0 20px;position:fixed;top:0;transform:translate3d(0,-105%,0);transition:transform .35s cubic-bezier(.43,-.1,.16,1.1);width:100%;z-index:1000}.react-jinke-music-player-mobile-play-model-tip-title{margin-right:12px}.react-jinke-music-player-mobile-play-model-tip-title svg{animation:none!important;vertical-align:text-bottom!important}@media screen and (max-width:767px){.react-jinke-music-player-mobile-play-model-tip-title svg{color:#fff!important;font-size:19px}}.react-jinke-music-player-mobile-play-model-tip-title .loop-btn{display:flex}.react-jinke-music-player-mobile-play-model-tip-text{font-size:14px}.react-jinke-music-player-mobile-play-model-tip.show{transform:translateZ(0)}.react-jinke-music-player-mobile-header{align-items:center;animation:fromDown .35s cubic-bezier(.43,-.1,.16,1.1) forwards;display:flex;justify-content:center;left:0;position:relative;top:0;width:100%}.react-jinke-music-player-mobile-header-title{font-size:20px;overflow:hidden;padding:0 30px;text-align:center;text-overflow:ellipsis;transition:color .35s cubic-bezier(.43,-.1,.16,1.1);white-space:nowrap}.react-jinke-music-player-mobile-header .item{display:inline-flex;width:50px}.react-jinke-music-player-mobile-header-right{color:#fff9;cursor:pointer;position:absolute;right:0}.react-jinke-music-player-mobile-header-right svg{font-size:25px}.react-jinke-music-player-mobile-singer{animation:fromDown .35s cubic-bezier(.43,-.1,.16,1.1) forwards;padding:12px 0}@media screen and (max-width:320px){.react-jinke-music-player-mobile-singer{padding:0}}.react-jinke-music-player-mobile-singer-name{font-size:14px;position:relative;transition:color .35s cubic-bezier(.43,-.1,.16,1.1)}.react-jinke-music-player-mobile-singer-name:after,.react-jinke-music-player-mobile-singer-name:before{background-color:#fff9;border-radius:2px;content:"";height:1px;position:absolute;top:9px;transition:background-color .35s cubic-bezier(.43,-.1,.16,1.1);width:16px}.react-jinke-music-player-mobile-singer-name:after{left:-25px}.react-jinke-music-player-mobile-singer-name:before{right:-25px}.react-jinke-music-player-mobile-cover{animation:fromTo .35s cubic-bezier(.43,-.1,.16,1.1) forwards;border:5px solid rgba(0,0,0,.2);border-radius:50%;box-shadow:0 0 1px 3px #0000001a;height:300px;margin:15px auto;overflow:hidden;transition:box-shadow,border .35s cubic-bezier(.43,-.1,.16,1.1);width:300px}@media screen and (max-width:320px){.react-jinke-music-player-mobile-cover{height:230px;margin:10px auto;width:230px}}.react-jinke-music-player-mobile-cover .cover{animation:imgRotate 30s linear infinite;object-fit:cover;width:100%}.react-jinke-music-player-mobile-progress{align-items:center;display:flex;justify-content:space-around}.react-jinke-music-player-mobile-progress .current-time,.react-jinke-music-player-mobile-progress .duration{color:#fff9;display:inline-flex;font-size:12px;transition:color .35s cubic-bezier(.43,-.1,.16,1.1);width:55px}.react-jinke-music-player-mobile-progress .current-time{margin-right:5px}.react-jinke-music-player-mobile-progress .duration{justify-content:flex-end;margin-left:5px}.react-jinke-music-player-mobile-progress .progress-bar{flex:1 1 auto}.react-jinke-music-player-mobile-progress .rc-slider-rail{background-color:#fff9}.react-jinke-music-player-mobile-progress .rc-slider-handle,.react-jinke-music-player-mobile-progress .rc-slider-track{background-color:#31c27c}.react-jinke-music-player-mobile-progress .rc-slider-handle{border:2px solid #fff}.react-jinke-music-player-mobile-progress .rc-slider-handle:active{box-shadow:0 0 2px #31c27c}.react-jinke-music-player-mobile-progress-bar{display:flex;position:relative;width:100%}.react-jinke-music-player-mobile-progress-bar .progress-load-bar{background-color:#0000000f;height:4px;left:0;position:absolute;top:5px;transition:width,background-color .35s cubic-bezier(.43,-.1,.16,1.1);width:0;z-index:77}.react-jinke-music-player-mobile-switch{animation:fromDown .35s cubic-bezier(.43,-.1,.16,1.1) forwards}.react-jinke-music-player-mobile-toggle{padding:17px 0}.react-jinke-music-player-mobile-toggle .group{cursor:pointer}.react-jinke-music-player-mobile-toggle .group svg{font-size:40px}.react-jinke-music-player-mobile-toggle .play-btn{padding:0 40px}.react-jinke-music-player-mobile-toggle .play-btn svg{font-size:45px}.react-jinke-music-player-mobile-toggle .loading-icon{padding:0 40px}@media screen and (max-width:320px){.react-jinke-music-player-mobile-toggle{padding:10px 0}.react-jinke-music-player-mobile-toggle>.group svg{font-size:32px}.react-jinke-music-player-mobile-toggle .play-btn svg{font-size:40px}}.react-jinke-music-player-mobile-operation,.react-jinke-music-player-mobile-progress,.react-jinke-music-player-mobile-toggle{animation:fromTo .35s cubic-bezier(.43,-.1,.16,1.1) forwards}.react-jinke-music-player-mobile-operation .items{align-items:center;display:flex;justify-content:space-around}.react-jinke-music-player-mobile-operation .items .item{cursor:pointer;flex:1;text-align:center}.react-jinke-music-player-mobile-operation .items .item svg{color:#fff9}@keyframes mobile-bg-show{0%{opacity:0}to{opacity:1}}@keyframes mobile-bg-hide{0%{opacity:1}to{opacity:0}}.audio-lists-panel-sortable-highlight-bg{background-color:#31c27c26!important}.audio-lists-panel{background-color:#000000bf;border-radius:4px 4px 0 0;bottom:80px;color:#fffc;display:none\ ;height:410px;overflow:hidden;position:fixed;right:33px;transform:scale(0);transform-origin:right bottom;transition:background-color .35s cubic-bezier(.43,-.1,.16,1.1);width:480px;z-index:999}.audio-lists-panel svg{font-size:24px}.audio-lists-panel.show{animation:scaleTo .35s cubic-bezier(.43,-.1,.16,1.1) forwards;display:block\ }.audio-lists-panel.hide{animation:scaleFrom .35s cubic-bezier(.43,-.1,.16,1.1) forwards;display:none\ }.audio-lists-panel-mobile{background-color:#000000bf;border-radius:0;bottom:0;height:auto!important;left:0;right:0;top:0;transform-origin:bottom center;width:100%!important}.audio-lists-panel-mobile.show{animation:fromTo .35s cubic-bezier(.43,-.1,.16,1.1) forwards;display:block\ }.audio-lists-panel-mobile.hide{animation:fromOut .35s cubic-bezier(.43,-.1,.16,1.1) forwards;display:none\ }.audio-lists-panel-mobile .audio-item:not(.audio-lists-panel-sortable-highlight-bg){background-color:#00000026!important}.audio-lists-panel-mobile .audio-item:not(.audio-lists-panel-sortable-highlight-bg).playing{background-color:#000000bf!important}.audio-lists-panel-mobile .audio-lists-panel-content{-webkit-overflow-scrolling:touch;height:calc(100vh - 50px)!important;transform-origin:bottom center;width:100%!important}.audio-lists-panel-header{border-bottom:1px solid rgba(3,3,3,.75);box-shadow:0 1px 2px #0003;text-shadow:0 1px 1px rgba(0,0,0,.1);transition:background-color,border-bottom .35s cubic-bezier(.43,-.1,.16,1.1)}.audio-lists-panel-header-close-btn,.audio-lists-panel-header-delete-btn{cursor:pointer;display:flex}.audio-lists-panel-header-delete-btn svg{font-size:21px}@media screen and (max-width:767px){.audio-lists-panel-header-delete-btn svg{font-size:19px}}@media screen and (min-width:768px){.audio-lists-panel-header-close-btn:hover svg{animation:imgRotate .35s cubic-bezier(.43,-.1,.16,1.1)}}.audio-lists-panel-header-line{background:#fff;height:20px;margin:0 10px;width:1px}.audio-lists-panel-header-title{align-items:center;display:flex;font-size:16px;font-weight:500;height:50px;margin:0;padding:0 20px;text-align:left;transition:color .35s cubic-bezier(.43,-.1,.16,1.1)}.audio-lists-panel-header-num{margin-left:10px}.audio-lists-panel-header-actions{align-items:center;display:flex;flex-grow:1;justify-content:flex-end}.audio-lists-panel-content{height:359px;overflow-x:hidden;overflow-y:auto}.audio-lists-panel-content.no-content{align-items:center;display:flex;justify-content:center}.audio-lists-panel-content.no-content>span{display:flex}.audio-lists-panel-content .no-data{margin-left:10px}.audio-lists-panel-content .audio-item{align-items:center;border-bottom:1px solid transparent;cursor:pointer;display:flex;font-size:14px;justify-content:space-between;line-height:40px;padding:3px 20px;transition:background-color .35s cubic-bezier(.43,-.1,.16,1.1)}.audio-lists-panel-content .audio-item:nth-child(odd){background-color:#0000001a}.audio-lists-panel-content .audio-item.playing{background-color:#00000059}.audio-lists-panel-content .audio-item.playing,.audio-lists-panel-content .audio-item.playing svg{color:#31c27c}.audio-lists-panel-content .audio-item.remove{animation:remove .35s cubic-bezier(.43,-.1,.16,1.1) forwards}.audio-lists-panel-content .audio-item .player-icons{display:inline-flex;width:30px}.audio-lists-panel-content .audio-item .player-icons .loading{animation:audioLoading 1s linear infinite}.audio-lists-panel-content .audio-item:active,.audio-lists-panel-content .audio-item:hover{background-color:#00000059}.audio-lists-panel-content .audio-item:active .group:not([class=".player-delete"]) svg,.audio-lists-panel-content .audio-item:hover .group:not([class=".player-delete"]) svg{color:#31c27c}.audio-lists-panel-content .audio-item .group{display:inline-flex}.audio-lists-panel-content .audio-item .player-name{flex:1;padding:0 20px 0 10px}.audio-lists-panel-content .audio-item .player-name,.audio-lists-panel-content .audio-item .player-singer{display:inline-block;overflow:hidden;text-overflow:ellipsis;transition:color .35s cubic-bezier(.43,-.1,.16,1.1);white-space:nowrap}.audio-lists-panel-content .audio-item .player-singer{font-size:12px;width:85px}.audio-lists-panel-content .audio-item .player-delete{justify-content:flex-end;text-align:right;width:30px}.audio-lists-panel-content .audio-item .player-delete:hover svg{animation:imgRotate .35s cubic-bezier(.43,-.1,.16,1.1)}.react-jinke-music-player-main{font-family:inherit;touch-action:none}.react-jinke-music-player-main ::-webkit-scrollbar-thumb{background-color:#31c27c;height:20px;opacity:.5}.react-jinke-music-player-main ::-webkit-scrollbar{background-color:#f7f8fa;width:8px}.react-jinke-music-player-main .rc-switch:focus{box-shadow:none}.react-jinke-music-player-main .lyric-btn svg{font-size:20px}.react-jinke-music-player-main .lyric-btn-active,.react-jinke-music-player-main .lyric-btn-active svg{color:#31c27c!important}.react-jinke-music-player-main .music-player-lyric{background:transparent;bottom:100px;color:#31c27c;cursor:move;font-size:36px;left:0;position:fixed;text-align:center;text-shadow:0 1px 1px hsla(0,0%,100%,.05);transition:box-shadow .35s cubic-bezier(.43,-.1,.16,1.1);width:100%;z-index:998}@media screen and (max-width:767px){.react-jinke-music-player-main .music-player-lyric{font-size:16px;z-index:999}}.react-jinke-music-player-main .play-mode-title{background-color:#00000080;bottom:80px;color:#fff;line-height:1.5;opacity:0;padding:5px 20px;pointer-events:none;position:fixed;right:72px;text-align:center;transform:translate3d(100%,0,0);transform-origin:bottom center;transition:all .35s cubic-bezier(.43,-.1,.16,1.1);-webkit-user-select:none;user-select:none;visibility:hidden;z-index:1000}.react-jinke-music-player-main .play-mode-title.play-mode-title-visible{opacity:1;pointer-events:all;transform:translateZ(0);visibility:visible}.react-jinke-music-player-main .glass-bg-container{background-position:50%;background-repeat:no-repeat;background-size:cover;filter:blur(80px);height:300%;left:0;position:absolute;top:0;width:300%;z-index:-1}.react-jinke-music-player-main .glass-bg{-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px)}.react-jinke-music-player-main svg{font-size:24px;transition:color .35s cubic-bezier(.43,-.1,.16,1.1)}.react-jinke-music-player-main svg:active,.react-jinke-music-player-main svg:hover{color:#31c27c}@media screen and (max-width:767px){.react-jinke-music-player-main svg{font-size:22px}}.react-jinke-music-player-main .react-jinke-music-player-mode-icon{animation:scaleTo .35s cubic-bezier(.43,-.1,.16,1.1)}.react-jinke-music-player-main .music-player-panel{background-color:#000000bf;bottom:0;box-shadow:0 0 3px #403f3f;color:#fff;height:80px;left:0;position:fixed;transition:background-color .35s cubic-bezier(.43,-.1,.16,1.1);width:100%;z-index:99}.react-jinke-music-player-main .music-player-panel .panel-content{align-items:center;display:flex;height:100%;justify-content:center;overflow:hidden;padding:0 30px;position:relative}.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-rail{background-color:#fff9}.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle,.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-track{background-color:#31c27c}.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle{border:2px solid #fff}.react-jinke-music-player-main .music-player-panel .panel-content .rc-slider-handle:active{box-shadow:0 0 2px #31c27c}.react-jinke-music-player-main .music-player-panel .panel-content .img-content{background-repeat:no-repeat;background-size:100%;border-radius:50%;box-shadow:0 0 10px #00224d0d;cursor:pointer;height:50px;overflow:hidden;width:50px}@media screen and (max-width:767px){.react-jinke-music-player-main .music-player-panel .panel-content .img-content{height:40px;width:40px}}.react-jinke-music-player-main .music-player-panel .panel-content .img-content img{width:100%}.react-jinke-music-player-main .music-player-panel .panel-content .img-rotate{animation:imgRotate 15s linear infinite}.react-jinke-music-player-main .music-player-panel .panel-content .hide-panel,.react-jinke-music-player-main .music-player-panel .panel-content .upload-music{cursor:pointer;flex-basis:10%;margin-left:15px}@media screen and (max-width:767px){.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content{display:none!important}}.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content{flex:1;overflow:hidden;padding:0 20px}.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .audio-title{display:inline-block;overflow:hidden;text-overflow:ellipsis;transition:color .35s cubic-bezier(.43,-.1,.16,1.1);white-space:nowrap;width:100%}.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .audio-main{display:inline-flex;justify-content:center;margin-top:6px;width:100%}.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .audio-main .current-time,.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .audio-main .duration{flex-basis:5%;font-size:12px;transition:color .35s cubic-bezier(.43,-.1,.16,1.1)}.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .progress-bar{flex:1 1 auto;margin:2px 20px 0;position:relative;width:100%}.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .progress-bar .progress{background:linear-gradient(135deg,transparent,transparent 31%,rgba(0,0,0,.05) 33%,rgba(0,0,0,.05) 67%,transparent 69%);background-color:#31c27c;display:inline-block;height:5px;left:0;position:absolute;top:0;transition:width .35s cubic-bezier(.43,-.1,.16,1.1)}.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .progress-bar .progress .progress-change{background-color:#fff;border-radius:50%;bottom:-2px;box-shadow:0 0 2px #0006;cursor:pointer;height:10px;position:absolute;right:0;width:10px}.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .progress-bar .progress-load-bar{background-color:#0000001c;border-radius:6px;height:4px;left:0;position:absolute;top:5px;transition:width,background-color .35s cubic-bezier(.43,-.1,.16,1.1);width:0;z-index:77}.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .progress-bar .rc-slider-track{z-index:78}.react-jinke-music-player-main .music-player-panel .panel-content .progress-bar-content .progress-bar .rc-slider-handle{z-index:79}.react-jinke-music-player-main .music-player-panel .panel-content .player-content{align-content:center;align-items:center;display:inline-flex;flex-basis:15%;padding-left:5%}.react-jinke-music-player-main .music-player-panel .panel-content .player-content>.group{align-items:center;display:inline-flex;flex:1;justify-content:center;margin:0 10px;text-align:center}.react-jinke-music-player-main .music-player-panel .panel-content .player-content>.group,.react-jinke-music-player-main .music-player-panel .panel-content .player-content>.group>svg{cursor:pointer}.react-jinke-music-player-main .music-player-panel .panel-content .player-content>.group .group{display:flex}@media screen and (max-width:767px){.react-jinke-music-player-main .music-player-panel .panel-content .player-content>.group{margin:0 6px}}@media screen and (max-width:320px){.react-jinke-music-player-main .music-player-panel .panel-content .player-content>.group{margin:0 4px}}.react-jinke-music-player-main .music-player-panel .panel-content .player-content>.group>i{color:#31c27c;cursor:pointer;font-size:25px;vertical-align:middle}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .theme-switch .rc-switch{background-color:transparent}@media screen and (max-width:767px){.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds{display:none!important}}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .next-audio svg,.react-jinke-music-player-main .music-player-panel .panel-content .player-content .prev-audio svg{font-size:35px}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .loading-icon,.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn{padding:0 18px}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-btn svg{font-size:26px}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .loop-btn.active{color:#31c27c}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds{align-items:center}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds svg{font-size:28px}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sounds-icon{display:flex;flex:1 1 auto;margin-right:15px}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .play-sounds .sound-operation{width:100px}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .destroy-btn svg{font-size:28px}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn{background-color:#0000004d;border-radius:40px;box-shadow:0 0 1px 1px #ffffff05;height:28px;min-width:60px;padding:0 10px;position:relative;transition:color,background-color .35s cubic-bezier(.43,-.1,.16,1.1);-webkit-user-select:none;user-select:none}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn .audio-lists-icon{display:flex}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn>.group:hover,.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn>.group:hover>svg{color:#31c27c}.react-jinke-music-player-main .music-player-panel .panel-content .player-content .audio-lists-btn .audio-lists-num{margin-left:8px}.react-jinke-music-player-main .music-player-panel .rc-switch-inner svg{font-size:13px}.react-jinke-music-player-main .rc-slider-rail{background-color:#fff!important;transition:background-color .35s cubic-bezier(.43,-.1,.16,1.1)}.react-jinke-music-player-main.light-theme svg{color:#31c27c}.react-jinke-music-player-main.light-theme svg:active,.react-jinke-music-player-main.light-theme svg:hover{color:#3ece89}.react-jinke-music-player-main.light-theme .rc-slider-rail{background-color:#00000017!important}.react-jinke-music-player-main.light-theme .music-player-controller{background-color:#fff;border-color:#fff}.react-jinke-music-player-main.light-theme .music-player-panel{background-color:#fff;box-shadow:0 1px 2px #00224d0d;color:#7d7d7d}.react-jinke-music-player-main.light-theme .music-player-panel .img-content{box-shadow:0 0 10px #dcdcdc}.react-jinke-music-player-main.light-theme .music-player-panel .progress-load-bar{background-color:#0000000f!important}.react-jinke-music-player-main.light-theme .rc-switch{color:#fff}.react-jinke-music-player-main.light-theme .rc-switch:after{background-color:#fff}.react-jinke-music-player-main.light-theme .rc-switch-checked{background-color:#31c27c!important;border:1px solid #31c27c}.react-jinke-music-player-main.light-theme .rc-switch-inner{color:#fff}.react-jinke-music-player-main.light-theme .audio-lists-btn{background-color:#f7f8fa!important}.react-jinke-music-player-main.light-theme .audio-lists-btn:active,.react-jinke-music-player-main.light-theme .audio-lists-btn:hover{background-color:#fdfdfe;color:#444}.react-jinke-music-player-main.light-theme .audio-lists-btn>.group:hover,.react-jinke-music-player-main.light-theme .audio-lists-btn>.group:hover>svg{color:#444}.react-jinke-music-player-main.light-theme .audio-lists-panel{background-color:#fff;box-shadow:0 0 2px #dcdcdc;color:#444}.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item{background-color:#fff}.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item:nth-child(odd){background-color:#fafafa!important}.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing{background-color:#f2f2f2!important}.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing,.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg{color:#31c27c!important}@media screen and (max-width:767px){.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item{background-color:#fff!important}.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing{background-color:#f2f2f2!important}.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing,.react-jinke-music-player-main.light-theme .audio-lists-panel .audio-item.playing svg{color:#31c27c!important}}.react-jinke-music-player-main.light-theme .audio-lists-panel-header{background-color:#fff;border-bottom:1px solid #f4f4f7;color:#444}.react-jinke-music-player-main.light-theme .audio-lists-panel-header-line{background-color:#f4f4f7}.react-jinke-music-player-main.light-theme .audio-item{background-color:#40444ba6;border-bottom:1px solid hsla(0,0%,86.3%,.26);box-shadow:0 0 2px transparent!important}.react-jinke-music-player-main.light-theme .audio-item:active,.react-jinke-music-player-main.light-theme .audio-item:hover{background-color:#fafafa!important}.react-jinke-music-player-main.light-theme .audio-item:active svg,.react-jinke-music-player-main.light-theme .audio-item:hover svg{color:#31c27c}.react-jinke-music-player-main.light-theme .audio-item.playing{background-color:#fafafa!important}.react-jinke-music-player-main.light-theme .audio-item.playing svg{color:#31c27c}.react-jinke-music-player-main.light-theme .audio-item.playing .player-singer{color:#31c27c!important}.react-jinke-music-player-main.light-theme .audio-item .player-singer{color:#a2a2a273!important}.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile{background-color:#fff;color:#444}.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile-cover{border:5px solid transparent;box-shadow:0 0 30px 2px #0003}.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile .current-time,.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile .duration{color:#444}.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile .rc-slider-rail{background-color:#e9e9e9}.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile-operation svg{color:#444}.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile-tip svg{color:#fff!important}.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile-singer-name{color:#444;transition:color .35s cubic-bezier(.43,-.1,.16,1.1)}.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile-singer-name:after,.react-jinke-music-player-main.light-theme .react-jinke-music-player-mobile-singer-name:before{background-color:#444}.react-jinke-music-player-main.light-theme .play-mode-title{background-color:#fff;color:#31c27c}.react-jinke-music-player{height:80px;position:fixed;width:80px;z-index:999}@media screen and (max-width:767px){.react-jinke-music-player{height:60px;width:60px}}.react-jinke-music-player:focus{outline:none}.react-jinke-music-player .music-player{cursor:pointer;height:80px;position:relative;width:80px}@media screen and (max-width:767px){.react-jinke-music-player .music-player{height:60px;width:60px}}.react-jinke-music-player .music-player:focus{outline:none}.react-jinke-music-player .music-player-audio{display:none!important}.react-jinke-music-player .music-player .destroy-btn{position:absolute;right:0;top:0;z-index:100}@media screen and (max-width:767px){.react-jinke-music-player .music-player .destroy-btn{right:-3px}}.react-jinke-music-player .music-player-controller{align-items:center;background-color:#e6e6e6;background-repeat:no-repeat;background-size:100%;border:1px solid #e6e6e6;border-radius:50%;box-shadow:0 0 10px #00000026;color:#31c27c;cursor:pointer;display:flex;font-size:20px;height:80px;justify-content:center;padding:10px;position:fixed;text-align:center;transition:all .3s cubic-bezier(.43,-.1,.16,1.1);width:80px;z-index:99}.react-jinke-music-player .music-player-controller:focus{outline:none}.react-jinke-music-player .music-player-controller.music-player-playing:before{animation:scale 5s linear infinite;border:1px solid hsla(0,0%,100%,.2);border-radius:50%;content:"";cursor:pointer;height:80px;position:fixed;width:80px;z-index:-1}@media screen and (max-width:767px){.react-jinke-music-player .music-player-controller,.react-jinke-music-player .music-player-controller.music-player-playing:before{height:60px;width:60px}}.react-jinke-music-player .music-player-controller i{font-size:28px}.react-jinke-music-player .music-player-controller:active{box-shadow:0 0 30px #0003}.react-jinke-music-player .music-player-controller:hover{font-size:16px}.react-jinke-music-player .music-player-controller:hover .music-player-controller-setting{transform:scale(1)}.react-jinke-music-player .music-player-controller .controller-title{font-size:14px}@media screen and (max-width:767px){.react-jinke-music-player .music-player-controller i{font-size:20px}.react-jinke-music-player .music-player-controller:active,.react-jinke-music-player .music-player-controller:hover{font-size:12px}.react-jinke-music-player .music-player-controller:active .music-player-controller-setting,.react-jinke-music-player .music-player-controller:hover .music-player-controller-setting{transform:scale(1)}}.react-jinke-music-player .music-player-controller .music-player-controller-setting{align-items:center;background:#31c27c4d;border-radius:50%;color:#fff;display:flex;height:100%;justify-content:center;left:0;position:absolute;top:0;transform:scale(0);transition:all .4s cubic-bezier(.43,-.1,.16,1.1);width:100%}.react-jinke-music-player .audio-circle-process-bar{stroke-width:3px;stroke-linejoin:round;animation:scaleTo .35s cubic-bezier(.43,-.1,.16,1.1);height:80px;left:0;pointer-events:none;position:absolute;top:-80px;width:80px;z-index:100}.react-jinke-music-player .audio-circle-process-bar circle[class=bg]{stroke:#fff}.react-jinke-music-player .audio-circle-process-bar circle[class=stroke]{stroke:#31c27c}.react-jinke-music-player .audio-circle-process-bar,.react-jinke-music-player .audio-circle-process-bar circle{transform:matrix(0,-1,1,0,0,80);transition:stroke-dasharray .35s cubic-bezier(.43,-.1,.16,1.1)}@media screen and (max-width:767px){.react-jinke-music-player .audio-circle-process-bar,.react-jinke-music-player .audio-circle-process-bar circle{transform:matrix(0,-1,1,0,0,60)}} diff --git a/web/views/login.eta b/web/views/login.eta deleted file mode 100644 index 12ee98a..0000000 --- a/web/views/login.eta +++ /dev/null @@ -1,13 +0,0 @@ -<% layout('./layout', { title: it.lang("login") }) %> - -
-

<%= it.lang("logInToBonob") %>

-
-
-

-
-
- - " id="submit"> -
-
\ No newline at end of file diff --git a/web/views/login/classic/login.eta b/web/views/login/classic/login.eta new file mode 100644 index 0000000..14cfe18 --- /dev/null +++ b/web/views/login/classic/login.eta @@ -0,0 +1,19 @@ +<% layout('../../layout', { title: it.lang("login") }) %> + +
+ <% if (it.status == "fail") { %> +

<%= it.message %>

+

<%= it.cause || "" %>

+ <% } else { %> +

<%= it.lang("logInToBonob") %>

+ <% } %> + +
+
+

+
+
+ + " id="submit"> +
+
\ No newline at end of file diff --git a/web/views/login/classic/success.eta b/web/views/login/classic/success.eta new file mode 100644 index 0000000..7f6c19e --- /dev/null +++ b/web/views/login/classic/success.eta @@ -0,0 +1,5 @@ +<% layout('../../layout', { title: it.lang("success") }) %> + +
+

<%= it.message %>

+
\ No newline at end of file diff --git a/web/views/login/navidrome-ish/layout.eta b/web/views/login/navidrome-ish/layout.eta new file mode 100644 index 0000000..615c624 --- /dev/null +++ b/web/views/login/navidrome-ish/layout.eta @@ -0,0 +1,851 @@ + + + + + + + + + + + + + + + + + <%= it.title || "Navidrome (via bonob)" %> + + + + + <%~ it.body %> + + \ No newline at end of file diff --git a/web/views/login/navidrome-ish/login.eta b/web/views/login/navidrome-ish/login.eta new file mode 100644 index 0000000..a3e4a41 --- /dev/null +++ b/web/views/login/navidrome-ish/login.eta @@ -0,0 +1,54 @@ + +<% layout('./layout', { title: it.lang("login") }) %> + +
+
+
+ +
+
+
+ logo +
+ +
+ <% if (it.message != null) { %> +

<%= it.message %>


+ <% } %> +
+
+ <% if (it.cause != null) { %> +

<%= it.cause || "" %>

+ <% } %> +
+
+
+
+ +
+ +
+
+
+
+
+ +
+ +
+
+
+
+
+ +
+
+
+
+
+
diff --git a/web/views/login/navidrome-ish/success.eta b/web/views/login/navidrome-ish/success.eta new file mode 100644 index 0000000..7341def --- /dev/null +++ b/web/views/login/navidrome-ish/success.eta @@ -0,0 +1,26 @@ +<% layout('./layout') %> + +
+
+
+
+
+ logo +
+ +
+

<%= it.message %>


+
+ +
+
+
+
+ diff --git a/web/views/login/wkulhanek/layout.eta b/web/views/login/wkulhanek/layout.eta new file mode 100644 index 0000000..73f0939 --- /dev/null +++ b/web/views/login/wkulhanek/layout.eta @@ -0,0 +1,193 @@ + + + + + + <%= it.title || "bonob" %> + + + + <%~ it.body %> + + \ No newline at end of file diff --git a/web/views/login/wkulhanek/login.eta b/web/views/login/wkulhanek/login.eta new file mode 100644 index 0000000..09508ed --- /dev/null +++ b/web/views/login/wkulhanek/login.eta @@ -0,0 +1,30 @@ +<% layout('./layout', { title: it.lang("login") }) %> + +
+ <% if (it.status == "fail") { %> +
+ +

<%= it.message %>

+ <% if (it.cause) { %> +

<%= it.cause %>

+ <% } %> +

<%= it.lang("failure") %>

+
+ <% } else { %> + +

<%= it.lang("logInToBonob") %>

+ <% } %> + +
+
+ + +
+
+ + +
+ + " id="submit"> +
+
\ No newline at end of file diff --git a/web/views/login/wkulhanek/success.eta b/web/views/login/wkulhanek/success.eta new file mode 100644 index 0000000..0578af3 --- /dev/null +++ b/web/views/login/wkulhanek/success.eta @@ -0,0 +1,9 @@ +<% layout('./layout', { title: it.lang("success") }) %> + +
+
+ +

<%= it.message %>

+

<%= it.lang("success") %>

+
+
\ No newline at end of file From 6335b4c7f42cf8e03f778e278a7a9c623e842209 Mon Sep 17 00:00:00 2001 From: simojenki Date: Tue, 11 Nov 2025 00:28:03 +0000 Subject: [PATCH 09/51] Remove default value for BNB_SECRET, make it mandatory config. Remove redundant key value from various sonos interaction calls, rename bnbt header to authorization --- .devcontainer/devcontainer.json | 3 +- README.md | 2 +- package.json | 6 +- src/config.ts | 14 ++++- src/server.ts | 14 ++--- src/smapi.ts | 20 ++----- src/smapi_auth.ts | 36 ++++-------- tests/builders.ts | 3 +- tests/config.test.ts | 97 ++++++++++++++++++++++++--------- tests/scenarios.test.ts | 1 - tests/server.test.ts | 39 +++++-------- tests/smapi.test.ts | 26 +++------ tests/smapi_auth.test.ts | 26 ++------- 13 files changed, 140 insertions(+), 147 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3fd52d3..97b6236 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -8,7 +8,8 @@ "BNB_DEV_SONOS_DEVICE_IP": "${localEnv:BNB_DEV_SONOS_DEVICE_IP}", "BNB_DEV_URL": "${localEnv:BNB_DEV_URL}", "BNB_DEV_LOCAL_URL": "${localEnv:BNB_DEV_LOCAL_URL}", - "BNB_DEV_SUBSONIC_URL": "${localEnv:BNB_DEV_SUBSONIC_URL}" + "BNB_DEV_SUBSONIC_URL": "${localEnv:BNB_DEV_SUBSONIC_URL}", + "BNB_SECRET": "${localEnv:BNB_SECRET}" }, "remoteUser": "node", "runArgs": ["-p", "0.0.0.0:4534:4534"], diff --git a/README.md b/README.md index 3168c44..7ea21f7 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ item | default value | description ---- | ------------- | ----------- BNB_PORT | 4534 | Default http port for bonob to listen on BNB_URL | http://$(hostname):4534 | URL (including path) for bonob so that sonos devices can communicate. **This must be either the public IP or DNS entry of the bonob instance so that the sonos devices can communicate with it.** -BNB_SECRET | bonob | secret used for encrypting credentials +BNB_SECRET | undefined | secret used for encrypting credentials, must be provided, make it long, make it secure BNB_AUTH_TIMEOUT | 1h | Timeout for the sonos auth token, described in the format [ms](https://github.com/vercel/ms), ie. '5s' == 5 seconds, '11h' == 11 hours. In the case of using Navidrome this should be less than the value for ND_SESSIONTIMEOUT BNB_LOG_LEVEL | info | Log level. One of ['debug', 'info', 'warn', 'error'] BNB_SERVER_LOG_REQUESTS | false | Whether or not to log http requests diff --git a/package.json b/package.json index 96bf7f8..3ab628a 100644 --- a/package.json +++ b/package.json @@ -66,9 +66,9 @@ "scripts": { "clean": "rm -Rf build node_modules", "build": "tsc", - "dev80": "BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", - "dev": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=false BNB_SONOS_AUTO_REGISTER=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", - "devr": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"${BNB_DEV_LOCAL_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts", + "dev80": "BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", + "dev": "BNB_LOGIN_THEME=navidrome-ish BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=false BNB_SONOS_AUTO_REGISTER=false BNB_URL=\"${BNB_DEV_LOCAL_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", + "devr": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"${BNB_DEV_LOCAL_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", "register-dev": "ts-node ./src/register.ts ${BNB_DEV_URL}", "test": "jest", "testw": "jest --watch", diff --git a/src/config.ts b/src/config.ts index 71fcfc7..76ca497 100644 --- a/src/config.ts +++ b/src/config.ts @@ -73,7 +73,7 @@ const cleanLoginTheme = (value: string) => { } } -export default function () { +export default function (die: (code?: number) => never = process.exit) { const port = bnbEnvVar("PORT", { default: 4534, parser: asInt })!; const bonobUrl = bnbEnvVar("URL", { legacy: ["BONOB_WEB_ADDRESS"], @@ -84,13 +84,21 @@ export default function () { logger.error( "BNB_URL containing localhost is almost certainly incorrect, sonos devices will not be able to communicate with bonob using localhost, please specify either public IP or DNS entry" ); - process.exit(1); + die(1); + } + + const secret = bnbEnvVar("SECRET")! + if(secret == null || secret === "") { + logger.error("BNB_SECRET not provided, choose a secret, make it long"); + die(1); + } else if(secret.length < 32) { + logger.warn("BNB_SECRET length is <32 chars"); } return { port, bonobUrl: url(bonobUrl), - secret: bnbEnvVar("SECRET", { default: "bonob" })!, + secret, authTimeout: bnbEnvVar("AUTH_TIMEOUT", { default: "1h" })!, icons: { foregroundColor: bnbEnvVar("ICON_FOREGROUND_COLOR", { diff --git a/src/server.ts b/src/server.ts index f41c6a3..3d60fc8 100644 --- a/src/server.ts +++ b/src/server.ts @@ -395,7 +395,7 @@ function server( E.fromNullable("Missing authorization header")(req.headers["authorization"] as string), E.flatMap((token) => { return pipe( - smapiAuthTokens.verify({ token, key: "nonsense" }), + smapiAuthTokens.verify({ token }), E.mapLeft((_) => "Auth token failed to verify") ) }), @@ -443,18 +443,14 @@ function server( logger.debug( `${trace} bnb<- ${req.method} ${req.path}?${JSON.stringify( req.query - )}, headers=${JSON.stringify({ ...req.headers, "bnbt": "*****", "bnbk": "*****" })}` + )}, headers=${JSON.stringify({ ...req.headers, "authorization": "*****" })}` ); const serviceToken = pipe( - E.fromNullable("Missing bnbt header")(req.headers["bnbt"] as string), - E.chain(token => pipe( - E.fromNullable("Missing bnbk header")(req.headers["bnbk"] as string), - E.map(key => ({ token, key })) - )), - E.chain((auth) => + E.fromNullable("Missing authorization header")(req.headers["authorization"] as string), + E.chain((authorization) => pipe( - smapiAuthTokens.verify(auth), + smapiAuthTokens.verify({ token: authorization }), E.mapLeft((_) => "Auth token failed to verify") ) ), diff --git a/src/smapi.ts b/src/smapi.ts index ed76360..4e3bfff 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -68,7 +68,6 @@ const WSDL_FILE = path.resolve( export type Credentials = { loginToken: { token: string; - key: string; householdId: string; }; deviceId: string; @@ -87,7 +86,7 @@ export type GetAppLinkResult = { export type GetDeviceAuthTokenResult = { getDeviceAuthTokenResult: { authToken: string; - privateKey: string; + // todo: appears this thing can be optional userInfo: { nickname: string; userIdHashCode: string; @@ -208,7 +207,6 @@ class SonosSoap { return { getDeviceAuthTokenResult: { authToken: smapiAuthToken.token, - privateKey: smapiAuthToken.key, userInfo: { nickname: association.nickname, userIdHashCode: crypto @@ -410,9 +408,7 @@ function bindSmapiSoapServiceToExpress( E.chain((credentials) => pipe( smapiAuthTokens.verify({ - token: credentials.loginToken.token, - //todo: remove me - key: "nonsense", + token: credentials.loginToken.token }), E.map((serviceToken) => ({ serviceToken, @@ -451,7 +447,7 @@ function bindSmapiSoapServiceToExpress( detail: { refreshAuthTokenResult: { authToken: newToken.token, - privateKey: newToken.key, + privateKey: "nonsense", }, }, }, @@ -502,7 +498,7 @@ function bindSmapiSoapServiceToExpress( TE.map((it) => ({ refreshAuthTokenResult: { authToken: it.token, - privateKey: it.key, + privateKey: "nonsense", }, })), TE.getOrElse((_) => { @@ -533,16 +529,10 @@ function bindSmapiSoapServiceToExpress( httpHeaders: [ { httpHeader: { - header: "bnbt", + header: "authorization", value: credentials.loginToken.token, }, }, - { - httpHeader: { - header: "bnbk", - value: credentials.loginToken.key, - }, - }, ], }; default: diff --git a/src/smapi_auth.ts b/src/smapi_auth.ts index 105138f..2b21519 100644 --- a/src/smapi_auth.ts +++ b/src/smapi_auth.ts @@ -1,6 +1,5 @@ import { either as E } from "fp-ts"; import jwt from "jsonwebtoken"; -import { b64Decode, b64Encode } from "./b64"; import { Clock } from "./clock"; import { StringValue } from 'ms' @@ -24,8 +23,7 @@ export function isSmapiRefreshTokenResultFault( } export type SmapiToken = { - token: string; - key: string; + token: string }; export interface ToSmapiFault { @@ -105,23 +103,12 @@ function isTokenExpiredError(thing: any): thing is TokenExpiredError { return thing.name == "TokenExpiredError"; } -export const smapiTokenAsString = (smapiToken: SmapiToken) => - b64Encode( - JSON.stringify({ - token: smapiToken.token, - key: smapiToken.key, - }) - ); -export const smapiTokenFromString = (smapiTokenString: string): SmapiToken => - JSON.parse(b64Decode(smapiTokenString)); - -export const SMAPI_TOKEN_VERSION = 3; +export const SMAPI_TOKEN_VERSION = 4; export class JWTSmapiLoginTokens implements SmapiAuthTokens { private readonly clock: Clock; private readonly secret: string; private readonly expiresIn: StringValue; - private readonly key: string; constructor( clock: Clock, @@ -130,19 +117,20 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens { version: number = SMAPI_TOKEN_VERSION ) { this.clock = clock; - this.secret = secret; this.expiresIn = expiresIn; - this.key = this.secret + "." + version + this.secret = secret + "." + version } issue = (serviceToken: string) => ({ token: jwt.sign( { serviceToken, iat: this.clock.now().unix() }, - this.key, - { expiresIn: this.expiresIn } - ), - // todo: remove this entirely - key: "nonsense" + this.secret, + { + algorithm: "HS256", + expiresIn: this.expiresIn, + issuer: "bonob" + } + ) }); verify = (smapiToken: SmapiToken): E.Either => { @@ -151,7 +139,7 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens { ( jwt.verify( smapiToken.token, - this.key + this.secret ) as any ).serviceToken ); @@ -160,7 +148,7 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens { const serviceToken = ( jwt.verify( smapiToken.token, - this.key, + this.secret, { ignoreExpiration: true } ) as any ).serviceToken; diff --git a/tests/builders.ts b/tests/builders.ts index 13d4701..fcdf19d 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -91,11 +91,10 @@ export function getAppLinkMessage() { }; } -export function someCredentials({ token, key } : { token: string, key: string }): Credentials { +export function someCredentials({ token } : { token: string }): Credentials { return { loginToken: { token, - key, householdId: "hh1", }, deviceId: "d1", diff --git a/tests/config.test.ts b/tests/config.test.ts index cc7673a..9493b94 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -118,6 +118,7 @@ describe("config", () => { it("should be used", () => { const url = "http://bonob1.example.com:8877/"; + process.env["BNB_SECRET"] = "bonob"; process.env["BNB_URL"] = ""; process.env["BONOB_URL"] = ""; process.env["BONOB_WEB_ADDRESS"] = ""; @@ -127,7 +128,20 @@ describe("config", () => { }); }); + describe(`when BNB_URL is 'http://localhost'`, () => { + it(`should process exit 1`, () => { + process.env["BNB_URL"] = "http://localhost"; + const mockDeath = jest.fn() as unknown as (code?: number) => never; + expect(config(mockDeath)); + expect(mockDeath).toHaveBeenCalledWith(1); + }); + }); + describe("when none of BNB_URL, BONOB_URL, BONOB_WEB_ADDRESS are specified", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + describe("when BONOB_PORT is not specified", () => { it(`should default to http://${hostname()}:4534`, () => { expect(config().bonobUrl.href()).toEqual( @@ -157,6 +171,10 @@ describe("config", () => { }); describe("icons", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + describe("foregroundColor", () => { describe.each([ "BNB_ICON_FOREGROUND_COLOR", @@ -245,6 +263,10 @@ describe("config", () => { }); describe("login theme", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + it("should default to classic", () => { expect(config().loginTheme).toEqual("classic"); }); @@ -261,8 +283,10 @@ describe("config", () => { }); describe("secret", () => { - it("should default to bonob", () => { - expect(config().secret).toEqual("bonob"); + it("should process exit 1 if not provided", () => { + const mockDeath = jest.fn() as unknown as (code?: number) => never; + expect(config(mockDeath)); + expect(mockDeath).toHaveBeenCalledWith(1); }); describe.each([ @@ -270,13 +294,18 @@ describe("config", () => { "BONOB_SECRET" ])("%s", (k) => { it(`should be overridable using ${k}`, () => { - process.env[k] = "new secret"; - expect(config().secret).toEqual("new secret"); + const secret = "new-secret-that-is-really-really-really-long-isnt-it" + process.env[k] = secret; + expect(config().secret).toEqual(secret); }); }); }); describe("authTimeout", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + it("should default to 1h", () => { expect(config().authTimeout).toEqual("1h"); }); @@ -288,6 +317,10 @@ describe("config", () => { }); describe("logRequests", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + describeBooleanConfigValue( "logRequests", "BNB_SERVER_LOG_REQUESTS", @@ -297,6 +330,10 @@ describe("config", () => { }); describe("sonos", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + describe("serviceName", () => { it("should default to bonob", () => { expect(config().sonos.serviceName).toEqual("bonob"); @@ -383,6 +420,10 @@ describe("config", () => { }); describe("subsonic", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + describe("url", () => { describe.each([ "BNB_SUBSONIC_URL", @@ -449,30 +490,36 @@ describe("config", () => { }); }); - describe.each([ - "BNB_SCROBBLE_TRACKS", - "BONOB_SCROBBLE_TRACKS" - ])("%s", (k) => { - describeBooleanConfigValue( - "scrobbleTracks", - k, - true, - (config) => config.scrobbleTracks - ); - }); + describe("scrobbling and reporting", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); - describe.each([ - "BNB_REPORT_NOW_PLAYING", - "BONOB_REPORT_NOW_PLAYING" - ])( - "%s", - (k) => { + describe.each([ + "BNB_SCROBBLE_TRACKS", + "BONOB_SCROBBLE_TRACKS" + ])("%s", (k) => { describeBooleanConfigValue( - "reportNowPlaying", + "scrobbleTracks", k, true, - (config) => config.reportNowPlaying + (config) => config.scrobbleTracks ); - } - ); + }); + + describe.each([ + "BNB_REPORT_NOW_PLAYING", + "BONOB_REPORT_NOW_PLAYING" + ])( + "%s", + (k) => { + describeBooleanConfigValue( + "reportNowPlaying", + k, + true, + (config) => config.reportNowPlaying + ); + } + ); + }); }); diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index fd7170c..12dece7 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -35,7 +35,6 @@ class LoggedInSonosDriver { this.client.addSoapHeader({ credentials: someCredentials({ token: this.token.getDeviceAuthTokenResult.authToken, - key: this.token.getDeviceAuthTokenResult.privateKey }), }); } diff --git a/tests/server.test.ts b/tests/server.test.ts index c2a090a..5d5718a 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -752,7 +752,7 @@ describe("server", () => { const serviceToken = `serviceToken-${uuid()}`; const trackId = `t-${uuid()}`; - const smapiAuthToken: SmapiToken = { token: `token-${uuid()}`, key: `key-${uuid()}` }; + const smapiAuthToken: SmapiToken = { token: `token-${uuid()}` }; const streamContent = (content: string) => { const self = { @@ -793,8 +793,7 @@ describe("server", () => { }) .path(), ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', smapiAuthToken.token); expect(res.status).toEqual(401); }); @@ -826,8 +825,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}`}) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', smapiAuthToken.token); expect(res.status).toEqual(trackStream.status); expect(res.headers["content-type"]).toEqual( @@ -856,8 +854,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', smapiAuthToken.token); expect(res.status).toEqual(404); @@ -890,8 +887,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', smapiAuthToken.token); expect(res.status).toEqual(401); }); @@ -919,8 +915,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', smapiAuthToken.token); expect(res.status).toEqual(404); @@ -955,8 +950,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', smapiAuthToken.token); expect(res.status).toEqual(stream.status); expect(res.headers["content-type"]).toEqual( @@ -999,8 +993,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', smapiAuthToken.token); expect(res.status).toEqual(stream.status); expect(res.headers["content-type"]).toEqual( @@ -1041,8 +1034,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', smapiAuthToken.token); expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( @@ -1084,8 +1076,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', smapiAuthToken.token); expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( @@ -1132,8 +1123,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key) + .set('authorization', smapiAuthToken.token) .set("Range", requestedRange); expect(res.status).toEqual(stream.status); @@ -1179,8 +1169,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key) + .set('authorization', smapiAuthToken.token) .set("Range", "4000-5000"); expect(res.status).toEqual(stream.status); @@ -1639,7 +1628,7 @@ describe("server", () => { .set('authorization', "not-a-valid-token") .expect(401); - expect(smapiAuthTokens.verify).toHaveBeenCalledWith({ token: "not-a-valid-token", key: "nonsense" }); + expect(smapiAuthTokens.verify).toHaveBeenCalledWith({ token: "not-a-valid-token" }); }); }); @@ -1656,7 +1645,7 @@ describe("server", () => { .set('authorization', authToken); expect(res.status).toEqual(200); - expect(smapiAuthTokens.verify).toHaveBeenCalledWith({ token: authToken, key: "nonsense" }); + expect(smapiAuthTokens.verify).toHaveBeenCalledWith({ token: authToken }); }); describe("and there are no items to report", () => { diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 170a34e..03b0455 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -620,7 +620,7 @@ describe("wsdl api", () => { }; const smapiAuthTokens = { - issue: jest.fn(() => ({ token: `default-smapiToken-${uuid()}`, key: `default-smapiKey-${uuid()}` })), + issue: jest.fn(() => ({ token: `default-smapiToken-${uuid()}` })), verify: jest.fn, []>(() => E.right(`default-serviceToken-${uuid()}`)), }; @@ -634,8 +634,7 @@ describe("wsdl api", () => { const serviceToken = `serviceToken-${uuid()}`; const apiToken = `apiToken-${uuid()}`; const smapiAuthToken: SmapiToken = { - token: `smapiAuthToken.token-${uuid()}`, - key: "nonsense" + token: `smapiAuthToken.token-${uuid()}` }; const bonobUrlWithAccessToken = bonobUrl.append({ @@ -742,7 +741,6 @@ describe("wsdl api", () => { expect(result[0]).toEqual({ getDeviceAuthTokenResult: { authToken: smapiAuthToken.token, - privateKey: smapiAuthToken.key, userInfo: { nickname: association.nickname, userIdHashCode: crypto @@ -838,7 +836,7 @@ describe("wsdl api", () => { expect(result[0]).toEqual({ refreshAuthTokenResult: { authToken: newSmapiAuthToken.token, - privateKey: newSmapiAuthToken.key, + privateKey: "nonsense" }, }); @@ -891,7 +889,7 @@ describe("wsdl api", () => { expect(result[0]).toEqual({ refreshAuthTokenResult: { authToken: newSmapiAuthToken.token, - privateKey: newSmapiAuthToken.key + privateKey: "nonsense" }, }); @@ -1044,7 +1042,7 @@ describe("wsdl api", () => { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials({ token: 'tokenThatFails', key: `keyThatFails` }) }); + ws.addSoapHeader({ credentials: someCredentials({ token: 'tokenThatFails' }) }); await action(ws) .then(() => fail("shouldnt get here")) @@ -1103,7 +1101,7 @@ describe("wsdl api", () => { detail: { refreshAuthTokenResult: { authToken: newToken.token, - privateKey: newToken.key, + privateKey: "nonsense", }, }, }); @@ -2937,20 +2935,12 @@ describe("wsdl api", () => { pathname: `/stream/track/${trackId}`, }) .href(), - httpHeaders: [ - { + httpHeaders: { httpHeader: [{ - header: "bnbt", + header: "authorization", value: smapiAuthToken.token, }], }, - { - httpHeader: [{ - header: "bnbk", - value: smapiAuthToken.key, - }], - } - ], }); expect(musicService.login).toHaveBeenCalledWith(serviceToken); diff --git a/tests/smapi_auth.test.ts b/tests/smapi_auth.test.ts index b79d744..06c0454 100644 --- a/tests/smapi_auth.test.ts +++ b/tests/smapi_auth.test.ts @@ -6,30 +6,12 @@ import { InvalidTokenError, isSmapiRefreshTokenResultFault, JWTSmapiLoginTokens, - smapiTokenAsString, - smapiTokenFromString, SMAPI_TOKEN_VERSION, } from "../src/smapi_auth"; import { either as E } from "fp-ts"; import { FixedClock } from "../src/clock"; import dayjs from "dayjs"; -import { b64Encode } from "../src/b64"; - -describe("smapiTokenAsString", () => { - it("can round trip token to and from string", () => { - const smapiToken = { token: uuid(), key: uuid(), someOtherStuff: 'this needs to be explicitly ignored' }; - const asString = smapiTokenAsString(smapiToken) - - expect(asString).toEqual(b64Encode(JSON.stringify({ - token: smapiToken.token, - key: smapiToken.key, - }))); - expect(smapiTokenFromString(asString)).toEqual({ - token: smapiToken.token, - key: smapiToken.key - }); - }); -}); + describe("isSmapiRefreshTokenResultFault", () => { it("should return true for a refreshAuthTokenResult fault", () => { @@ -77,7 +59,11 @@ describe("auth", () => { iat: clock.now().unix(), }, secret + "." + SMAPI_TOKEN_VERSION, - { expiresIn } + { + algorithm: "HS256", + expiresIn, + issuer: "bonob" + } ); expect(smapiToken.token).toEqual(expected); From 6e78372fa154690901f935c3a6a77332e14eecaa Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 29 Nov 2025 06:11:54 +0000 Subject: [PATCH 10/51] Add temporary ability to debug auth headers for CF support --- src/server.ts | 4 ++++ src/smapi.ts | 8 ++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index 3d60fc8..c13def9 100644 --- a/src/server.ts +++ b/src/server.ts @@ -446,6 +446,10 @@ function server( )}, headers=${JSON.stringify({ ...req.headers, "authorization": "*****" })}` ); + if(process.env["BNB_DEBUG_CF"] == "true") { + console.log(`/stream auth header == '${req.headers["authorization"]}'`) + } + const serviceToken = pipe( E.fromNullable("Missing authorization header")(req.headers["authorization"] as string), E.chain((authorization) => diff --git a/src/smapi.ts b/src/smapi.ts index 4e3bfff..0fe66d0 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -510,7 +510,7 @@ function bindSmapiSoapServiceToExpress( { id }: { id: string }, _, soapyHeaders: SoapyHeaders - ) => + ) => login(soapyHeaders?.credentials) .then(withSplitId(id)) .then(({ musicLibrary, credentials, type, typeId }) => { @@ -520,6 +520,9 @@ function bindSmapiSoapServiceToExpress( getMediaURIResult: it.url, })); case "track": + if(process.env["BNB_DEBUG_CF"] == "true") { + console.log(`getMediaURIResult header 'authorization'== '${credentials.loginToken.token}'`) + } return { getMediaURIResult: bonobUrl .append({ @@ -539,7 +542,8 @@ function bindSmapiSoapServiceToExpress( // todo: maybe not throw this? throw `Unsupported type:${type}`; } - }), + }) + , getMediaMetadata: async ( { id }: { id: string }, _, From 2fb057d40fc47becafdc6ef22ce2fd703d6fafa7 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 29 Nov 2025 06:12:49 +0000 Subject: [PATCH 11/51] Bump some libs --- package-lock.json | 56 ++++++++++++++++++++--------------------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/package-lock.json b/package-lock.json index 957898c..e5bb124 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2656,23 +2656,27 @@ "license": "Apache-2.0" }, "node_modules/body-parser": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.0.tgz", - "integrity": "sha512-02qvAaxv8tp7fBa/mw1ga98OGm+eCbqzJOKoRt70sLmfEEi+jyBYVTDGfCL/k06/4EMk/z01gCe7HoCH/f2LTg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", + "integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==", "license": "MIT", "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", - "debug": "^4.4.0", + "debug": "^4.4.3", "http-errors": "^2.0.0", - "iconv-lite": "^0.6.3", + "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", - "raw-body": "^3.0.0", - "type-is": "^2.0.0" + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "engines": { "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/boolbase": { @@ -4118,9 +4122,9 @@ } }, "node_modules/glob": { - "version": "10.4.5", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", - "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", "dev": true, "license": "ISC", "dependencies": { @@ -4338,15 +4342,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", + "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore-by-default": { @@ -5334,9 +5342,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "3.14.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", - "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", "dev": true, "license": "MIT", "dependencies": { @@ -6732,22 +6740,6 @@ "node": ">= 0.10" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz", - "integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/react-is": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", From e526feff9780799aa4dd9aea0f0f2149d34910f9 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 29 Nov 2025 06:18:46 +0000 Subject: [PATCH 12/51] Improve debug message --- src/server.ts | 2 +- src/smapi.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/server.ts b/src/server.ts index c13def9..541417e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -447,7 +447,7 @@ function server( ); if(process.env["BNB_DEBUG_CF"] == "true") { - console.log(`/stream auth header == '${req.headers["authorization"]}'`) + console.log(`DEBUG_CF /stream auth header == '${req.headers["authorization"]}'`) } const serviceToken = pipe( diff --git a/src/smapi.ts b/src/smapi.ts index 0fe66d0..f796b74 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -521,7 +521,7 @@ function bindSmapiSoapServiceToExpress( })); case "track": if(process.env["BNB_DEBUG_CF"] == "true") { - console.log(`getMediaURIResult header 'authorization'== '${credentials.loginToken.token}'`) + console.log(`DEBUG_CF getMediaURIResult header 'authorization'== '${credentials.loginToken.token}'`) } return { getMediaURIResult: bonobUrl From cc53beca19e144524cf0930d37cb5e6f7837346e Mon Sep 17 00:00:00 2001 From: simojenki Date: Wed, 3 Dec 2025 22:18:03 +0000 Subject: [PATCH 13/51] Use api key on /stream rather than full jwt to reduce length --- src/api_tokens.ts | 35 +++++++++++++++++++++------ src/app.ts | 2 +- src/server.ts | 10 ++++---- src/smapi.ts | 6 ++--- tests/api_tokens.test.ts | 30 ++++++++++++++++++++++- tests/server.test.ts | 51 +++++++++++++++++----------------------- tests/smapi.test.ts | 2 +- 7 files changed, 90 insertions(+), 46 deletions(-) diff --git a/src/api_tokens.ts b/src/api_tokens.ts index b72a0a6..f5a6d21 100644 --- a/src/api_tokens.ts +++ b/src/api_tokens.ts @@ -1,4 +1,8 @@ import crypto from "crypto"; +import ms, { StringValue } from "ms"; +import { Clock, SystemClock } from "./clock"; +import { Dayjs } from "dayjs"; +import _ from "underscore"; export interface APITokens { mint(authToken: string): string; @@ -13,18 +17,35 @@ export const sha256 = (salt: string) => (value: string) => crypto export class InMemoryAPITokens implements APITokens { - tokens = new Map(); + tokens = new Map(); + clock; minter; - - constructor(minter: (authToken: string) => string = sha256('bonob')) { + timeout_ms; + + constructor( + clock: Clock = SystemClock, + timeout: StringValue = "1h", + minter: (authToken: string) => string = sha256('bonob') + ) { + this.clock = clock; + this.timeout_ms = ms(timeout) this.minter = minter } mint = (authToken: string): string => { - const accessToken = this.minter(authToken); - this.tokens.set(accessToken, authToken); - return accessToken; + const apiToken = this.minter(authToken); + this.tokens.set(apiToken, { authToken, expiresAt: this.clock.now().add(this.timeout_ms, 'ms') }); + + const expired = [...this.tokens.entries()].filter(([_, minted]) => minted.expiresAt.isBefore(this.clock.now())) + expired.forEach(([apiToken,_]) => this.tokens.delete(apiToken)) + + return apiToken; } - authTokenFor = (apiToken: string): string | undefined => this.tokens.get(apiToken); + authTokenFor = (apiToken: string): string | undefined => { + const minted = this.tokens.get(apiToken) + return minted != null && minted.expiresAt.isAfter(this.clock.now()) ? minted.authToken : undefined + }; + + authTokens = () => [...this.tokens.values()].map((it) => it.authToken) } diff --git a/src/app.ts b/src/app.ts index 25c096e..deeaad8 100644 --- a/src/app.ts +++ b/src/app.ts @@ -88,7 +88,7 @@ const app = server( featureFlagAwareMusicService, { linkCodes: () => new InMemoryLinkCodes(), - apiTokens: () => new InMemoryAPITokens(sha256(config.secret)), + apiTokens: () => new InMemoryAPITokens(clock, config.authTimeout, sha256(config.secret)), clock, iconColors: config.icons, applyContextPath: true, diff --git a/src/server.ts b/src/server.ts index 541417e..cd64468 100644 --- a/src/server.ts +++ b/src/server.ts @@ -106,9 +106,11 @@ export type ServerOpts = { loginTheme: string; }; +const DEFAULT_TIMEOUT = "1h" + const DEFAULT_SERVER_OPTS: ServerOpts = { linkCodes: () => new InMemoryLinkCodes(), - apiTokens: () => new InMemoryAPITokens(), + apiTokens: () => new InMemoryAPITokens(SystemClock, DEFAULT_TIMEOUT), clock: SystemClock, iconColors: { foregroundColor: undefined, backgroundColor: undefined }, applyContextPath: true, @@ -117,7 +119,7 @@ const DEFAULT_SERVER_OPTS: ServerOpts = { smapiAuthTokens: new JWTSmapiLoginTokens( SystemClock, `bonob-${uuid()}`, - "1m" + DEFAULT_TIMEOUT ), externalImageResolver: axiosImageFetcher, loginTheme: DEFAULT_LOGIN_THEME @@ -454,8 +456,8 @@ function server( E.fromNullable("Missing authorization header")(req.headers["authorization"] as string), E.chain((authorization) => pipe( - smapiAuthTokens.verify({ token: authorization }), - E.mapLeft((_) => "Auth token failed to verify") + apiTokens.authTokenFor(authorization), + E.fromNullable("Failed to find matching API token, or API token has expired") ) ), E.getOrElseW(() => undefined) diff --git a/src/smapi.ts b/src/smapi.ts index f796b74..d9b50f1 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -513,7 +513,7 @@ function bindSmapiSoapServiceToExpress( ) => login(soapyHeaders?.credentials) .then(withSplitId(id)) - .then(({ musicLibrary, credentials, type, typeId }) => { + .then(({ musicLibrary, apiKey, type, typeId }) => { switch (type) { case "internetRadioStation": return musicLibrary.radioStation(typeId).then((it) => ({ @@ -521,7 +521,7 @@ function bindSmapiSoapServiceToExpress( })); case "track": if(process.env["BNB_DEBUG_CF"] == "true") { - console.log(`DEBUG_CF getMediaURIResult header 'authorization'== '${credentials.loginToken.token}'`) + console.log(`DEBUG_CF getMediaURIResult header 'authorization'== '${apiKey}'`) } return { getMediaURIResult: bonobUrl @@ -533,7 +533,7 @@ function bindSmapiSoapServiceToExpress( { httpHeader: { header: "authorization", - value: credentials.loginToken.token, + value: apiKey, }, }, ], diff --git a/tests/api_tokens.test.ts b/tests/api_tokens.test.ts index 4bad1e5..416f8fd 100644 --- a/tests/api_tokens.test.ts +++ b/tests/api_tokens.test.ts @@ -1,5 +1,6 @@ import { v4 as uuid } from "uuid"; +import { FixedClock } from "../src/clock"; import { InMemoryAPITokens, sha256 @@ -36,9 +37,12 @@ describe('sha256 minter', () => { }); describe("InMemoryAPITokens", () => { + const clock = new FixedClock(); + const timeout_ms = 10; + const reverseAuthToken = (authToken: string) => authToken.split("").reverse().join(""); - const accessTokens = new InMemoryAPITokens(reverseAuthToken); + const accessTokens = new InMemoryAPITokens(clock, `${timeout_ms}ms`, reverseAuthToken); it("should return the same access token for the same auth token", () => { const authToken = "token1"; @@ -64,4 +68,28 @@ describe("InMemoryAPITokens", () => { expect(accessTokens.authTokenFor(uuid())).toBeUndefined(); }); }); + + describe("when a token has expired", () => { + it("should not be returned", () => { + const authToken = "token1"; + const accessToken = accessTokens.mint(authToken); + expect(accessTokens.authTokenFor(accessToken)).toEqual(authToken); + + clock.add(timeout_ms + 1, "ms"); + + expect(accessTokens.authTokenFor(accessToken)).toBeUndefined(); + }); + + it("should be removed on next invocation to mint", () => { + accessTokens.mint("token1") + accessTokens.mint("token2") + expect(accessTokens.authTokens()).toStrictEqual(["token1", "token2"]) + + clock.add(timeout_ms + 1, "ms"); + expect(accessTokens.authTokens()).toStrictEqual(["token1", "token2"]) + + accessTokens.mint("token3") + expect(accessTokens.authTokens()).toStrictEqual(["token3"]) + }); + }); }); diff --git a/tests/server.test.ts b/tests/server.test.ts index 5d5718a..1ebbf41 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -22,9 +22,9 @@ import { Transform } from "stream"; import url from "../src/url_builder"; import i8n, { randomLang } from "../src/i8n"; import { SONOS_RECOMMENDED_IMAGE_SIZES } from "../src/smapi"; -import { Clock, SystemClock } from "../src/clock"; +import { Clock, FixedClock, SystemClock } from "../src/clock"; import { formatForURL } from "../src/burn"; -import { ExpiredTokenError, SmapiAuthTokens, SmapiToken } from "../src/smapi_auth"; +import { SmapiAuthTokens } from "../src/smapi_auth"; describe("rangeFilterFor", () => { describe("invalid range header string", () => { @@ -736,7 +736,8 @@ describe("server", () => { const smapiAuthTokens = { verify: jest.fn(), } - const apiTokens = new InMemoryAPITokens(); + const clock = new FixedClock(); + const apiTokens = new InMemoryAPITokens(clock, "1h"); const server = makeServer( jest.fn() as unknown as Sonos, @@ -752,7 +753,6 @@ describe("server", () => { const serviceToken = `serviceToken-${uuid()}`; const trackId = `t-${uuid()}`; - const smapiAuthToken: SmapiToken = { token: `token-${uuid()}` }; const streamContent = (content: string) => { const self = { @@ -782,9 +782,10 @@ describe("server", () => { }); }); - describe("when the authorization has expired", () => { + describe("when the authorisation header api key has expired", () => { it("should return a 401", async () => { - smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken))) + const apiToken = apiTokens.mint(serviceToken); + clock.add(2, "h"); const res = await request(server).head( bonobUrl @@ -793,17 +794,13 @@ describe("server", () => { }) .path(), ) - .set('authorization', smapiAuthToken.token); + .set('authorization', apiToken); expect(res.status).toEqual(401); }); }); describe("when the authorization token & key are valid", () => { - beforeEach(() => { - smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken)); - }); - describe("and the track exists", () => { it("should return a 200", async () => { const trackStream = { @@ -825,7 +822,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}`}) .path() ) - .set('authorization', smapiAuthToken.token); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(trackStream.status); expect(res.headers["content-type"]).toEqual( @@ -854,7 +851,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('authorization', smapiAuthToken.token); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(404); @@ -877,9 +874,10 @@ describe("server", () => { }); }); - describe("when the Bearer token has expired", () => { + describe("when the authorisation header api key has expired", () => { it("should return a 401", async () => { - smapiAuthTokens.verify.mockReturnValue(E.left(new ExpiredTokenError(serviceToken))) + const apiToken = apiTokens.mint(serviceToken); + clock.add(2, "h"); const res = await request(server) .get( @@ -887,17 +885,13 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('authorization', smapiAuthToken.token); + .set('authorization', apiToken); expect(res.status).toEqual(401); }); }); - describe("when the authorization token & key is valid", () => { - beforeEach(() => { - smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken)); - }); - + describe("when the authorization token & key are valid", () => { describe("when the track doesnt exist", () => { it("should return a 404", async () => { const stream = { @@ -915,7 +909,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('authorization', smapiAuthToken.token); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(404); @@ -950,7 +944,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('authorization', smapiAuthToken.token); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(stream.status); expect(res.headers["content-type"]).toEqual( @@ -993,7 +987,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('authorization', smapiAuthToken.token); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(stream.status); expect(res.headers["content-type"]).toEqual( @@ -1034,7 +1028,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('authorization', smapiAuthToken.token); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( @@ -1076,7 +1070,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('authorization', smapiAuthToken.token); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( @@ -1123,7 +1117,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('authorization', smapiAuthToken.token) + .set('authorization', apiTokens.mint(serviceToken)) .set("Range", requestedRange); expect(res.status).toEqual(stream.status); @@ -1169,7 +1163,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('authorization', smapiAuthToken.token) + .set('authorization', apiTokens.mint(serviceToken)) .set("Range", "4000-5000"); expect(res.status).toEqual(stream.status); @@ -1195,7 +1189,6 @@ describe("server", () => { }); }); }); - }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 03b0455..f73af6d 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -2938,7 +2938,7 @@ describe("wsdl api", () => { httpHeaders: { httpHeader: [{ header: "authorization", - value: smapiAuthToken.token, + value: apiToken, }], }, }); From f3a38a05f791f2e634d97b674724f8b375c12da1 Mon Sep 17 00:00:00 2001 From: simojenki Date: Tue, 20 Jan 2026 21:18:15 +0000 Subject: [PATCH 14/51] When returned cover art, if content type from upstream is invalid return 502 --- src/server.ts | 8 ++++++-- src/utils.ts | 6 ++++++ tests/server.test.ts | 26 ++++++++++++++++++++++++-- tests/utils.test.ts | 16 +++++++++++++++- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/src/server.ts b/src/server.ts index cd64468..6a58175 100644 --- a/src/server.ts +++ b/src/server.ts @@ -42,6 +42,7 @@ import { JWTSmapiLoginTokens, SmapiAuthTokens, } from "./smapi_auth"; +import { isValidMimeType } from "./utils"; export const BONOB_ACCESS_TOKEN_HEADER = "bat"; @@ -652,12 +653,15 @@ function server( } }) .then((coverArt) => { - if(coverArt) { + if(coverArt == undefined) { + return res.status(404).send(); + } else if(isValidMimeType(coverArt.contentType)) { res.status(200); res.setHeader("content-type", coverArt.contentType); return res.send(coverArt.data); } else { - return res.status(404).send(); + logger.warn(`Invalid content type of ${coverArt.contentType}, detected for ${urn}`); + return res.status(502).send(); } }) .catch((e: Error) => { diff --git a/src/utils.ts b/src/utils.ts index 84b4fee..2923d95 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -32,3 +32,9 @@ export function xmlTidy(xml: string | Node) { return xmlToString(doc as any); } +const MIME_TYPE_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]{0,126}\/[a-zA-Z0-9][a-zA-Z0-9!#$&^_.+-]{0,126}$/; + +export function isValidMimeType(value: string): boolean { + return MIME_TYPE_REGEX.test(value); +} + diff --git a/tests/server.test.ts b/tests/server.test.ts index 1ebbf41..b6194ba 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1257,8 +1257,8 @@ describe("server", () => { }); describe("fetching a single image", () => { - describe("when the images is available", () => { - it("should return the image and a 200", async () => { + describe("when the images is available and has a valid content type", () => { + it("should return the image with correct content type", async () => { const coverArtURN = { system: "subsonic", resource: "art:200" }; const coverArt = coverArtResponse({}); @@ -1286,6 +1286,28 @@ describe("server", () => { }); }); + describe("when the images is available however it has an invalid content type", () => { + it("should return a 502", async () => { + const coverArtURN = { system: "subsonic", resource: "art:200" }; + + const coverArt = coverArtResponse({ + contentType: "not-valid" + }); + + musicService.login.mockResolvedValue(musicLibrary); + + musicLibrary.coverArt.mockResolvedValue(coverArt); + + const res = await request(server) + .get( + `/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}` + ) + .set(BONOB_ACCESS_TOKEN_HEADER, apiToken); + + expect(res.status).toEqual(502); + }); + }); + describe("when the image is not available", () => { it("should return a 404", async () => { const coverArtURN = { system: "subsonic", resource: "art:404" }; diff --git a/tests/utils.test.ts b/tests/utils.test.ts index ce0d5f3..4866360 100644 --- a/tests/utils.test.ts +++ b/tests/utils.test.ts @@ -1,4 +1,4 @@ -import { takeWithRepeats } from "../src/utils"; +import { isValidMimeType, takeWithRepeats } from "../src/utils"; describe("takeWithRepeat", () => { describe("when there is nothing in the input", () => { @@ -33,3 +33,17 @@ describe("takeWithRepeat", () => { }); }); }); + +describe("isValidMimeType", () => { + [ + ["application/json", true], + ["image/jpeg", true], + ["text/html", true], + ["application/vnd.api+json", true], + ["json", false], + ["application", false], + ["blahblah", false] + ].forEach((spec) => { + expect(isValidMimeType(spec[0] as string)).toEqual(spec[1]) + }); +}); From 229a41130046e3c95185a246223dd1aa46c83850 Mon Sep 17 00:00:00 2001 From: simojenki Date: Wed, 21 Jan 2026 05:17:39 +0000 Subject: [PATCH 15/51] Cleanup smapi, fixed playlists so work in both S1 and S2 --- src/smapi.ts | 166 ++++++++++++++++---------------- tests/smapi.test.ts | 224 ++++++++++++++++++++++---------------------- 2 files changed, 190 insertions(+), 200 deletions(-) diff --git a/src/smapi.ts b/src/smapi.ts index d9b50f1..d5f02d8 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -10,18 +10,17 @@ import logger from "./logger"; import { LinkCodes } from "./link_codes"; import { - Album, AlbumQuery, AlbumSummary, ArtistSummary, Genre, Year, MusicService, - Playlist, RadioStation, Rating, slice2, Track, + PlaylistSummary, } from "./music_service"; import { APITokens } from "./api_tokens"; import { Clock } from "./clock"; @@ -244,6 +243,16 @@ export type Container = { displayType: string | undefined; }; +// const collection = () => ({ +// itemType: "collection", +// canScroll: false, +// canPlay: false, +// canEnumerate: true, +// canAddToFavorites: true, +// containsFavorite: false, +// canSkip: true, +// }) + const genre = (bonobUrl: URLBuilder, genre: Genre) => ({ itemType: "albumList", id: `genre:${genre.id}`, @@ -263,17 +272,25 @@ export const shouldScrobble = (track: Track, playbackTime: number) => ( (track.duration < 30 && playbackTime >= 10) || (track.duration >= 30 && playbackTime >= 30)) -const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({ +// canPlay: true, +// canEnumerate: true, +// canResume: false, +// attributes: { +// readOnly: false, +// userContent: true, +// renameable: true, +// }, + + +const playlist = (bonobUrl: URLBuilder, playlist: PlaylistSummary) => ({ itemType: "playlist", id: `playlist:${playlist.id}`, title: playlist.name, albumArtURI: coverArtURI(bonobUrl, playlist).href(), canPlay: true, attributes: { - readOnly: false, - userContent: false, - renameable: false, - }, + userContent: true, + }, }); export const coverArtURI = ( @@ -539,11 +556,12 @@ function bindSmapiSoapServiceToExpress( ], }; default: - // todo: maybe not throw this? - throw `Unsupported type:${type}`; + logger.info(`Sonos asked for an unsupported getMediaURI: ${type}:${typeId}`); + return { + getMediaURIResult: iconArtURI(bonobUrl, "error", "?").href(), + } } - }) - , + }), getMediaMetadata: async ( { id }: { id: string }, _, @@ -562,8 +580,10 @@ function bindSmapiSoapServiceToExpress( getMediaMetadataResult: track(urlWithToken(apiKey), it), })); default: - //todo: maybe not throw this? - throw `Unsupported type:${type}`; + logger.info(`Sonos asked for an unsupported getMediaMetadata: ${type}:${typeId}`); + return { + getMediaMetadataResult: {} + } } }), search: async ( @@ -603,59 +623,48 @@ function bindSmapiSoapServiceToExpress( }) ); default: - throw `Unsupported search by:${id}`; + logger.info(`Sonos asked for an unsupported search of: ${id}, term=${term}`); + return searchResult({ + count: 0, + mediaCollection: [], + }) } }), getExtendedMetadata: async ( - { - id, - index, - count, - }: // recursive, - { id: string; index: number; count: number; recursive: boolean }, + { id }: { id: string }, _, soapyHeaders: SoapyHeaders ) => login(soapyHeaders?.credentials) .then(withSplitId(id)) .then(async ({ musicLibrary, apiKey, type, typeId }) => { - const paging = { _index: index, _count: count }; switch (type) { case "artist": - return musicLibrary.artist(typeId).then((artist) => { - const [page, total] = slice2(paging)( - artist.albums - ); - return { + return musicLibrary + .artist(typeId) + .then((it) => ({ getExtendedMetadataResult: { - count: page.length, - index: paging._index, - total, - mediaCollection: page.map((it) => - album(urlWithToken(apiKey), it) - ), - relatedBrowse: - artist.similarArtists.filter((it) => it.inLibrary) - .length > 0 - ? [ - { - id: `relatedArtists:${artist.id}`, - type: "RELATED_ARTISTS", - }, - ] - : [], + mediaCollection: artist(urlWithToken(apiKey), it), + relatedBrowse: it + .similarArtists + .filter((it) => it.inLibrary) + .length > 0 + ? ([{ id: `relatedArtists:${it.id}`, type: "RELATED_ARTISTS" }]) + : [] }, - }; - }); + })); case "track": - return musicLibrary.track(typeId).then((it) => ({ - getExtendedMetadataResult: { - mediaMetadata: track(urlWithToken(apiKey), it), - }, - })); + return musicLibrary + .track(typeId) + .then((it) => ({ + getExtendedMetadataResult: { + mediaMetadata: track(urlWithToken(apiKey), it), + }, + })); case "album": return musicLibrary.album(typeId).then((it) => ({ getExtendedMetadataResult: { + // todo: can these go in the album function? Also used in search.... mediaCollection: { attributes: { readOnly: true, @@ -664,18 +673,21 @@ function bindSmapiSoapServiceToExpress( }, ...album(urlWithToken(apiKey), it), }, - // - // - // - // AL:123456 - // ALBUM_NOTES - // - // }, })); + case "playlist": + return musicLibrary + .playlist(typeId!) + .then(it => ({ + getExtendedMetadataResult: { + mediaCollection: playlist(urlWithToken(apiKey), it), + }, + })); default: - // unsupported "artists" - throw `Unsupported getExtendedMetadata id=${id}`; + logger.info(`Sonos requested extended meta data for currently unsupported type=${type}, typeId=${typeId}`) + return { + getExtendedMetadataResult: {} + }; } }), getMetadata: async ( @@ -748,11 +760,9 @@ function bindSmapiSoapServiceToExpress( id: "playlists", title: lang("playlists"), albumArtURI: iconArtURI(bonobUrl, "playlists").href(), - itemType: "playlist", + itemType: "collection", attributes: { - readOnly: false, userContent: true, - renameable: false, }, }, { @@ -913,9 +923,7 @@ function bindSmapiSoapServiceToExpress( .then(slice2(paging)) .then(([page, total]) => getMetadataResult({ - mediaCollection: page.map((it) => - genre(bonobUrl, it) - ), + mediaCollection: page.map((it) => genre(bonobUrl, it)), index: paging._index, total, }) @@ -923,26 +931,10 @@ function bindSmapiSoapServiceToExpress( case "playlists": return musicLibrary .playlists() - .then((it) => - Promise.all( - it.map((playlist) => { - // todo: whats this odd copy all about, can we just delete it? - return { - id: playlist.id, - name: playlist.name, - coverArt: playlist.coverArt, - // todo: are these every important? - entries: [], - }; - }) - ) - ) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ - mediaCollection: page.map((it) => - playlist(urlWithToken(apiKey), it) - ), + mediaCollection: page.map((it) => playlist(urlWithToken(apiKey), it)), index: paging._index, total, }); @@ -978,10 +970,7 @@ function bindSmapiSoapServiceToExpress( case "relatedArtists": return musicLibrary .artist(typeId!) - .then((artist) => artist.similarArtists) - .then((similarArtists) => - similarArtists.filter((it) => it.inLibrary) - ) + .then((artist) => artist.similarArtists.filter((it) => it.inLibrary)) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ @@ -1006,7 +995,12 @@ function bindSmapiSoapServiceToExpress( }); }); default: - throw `Unsupported getMetadata id=${id}`; + logger.info(`Sonos asked for an unsupported getMetadata: ${type}:${typeId}`); + return getMetadataResult({ + mediaMetadata: [], + index: paging._index, + total: 0, + }); } }), createContainer: async ( diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index f73af6d..5e8d4f2 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -1007,6 +1007,24 @@ describe("wsdl api", () => { expect(musicLibrary.searchTracks).toHaveBeenCalledWith(term); }); }); + + describe("searching for an unsupported type", () => { + it("should return the tracks", async () => { + const term = "whoopie"; + + const result = await ws.searchAsync({ + id: "foobar", + term, + }); + expect(result[0]).toEqual( + searchResult({ + count: 0, + index: 0, + total: 0, + }) + ); + }); + }); }); }); @@ -1173,10 +1191,8 @@ describe("wsdl api", () => { id: "playlists", title: "Playlists", albumArtURI: iconArtURI(bonobUrl, "playlists").href(), - itemType: "playlist", + itemType: "collection", attributes: { - readOnly: "false", - renameable: "false", userContent: "true", }, }, @@ -1273,10 +1289,8 @@ describe("wsdl api", () => { id: "playlists", title: "Afspeellijsten", albumArtURI: iconArtURI(bonobUrl, "playlists").href(), - itemType: "playlist", + itemType: "collection", attributes: { - readOnly: "false", - renameable: "false", userContent: "true", }, }, @@ -1331,14 +1345,31 @@ describe("wsdl api", () => { }); }); + describe("asking for a type that doesnt exist", () => { + it("should return an empty result", async () => { + const foobar= await ws.getMetadataAsync({ + id: "foobar", + index: 0, + count: 100, + }); + expect(foobar[0]).toEqual( + getMetadataResult({ + count: 0, + index: 0, + total: 0, + }) + ); + }); + }); + describe("asking for the search container", () => { it("should return it", async () => { - const root = await ws.getMetadataAsync({ + const search = await ws.getMetadataAsync({ id: "search", index: 0, count: 100, }); - expect(root[0]).toEqual( + expect(search[0]).toEqual( getMetadataResult({ mediaCollection: [ { itemType: "search", id: "artists", title: "Artists" }, @@ -1511,9 +1542,7 @@ describe("wsdl api", () => { ).href(), canPlay: true, attributes: { - readOnly: "false", - userContent: "false", - renameable: "false", + userContent: "true", }, })), index: 0, @@ -1543,9 +1572,7 @@ describe("wsdl api", () => { ).href(), canPlay: true, attributes: { - readOnly: "false", - userContent: "false", - renameable: "false", + userContent: "true", }, }) ), @@ -2582,7 +2609,7 @@ describe("wsdl api", () => { describe("getExtendedMetadata", () => { itShouldHandleInvalidCredentials((ws) => - ws.getExtendedMetadataAsync({ id: "root", index: 0, count: 0 }) + ws.getExtendedMetadataAsync({ id: "root" }) ); describe("when valid credentials are provided", () => { @@ -2597,71 +2624,6 @@ describe("wsdl api", () => { }); describe("asking for an artist", () => { - describe("when it has some albums", () => { - const album1 = anAlbum(); - const album2 = anAlbum(); - const album3 = anAlbum(); - - const artist = anArtist({ - similarArtists: [], - albums: [album1, album2, album3], - }); - - beforeEach(() => { - musicLibrary.artist.mockResolvedValue(artist); - }); - - describe("when all albums fit on a page", () => { - it("should return the albums", async () => { - const paging = { - index: 0, - count: 100, - }; - - const root = await ws.getExtendedMetadataAsync({ - id: `artist:${artist.id}`, - ...paging, - }); - - expect(root[0]).toEqual({ - getExtendedMetadataResult: { - count: "3", - index: "0", - total: "3", - mediaCollection: artist.albums.map((it) => - album(bonobUrlWithAccessToken, it) - ), - }, - }); - }); - }); - - describe("getting a page of albums", () => { - it("should return only that page", async () => { - const paging = { - index: 1, - count: 2, - }; - - const root = await ws.getExtendedMetadataAsync({ - id: `artist:${artist.id}`, - ...paging, - }); - - expect(root[0]).toEqual({ - getExtendedMetadataResult: { - count: "2", - index: "1", - total: "3", - mediaCollection: [album2, album3].map((it) => - album(bonobUrlWithAccessToken, it) - ), - }, - }); - }); - }); - }); - describe("when it has similar artists, some in the library and some not", () => { const similar1 = anArtist(); const similar2 = anArtist(); @@ -2674,8 +2636,7 @@ describe("wsdl api", () => { { ...similar2, inLibrary: false }, { ...similar3, inLibrary: false }, { ...similar4, inLibrary: true }, - ], - albums: [], + ] }); beforeEach(() => { @@ -2683,28 +2644,23 @@ describe("wsdl api", () => { }); it("should return a RELATED_ARTISTS browse option", async () => { - const paging = { - index: 0, - count: 100, - }; - const root = await ws.getExtendedMetadataAsync({ - id: `artist:${artist.id}`, - ...paging, + id: `artist:${artist.id}` }); expect(root[0]).toEqual({ getExtendedMetadataResult: { - // artist has no albums - count: "0", - index: "0", - total: "0", - relatedBrowse: [ - { - id: `relatedArtists:${artist.id}`, - type: "RELATED_ARTISTS", - }, - ], + mediaCollection: { + itemType: "artist", + id: `artist:${artist.id}`, + artistId: artist.id, + title: artist.name, + albumArtURI: coverArtURI(bonobUrlWithAccessToken, { coverArt: artist.image }).href(), + }, + relatedBrowse: [{ + id: `relatedArtists:${artist.id}`, + type: "RELATED_ARTISTS", + }], }, }); }); @@ -2722,16 +2678,17 @@ describe("wsdl api", () => { it("should not return a RELATED_ARTISTS browse option", async () => { const root = await ws.getExtendedMetadataAsync({ - id: `artist:${artist.id}`, - index: 0, - count: 100, + id: `artist:${artist.id}` }); expect(root[0]).toEqual({ getExtendedMetadataResult: { - // artist has no albums - count: "0", - index: "0", - total: "0", + mediaCollection: { + itemType: "artist", + id: `artist:${artist.id}`, + artistId: artist.id, + title: artist.name, + albumArtURI: coverArtURI(bonobUrlWithAccessToken, { coverArt: artist.image }).href(), + } }, }); }); @@ -2754,16 +2711,17 @@ describe("wsdl api", () => { it("should not return a RELATED_ARTISTS browse option", async () => { const root = await ws.getExtendedMetadataAsync({ - id: `artist:${artist.id}`, - index: 0, - count: 100, + id: `artist:${artist.id}` }); expect(root[0]).toEqual({ getExtendedMetadataResult: { - // artist has no albums - count: "0", - index: "0", - total: "0", + mediaCollection: { + itemType: "artist", + id: `artist:${artist.id}`, + artistId: artist.id, + title: artist.name, + albumArtURI: coverArtURI(bonobUrlWithAccessToken, { coverArt: artist.image }).href(), + } }, }); }); @@ -2902,6 +2860,18 @@ describe("wsdl api", () => { expect(musicLibrary.album).toHaveBeenCalledWith(album.id); }); }); + + describe("asking for something that doesnt exist", () => { + it("should return an empty result rather than throwing an error", async () => { + const root = await ws.getExtendedMetadataAsync({ + id: `foobar:1000`, + }); + + expect(root[0]).toEqual({ + getExtendedMetadataResult: null + }); + }); + }); }); }); @@ -2966,6 +2936,20 @@ describe("wsdl api", () => { expect(musicService.login).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.radioStation).toHaveBeenCalledWith(someStation.id); }); + }); + + describe("asking for a URI for an unsupported type", () => { + it("should return an error icon", async () => { + const root = await ws.getMediaURIAsync({ + id: `foobar:1000`, + }); + + expect(root[0]).toEqual({ + getMediaURIResult: iconArtURI(bonobUrl, "error", "?").href() + }); + + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + }); }); }); }); @@ -3032,7 +3016,19 @@ describe("wsdl api", () => { expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); expect(musicLibrary.radioStation).toHaveBeenCalledWith(someStation.id); }); - }); + }); + + describe("asking for media metadata for an unsupported type", () => { + it("should return it with auth header", async () => { + const root = await ws.getMediaMetadataAsync({ + id: `foobar:1000`, + }); + + expect(root[0]).toEqual({ + getMediaMetadataResult: null, + }); + }); + }); }); }); From 58ab6370eabe122bac0ea8b7cf5363b7172d77a6 Mon Sep 17 00:00:00 2001 From: Simon J Date: Thu, 22 Jan 2026 09:14:53 +1100 Subject: [PATCH 16/51] Refactor interaction between subsonic classes (#228) * Refactor/cleanup library/service versus subsonic Move subsonic music service/library into own file * Remove bearer token and unused variations of subsonic service type --- src/app.ts | 4 +- src/{music_service.ts => music_library.ts} | 31 +- src/server.ts | 2 +- src/smapi.ts | 7 +- src/smapi_auth.ts | 2 +- src/subsonic.ts | 855 +-- src/subsonic_music_library.ts | 282 + tests/builders.ts | 86 +- tests/in_memory_music_service.test.ts | 88 +- tests/in_memory_music_service.ts | 12 +- ..._service.test.ts => music_library.test.ts} | 2 +- tests/scenarios.test.ts | 2 +- tests/server.test.ts | 2 +- tests/smapi.test.ts | 21 +- tests/subsonic.test.ts | 5520 ++++------------- tests/subsonic_music_library.test.ts | 3626 +++++++++++ 16 files changed, 5455 insertions(+), 5087 deletions(-) rename src/{music_service.ts => music_library.ts} (85%) create mode 100644 src/subsonic_music_library.ts rename tests/{music_service.test.ts => music_library.test.ts} (88%) create mode 100644 tests/subsonic_music_library.test.ts diff --git a/src/app.ts b/src/app.ts index deeaad8..d936749 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,16 +6,16 @@ import logger from "./logger"; import { axiosImageFetcher, cachingImageFetcher, - SubsonicMusicService, TranscodingCustomPlayers, NO_CUSTOM_PLAYERS, Subsonic } from "./subsonic"; +import { SubsonicMusicService} from "./subsonic_music_library"; import { InMemoryAPITokens, sha256 } from "./api_tokens"; import { InMemoryLinkCodes } from "./link_codes"; import readConfig from "./config"; import sonos, { bonobService } from "./sonos"; -import { MusicService } from "./music_service"; +import { MusicService } from "./music_library"; import { SystemClock } from "./clock"; import { JWTSmapiLoginTokens } from "./smapi_auth"; diff --git a/src/music_service.ts b/src/music_library.ts similarity index 85% rename from src/music_service.ts rename to src/music_library.ts index 2504f29..23f5cbc 100644 --- a/src/music_service.ts +++ b/src/music_library.ts @@ -3,6 +3,7 @@ import { taskEither as TE } from "fp-ts"; export type Credentials = { username: string; password: string }; +// todo: these are using in subsonic, maybe they should go in there? export type AuthSuccess = { serviceToken: string; userId: string; @@ -23,7 +24,8 @@ export type ArtistSummary = { export type SimilarArtist = ArtistSummary & { inLibrary: boolean }; -export type Artist = ArtistSummary & { +// todo: maybe is should be artist.summary rather than an artist also being a summary? +export type Artist = Pick & { albums: AlbumSummary[]; similarArtists: SimilarArtist[] }; @@ -34,12 +36,11 @@ export type AlbumSummary = { year: string | undefined; genre: Genre | undefined; coverArt: BUrn | undefined; - artistName: string | undefined; artistId: string | undefined; }; -export type Album = AlbumSummary & {}; +export type Album = Pick & { tracks: Track[] }; export type Genre = { name: string; @@ -60,7 +61,7 @@ export type Encoding = { mimeType: string } -export type Track = { +export type TrackSummary = { id: string; name: string; encoding: Encoding, @@ -68,9 +69,12 @@ export type Track = { number: number | undefined; genre: Genre | undefined; coverArt: BUrn | undefined; - album: AlbumSummary; artist: ArtistSummary; rating: Rating; +} + +export type Track = TrackSummary & { + album: AlbumSummary; }; export type RadioStation = { @@ -129,6 +133,18 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({ coverArt: it.coverArt }); +export const trackToTrackSummary = (it: Track): TrackSummary => ({ + id: it.id, + name: it.name, + encoding: it.encoding, + duration: it.duration, + number: it.number, + genre: it.genre, + coverArt: it.coverArt, + artist: it.artist, + rating: it.rating +}); + export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({ id: it.id, name: it.name, @@ -176,7 +192,6 @@ export interface MusicLibrary { artist(id: string): Promise; albums(q: AlbumQuery): Promise>; album(id: string): Promise; - tracks(albumId: string): Promise; track(trackId: string): Promise; genres(): Promise; years(): Promise; @@ -200,8 +215,8 @@ export interface MusicLibrary { deletePlaylist(id: string): Promise addToPlaylist(playlistId: string, trackId: string): Promise removeFromPlaylist(playlistId: string, indicies: number[]): Promise - similarSongs(id: string): Promise; - topSongs(artistId: string): Promise; + similarSongs(id: string): Promise; + topSongs(artistId: string): Promise; radioStation(id: string): Promise radioStations(): Promise } diff --git a/src/server.ts b/src/server.ts index 6a58175..f8feb05 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,7 +24,7 @@ import { shouldScrobble } from "./smapi"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; -import { MusicService, AuthFailure, AuthSuccess } from "./music_service"; +import { MusicService, AuthFailure, AuthSuccess } from "./music_library"; import bindSmapiSoapServiceToExpress from "./smapi"; import { APITokens, InMemoryAPITokens } from "./api_tokens"; import logger from "./logger"; diff --git a/src/smapi.ts b/src/smapi.ts index d5f02d8..ec6eb69 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -20,8 +20,8 @@ import { Rating, slice2, Track, - PlaylistSummary, -} from "./music_service"; + PlaylistSummary +} from "./music_library"; import { APITokens } from "./api_tokens"; import { Clock } from "./clock"; import { URLBuilder } from "./url_builder"; @@ -983,7 +983,8 @@ function bindSmapiSoapServiceToExpress( }); case "album": return musicLibrary - .tracks(typeId!) + .album(typeId!) + .then(it => it.tracks) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ diff --git a/src/smapi_auth.ts b/src/smapi_auth.ts index 2b21519..6479692 100644 --- a/src/smapi_auth.ts +++ b/src/smapi_auth.ts @@ -103,7 +103,7 @@ function isTokenExpiredError(thing: any): thing is TokenExpiredError { return thing.name == "TokenExpiredError"; } -export const SMAPI_TOKEN_VERSION = 4; +export const SMAPI_TOKEN_VERSION = 5; export class JWTSmapiLoginTokens implements SmapiAuthTokens { private readonly clock: Clock; diff --git a/src/subsonic.ts b/src/subsonic.ts index aaaae6d..e9f407f 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -5,25 +5,18 @@ import { pipe } from "fp-ts/lib/function"; import { Md5 } from "ts-md5"; import { Credentials, - MusicService, Album, - Result, - slice2, AlbumQuery, - ArtistQuery, - MusicLibrary, AlbumSummary, Genre, Track, CoverArt, - Rating, AlbumQueryType, - Artist, - AuthFailure, - PlaylistSummary, Encoding, - AuthSuccess, -} from "./music_service"; + albumToAlbumSummary, + TrackSummary, + AuthFailure +} from "./music_library"; import sharp from "sharp"; import _ from "underscore"; import fse from "fs-extra"; @@ -32,9 +25,8 @@ import path from "path"; import axios, { AxiosRequestConfig } from "axios"; import randomstring from "randomstring"; import { b64Encode, b64Decode } from "./b64"; -import logger from "./logger"; -import { assertSystem, BUrn } from "./burn"; -import { artist } from "./smapi"; +import { BUrn } from "./burn"; +import { album, artist } from "./smapi"; import { URLBuilder } from "./url_builder"; export const BROWSER_HEADERS = { @@ -109,7 +101,7 @@ type genre = { value: string; }; -type GetGenresResponse = SubsonicResponse & { +export type GetGenresResponse = SubsonicResponse & { genres: { genre: genre[]; }; @@ -169,57 +161,70 @@ export type song = { transcodedContentType: string | undefined; type: string | undefined; userRating: number | undefined; + // todo: this field shouldnt be on song? starred: string | undefined; }; -type GetAlbumResponse = { +export type GetAlbumResponse = { album: album & { song: song[]; }; }; -type playlist = { - id: string; - name: string; - coverArt: string | undefined; -}; - -type GetPlaylistResponse = { +export type GetPlaylistResponse = { // todo: isnt the type here a composite? playlistSummary && { entry: song[]; } playlist: { id: string; name: string; - coverArt: string | undefined; entry: song[]; + + // todo: this is an ND specific field? + coverArt: string | undefined; }; }; -type GetPlaylistsResponse = { - playlists: { playlist: playlist[] }; +export type GetPlaylistsResponse = { + playlists: { + playlist: { + id: string; + name: string; + //owner: string, + //public: boolean, + //created: string, + //changed: string, + //songCount: int, + //duration: int, + + // todo: this is an ND specific field. + coverArt: string | undefined; + }[] + }; }; -type GetSimilarSongsResponse = { +export type GetSimilarSongsResponse = { similarSongs2: { song: song[] }; }; -type GetTopSongsResponse = { +export type GetTopSongsResponse = { topSongs: { song: song[] }; }; -type GetInternetRadioStationsResponse = { - internetRadioStations: { internetRadioStation: { - id: string, - name: string, - streamUrl: string, - homePageUrl?: string }[] - } -} +export type GetInternetRadioStationsResponse = { + internetRadioStations: { + internetRadioStation: { + id: string; + name: string; + streamUrl: string; + homePageUrl?: string; + }[]; + }; +}; -type GetSongResponse = { +export type GetSongResponse = { song: song; }; -type GetStarredResponse = { +export type GetStarredResponse = { starred2: { song: song[]; album: album[]; @@ -233,7 +238,7 @@ export type PingResponse = { serverVersion: string; }; -type Search3Response = SubsonicResponse & { +export type Search3Response = SubsonicResponse & { searchResult3: { artist: artist[]; album: album[]; @@ -247,12 +252,12 @@ export function isError( return (subsonicResponse as SubsonicError).error !== undefined; } -type IdName = { +export type IdName = { id: string; name: string; }; -const coverArtURN = (coverArt: string | undefined): BUrn | undefined => +export const coverArtURN = (coverArt: string | undefined): BUrn | undefined => pipe( coverArt, O.fromNullable, @@ -286,21 +291,25 @@ export const artistImageURN = ( } }; -export const asTrack = (album: Album, song: song, customPlayers: CustomPlayers): Track => ({ +export const asTrackSummary = ( + song: song, + customPlayers: CustomPlayers +): TrackSummary => ({ id: song.id, name: song.title, encoding: pipe( customPlayers.encodingFor({ mimeType: song.contentType }), - O.getOrElse(() => ({ - player: DEFAULT_CLIENT_APPLICATION, - mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType + O.getOrElse(() => ({ + player: DEFAULT_CLIENT_APPLICATION, + mimeType: song.transcodedContentType + ? song.transcodedContentType + : song.contentType, })) ), duration: song.duration || 0, number: song.track || 0, genre: maybeAsGenre(song.genre), coverArt: coverArtURN(song.coverArt), - album, artist: { id: song.artistId, name: song.artist ? song.artist : "?", @@ -317,7 +326,16 @@ export const asTrack = (album: Album, song: song, customPlayers: CustomPlayers): }, }); -const asAlbum = (album: album): Album => ({ +export const asTrack = ( + album: AlbumSummary, + song: song, + customPlayers: CustomPlayers +): Track => ({ + ...asTrackSummary(song, customPlayers), + album: album, +}); + +export const asAlbumSummary = (album: album): AlbumSummary => ({ id: album.id, name: album.name, year: album.year, @@ -327,19 +345,14 @@ const asAlbum = (album: album): Album => ({ coverArt: coverArtURN(album.coverArt), }); -// coverArtURN -const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({ - id: playlist.id, - name: playlist.name, - coverArt: coverArtURN(playlist.coverArt), -}); - export const asGenre = (genreName: string) => ({ id: b64Encode(genreName), name: genreName, }); -const maybeAsGenre = (genreName: string | undefined): Genre | undefined => +export const maybeAsGenre = ( + genreName: string | undefined +): Genre | undefined => pipe( genreName, O.fromNullable, @@ -352,7 +365,7 @@ export const asYear = (year: string) => ({ }); export interface CustomPlayers { - encodingFor({ mimeType }: { mimeType: string }): O.Option + encodingFor({ mimeType }: { mimeType: string }): O.Option; } export type CustomClient = { @@ -379,24 +392,25 @@ export class TranscodingCustomPlayers implements CustomPlayers { return new TranscodingCustomPlayers(new Map(parts)); } - encodingFor = ({ mimeType }: { mimeType: string }): O.Option => pipe( - this.transcodings.get(mimeType), - O.fromNullable, - O.map(transcodedMimeType => ({ - player:`${DEFAULT_CLIENT_APPLICATION}+${mimeType}`, - mimeType: transcodedMimeType - })) - ) + encodingFor = ({ mimeType }: { mimeType: string }): O.Option => + pipe( + this.transcodings.get(mimeType), + O.fromNullable, + O.map((transcodedMimeType) => ({ + player: `${DEFAULT_CLIENT_APPLICATION}+${mimeType}`, + mimeType: transcodedMimeType, + })) + ); } export const NO_CUSTOM_PLAYERS: CustomPlayers = { encodingFor(_) { - return O.none + return O.none; }, -} +}; -const DEFAULT_CLIENT_APPLICATION = "bonob"; -const USER_AGENT = "bonob"; +export const DEFAULT_CLIENT_APPLICATION = "bonob"; +export const USER_AGENT = "bonob"; export const asURLSearchParams = (q: any) => { const urlSearchParams = new URLSearchParams(); @@ -411,7 +425,7 @@ export const asURLSearchParams = (q: any) => { export type ImageFetcher = (url: string) => Promise; export const cachingImageFetcher = - (cacheDir: string, delegate: ImageFetcher) => + (cacheDir: string, delegate: ImageFetcher, makeSharp = sharp) => async (url: string): Promise => { const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); return fse @@ -420,7 +434,7 @@ export const cachingImageFetcher = .catch(() => delegate(url).then((image) => { if (image) { - return sharp(image.data) + return makeSharp(image.data) .png() .toBuffer() .then((png) => { @@ -463,447 +477,12 @@ const AlbumQueryTypeToSubsonicType: Record = { const artistIsInLibrary = (artistId: string | undefined) => artistId != undefined && artistId != "-1"; -type SubsonicCredentials = Credentials & { - type: string; - bearer: string | undefined; -}; - -export const asToken = (credentials: SubsonicCredentials) => +export const asToken = (credentials: Credentials) => b64Encode(JSON.stringify(credentials)); -export const parseToken = (token: string): SubsonicCredentials => +export const parseToken = (token: string): Credentials => JSON.parse(b64Decode(token)); -export class SubsonicMusicLibrary implements MusicLibrary { - subsonic: Subsonic; - credentials: Credentials - customPlayers: CustomPlayers - - constructor( - subsonic: Subsonic, - credentials: Credentials, - customPlayers: CustomPlayers - ) { - this.subsonic = subsonic - this.credentials = credentials - this.customPlayers = customPlayers - } - - flavour = () => "subsonic" - - bearerToken = (_: Credentials) => TE.right(undefined) - - artists = (q: ArtistQuery): Promise> => - this.subsonic - .getArtists(this.credentials) - .then(slice2(q)) - .then(([page, total]) => ({ - total, - results: page.map((it) => ({ - id: it.id, - name: it.name, - image: it.image, - })), - })) - - artist = async (id: string): Promise => - this.subsonic.getArtistWithInfo(this.credentials, id) - - albums = async (q: AlbumQuery): Promise> => - this.subsonic.getAlbumList2(this.credentials, q) - - album = (id: string): Promise => this.subsonic.getAlbum(this.credentials, id) - - genres = () => - this.subsonic - .getJSON(this.credentials, "/rest/getGenres") - .then((it) => - pipe( - it.genres.genre || [], - A.filter((it) => it.albumCount > 0), - A.map((it) => it.value), - A.sort(ordString), - A.map((it) => ({ id: b64Encode(it), name: it })) - ) - ) - - tracks = (albumId: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getAlbum", { - id: albumId, - }) - .then((it) => it.album) - .then((album) => - (album.song || []).map((song) => asTrack(asAlbum(album), song, this.customPlayers)) - ) - - track = (trackId: string) => this.subsonic.getTrack(this.credentials, trackId) - - rate = (trackId: string, rating: Rating) => - Promise.resolve(true) - .then(() => { - if (rating.stars >= 0 && rating.stars <= 5) { - return this.subsonic.getTrack(this.credentials, trackId); - } else { - throw `Invalid rating.stars value of ${rating.stars}`; - } - }) - .then((track) => { - const thingsToUpdate = []; - if (track.rating.love != rating.love) { - thingsToUpdate.push( - this.subsonic.getJSON( - this.credentials, - `/rest/${rating.love ? "star" : "unstar"}`, - { - id: trackId, - } - ) - ); - } - if (track.rating.stars != rating.stars) { - thingsToUpdate.push( - this.subsonic.getJSON(this.credentials, `/rest/setRating`, { - id: trackId, - rating: rating.stars, - }) - ); - } - return Promise.all(thingsToUpdate); - }) - .then(() => true) - .catch(() => false) - - stream = async ({ - trackId, - range, - }: { - trackId: string; - range: string | undefined; - }) => - this.subsonic.getTrack(this.credentials, trackId).then((track) => - this.subsonic - .get( - this.credentials, - `/rest/stream`, - { - id: trackId, - c: track.encoding.player, - }, - { - headers: pipe( - range, - O.fromNullable, - O.map((range) => ({ - "User-Agent": USER_AGENT, - Range: range, - })), - O.getOrElse(() => ({ - "User-Agent": USER_AGENT, - })) - ), - responseType: "stream", - } - ) - .then((stream) => ({ - status: stream.status, - headers: { - "content-type": stream.headers["content-type"], - "content-length": stream.headers["content-length"], - "content-range": stream.headers["content-range"], - "accept-ranges": stream.headers["accept-ranges"], - }, - stream: stream.data, - })) - ) - - coverArt = async (coverArtURN: BUrn, size?: number) => - Promise.resolve(coverArtURN) - .then((it) => assertSystem(it, "subsonic")) - .then((it) => this.subsonic.getCoverArt(this.credentials, it.resource.split(":")[1]!, size)) - .then((res) => ({ - contentType: res.headers["content-type"], - data: Buffer.from(res.data, "binary"), - })) - .catch((e) => { - logger.error( - `Failed getting coverArt for urn:'${coverArtURN}': ${e}` - ); - return undefined; - }) - - scrobble = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { - id, - submission: true, - }) - .then((_) => true) - .catch(() => false) - - nowPlaying = async (id: string) => - this.subsonic - .getJSON(this.credentials, `/rest/scrobble`, { - id, - submission: false, - }) - .then((_) => true) - .catch(() => false) - - searchArtists = async (query: string) => - this.subsonic - .search3(this.credentials, { query, artistCount: 20 }) - .then(({ artists }) => - artists.map((artist) => ({ - id: artist.id, - name: artist.name, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: artist.artistImageUrl, - }), - })) - ) - - searchAlbums = async (query: string) => - this.subsonic - .search3(this.credentials, { query, albumCount: 20 }) - .then(({ albums }) => this.subsonic.toAlbumSummary(albums)) - - searchTracks = async (query: string) => - this.subsonic - .search3(this.credentials, { query, songCount: 20 }) - .then(({ songs }) => - Promise.all( - songs.map((it) => this.subsonic.getTrack(this.credentials, it.id)) - ) - ) - - playlists = async () => - this.subsonic - .getJSON(this.credentials, "/rest/getPlaylists") - .then(({ playlists }) => (playlists.playlist || []).map(asPlayListSummary)) - - playlist = async (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/getPlaylist", { - id, - }) - .then(({ playlist }) => { - let trackNumber = 1; - return { - id: playlist.id, - name: playlist.name, - coverArt: coverArtURN(playlist.coverArt), - entries: (playlist.entry || []).map((entry) => ({ - ...asTrack( - { - id: entry.albumId!, - name: entry.album!, - year: entry.year, - genre: maybeAsGenre(entry.genre), - artistName: entry.artist, - artistId: entry.artistId, - coverArt: coverArtURN(entry.coverArt), - }, - entry, - this.customPlayers - ), - number: trackNumber++, - })), - }; - }) - - createPlaylist = async (name: string) => - this.subsonic - .getJSON(this.credentials, "/rest/createPlaylist", { - name, - }) - .then(({ playlist }) => ({ - id: playlist.id, - name: playlist.name, - coverArt: coverArtURN(playlist.coverArt), - })) - - deletePlaylist = async (id: string) => - this.subsonic - .getJSON(this.credentials, "/rest/deletePlaylist", { - id, - }) - .then((_) => true) - - addToPlaylist = async (playlistId: string, trackId: string) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { - playlistId, - songIdToAdd: trackId, - }) - .then((_) => true) - - removeFromPlaylist = async (playlistId: string, indicies: number[]) => - this.subsonic - .getJSON(this.credentials, "/rest/updatePlaylist", { - playlistId, - songIndexToRemove: indicies, - }) - .then((_) => true) - - similarSongs = async (id: string) => - this.subsonic - .getJSON( - this.credentials, - "/rest/getSimilarSongs2", - { id, count: 50 } - ) - .then((it) => it.similarSongs2.song || []) - .then((songs) => - Promise.all( - songs.map((song) => - this.subsonic - .getAlbum(this.credentials, song.albumId!) - .then((album) => asTrack(album, song, this.customPlayers)) - ) - ) - ) - - topSongs = async (artistId: string) => - this.subsonic.getArtist(this.credentials, artistId).then(({ name }) => - this.subsonic - .getJSON(this.credentials, "/rest/getTopSongs", { - artist: name, - count: 50, - }) - .then((it) => it.topSongs.song || []) - .then((songs) => - Promise.all( - songs.map((song) => - this.subsonic - .getAlbum(this.credentials, song.albumId!) - .then((album) => asTrack(album, song, this.customPlayers)) - ) - ) - ) - ) - - radioStations = async () => this.subsonic - .getJSON( - this.credentials, - "/rest/getInternetRadioStations" - ) - .then((it) => it.internetRadioStations.internetRadioStation || []) - .then((stations) => stations.map((it) => ({ - id: it.id, - name: it.name, - url: it.streamUrl, - homePage: it.homePageUrl - }))) - - radioStation = async (id: string) => this.radioStations() - .then(it => - it.find(station => station.id === id)! - ) - - years = async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100000, // FIXME: better than this, probably doesnt work anyway as max _count is 500 or something - type: "alphabeticalByArtist", - }; - const years = this.subsonic.getAlbumList2(this.credentials, q) - .then(({ results }) => - results.map((album) => album.year || "?") - .filter((item, i, ar) => ar.indexOf(item) === i) - .sort() - .map((year) => ({ - ...asYear(year) - })) - .reverse() - ); - return years; - } -} - -export class SubsonicMusicService implements MusicService { - subsonic: Subsonic; - customPlayers: CustomPlayers; - - constructor( - subsonic: Subsonic, - customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS - ) { - this.subsonic = subsonic; - this.customPlayers = customPlayers; - } - - generateToken = (credentials: Credentials): TE.TaskEither => { - const x: TE.TaskEither = TE.tryCatch( - () => - this.subsonic.getJSON( - _.pick(credentials, "username", "password"), - "/rest/ping.view" - ), - (e) => new AuthFailure(e as string) - ) - return pipe( - x, - TE.flatMap(({ type }) => - pipe( - TE.tryCatch( - () => this.libraryFor({ ...credentials, type }), - () => new AuthFailure("Failed to get library") - ), - TE.map((library) => ({ type, library })) - ) - ), - TE.flatMap(({ library, type }) => - pipe( - library.bearerToken(credentials), - TE.map((bearer) => ({ bearer, type })) - ) - ), - TE.map(({ bearer, type }) => ({ - serviceToken: asToken({ ...credentials, bearer, type }), - userId: credentials.username, - nickname: credentials.username, - })) - ) - } - - refreshToken = (serviceToken: string) => - this.generateToken(parseToken(serviceToken)); - - login = async (token: string) => this.libraryFor(parseToken(token)); - - private libraryFor = ( - credentials: Credentials & { type: string } - ): Promise => { - const genericSubsonic = new SubsonicMusicLibrary(this.subsonic, credentials, this.customPlayers); - // return Promise.resolve(genericSubsonic); - - if (credentials.type == "navidrome") { - // todo: there does not seem to be a test for this?? - const nd: SubsonicMusicLibrary = { - ...genericSubsonic, - flavour: () => "navidrome", - bearerToken: (credentials: Credentials) => - pipe( - TE.tryCatch( - () => - axios.post( - this.subsonic.url.append({ pathname: "/auth/login" }).href(), - _.pick(credentials, "username", "password") - ), - () => new AuthFailure("Failed to get bearerToken") - ), - TE.map((it) => it.data.token as string | undefined) - ), - } - return Promise.resolve(nd); - } else { - return Promise.resolve(genericSubsonic); - } - }; -} - export class Subsonic { url: URLBuilder; customPlayers: CustomPlayers; @@ -919,7 +498,7 @@ export class Subsonic { this.externalImageFetcher = externalImageFetcher; } - get = async ( + private get = async ( { username, password }: Credentials, path: string, q: {} = {}, @@ -948,7 +527,9 @@ export class Subsonic { } else return response; }); - getJSON = async ( + // todo: should I put a catch in here and force a subsonic fail status? + // or there is a catch above, that then throws, perhaps can go in there? + private getJSON = async ( { username, password }: Credentials, path: string, q: {} = {} @@ -961,6 +542,19 @@ export class Subsonic { else return json as unknown as T; }); + ping = (credentials: Credentials): TE.TaskEither => + pipe( + TE.tryCatch( + () => this.getJSON(credentials, "/rest/ping.view"), + (e) => new AuthFailure(String(e)) + ), + TE.chain(it => + it.status === "ok" + ? TE.right({ authenticated: true, type: it.type }) + : TE.left(new AuthFailure("Not authenticated, status not 'ok'")) + ) + ); + getArtists = ( credentials: Credentials ): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> => @@ -978,6 +572,7 @@ export class Subsonic { })) ); + // todo: should be getArtistInfo2? getArtistInfo = ( credentials: Credentials, id: string @@ -1001,30 +596,43 @@ export class Subsonic { m: it.mediumImageUrl, l: it.largeImageUrl, }, + //todo: this does seem to be in OpenSubsonic?? it is also singular similarArtist: (it.similarArtist || []).map((artist) => ({ id: `${artist.id}`, name: artist.name, + // todo: whats this inLibrary used for? it probably should be filtered on?? inLibrary: artistIsInLibrary(artist.id), image: artistImageURN({ artistId: artist.id, artistImageURL: artist.artistImageUrl, }), })), - })); + }) + ); - getAlbum = (credentials: Credentials, id: string): Promise => + getAlbum = (credentials: Credentials, id: string): Promise => this.getJSON(credentials, "/rest/getAlbum", { id }) .then((it) => it.album) - .then((album) => ({ - id: album.id, - name: album.name, - year: album.year, - genre: maybeAsGenre(album.genre), - artistId: album.artistId, - artistName: album.artist, - coverArt: coverArtURN(album.coverArt), - })); - + .then((album) => { + const x: AlbumSummary = { + id: album.id, + name: album.name, + year: album.year, + genre: maybeAsGenre(album.genre), + artistId: album.artistId, + artistName: album.artist, + coverArt: coverArtURN(album.coverArt) + } + return { summary: x, songs: album.song } + }).then(({ summary, songs }) => { + const x: AlbumSummary = summary + const y: Track[] = songs.map((it) => asTrack(summary, it, this.customPlayers)) + return { + ...x, + tracks: y + }; + }); + getArtist = ( credentials: Credentials, id: string @@ -1042,26 +650,6 @@ export class Subsonic { albums: this.toAlbumSummary(it.album || []), })); - getArtistWithInfo = (credentials: Credentials, id: string) => - Promise.all([ - this.getArtist(credentials, id), - this.getArtistInfo(credentials, id), - ]).then(([artist, artistInfo]) => ({ - id: artist.id, - name: artist.name, - image: artistImageURN({ - artistId: artist.id, - artistImageURL: [ - artist.artistImageUrl, - artistInfo.images.l, - artistInfo.images.m, - artistInfo.images.s, - ].find(isValidImage), - }), - albums: artist.albums, - similarArtists: artistInfo.similarArtist, - })); - getCoverArt = (credentials: Credentials, id: string, size?: number) => this.get(credentials, "/rest/getCoverArt", size ? { id, size } : { id }, { headers: { "User-Agent": "bonob" }, @@ -1075,7 +663,7 @@ export class Subsonic { .then((it) => it.song) .then((song) => this.getAlbum(credentials, song.albumId!).then((album) => - asTrack(album, song, this.customPlayers) + asTrack(albumToAlbumSummary(album), song, this.customPlayers) ) ); @@ -1115,8 +703,8 @@ export class Subsonic { this.getJSON(credentials, "/rest/getAlbumList2", { type: AlbumQueryTypeToSubsonicType[q.type], ...(q.genre ? { genre: b64Decode(q.genre) } : {}), - ...(q.fromYear ? { fromYear: q.fromYear} : {}), - ...(q.toYear ? { toYear: q.toYear} : {}), + ...(q.fromYear ? { fromYear: q.fromYear } : {}), + ...(q.toYear ? { toYear: q.toYear } : {}), size: 500, offset: q._index, }) @@ -1127,11 +715,176 @@ export class Subsonic { total: albums.length == 500 ? total : q._index + albums.length, })); - // getStarred2 = (credentials: Credentials): Promise<{ albums: Album[] }> => - // this.getJSON(credentials, "/rest/getStarred2") - // .then((it) => it.starred2) - // .then((it) => ({ - // albums: it.album.map(asAlbum), - // })); + getGenres = (credentials: Credentials) => + this.getJSON(credentials, "/rest/getGenres").then((it) => + pipe( + it.genres.genre || [], + A.filter((it) => it.albumCount > 0), + A.map((it) => it.value), + A.sort(ordString), + A.map(maybeAsGenre), + A.filter((it) => it != undefined) + ) + ); -} + private st4r = (credentials: Credentials, action: string, { id } : { id: string }) => + this.getJSON(credentials, `/rest/${action}`, { id }).then(it => + it.status == "ok" + ); + + star = (credentials: Credentials, ids : { id: string }) => + this.st4r(credentials, "star", ids) + + unstar = (credentials: Credentials, ids : { id: string }) => + this.st4r(credentials, "unstar", ids) + + setRating = (credentials: Credentials, id: string, rating: number) => + this.getJSON(credentials, `/rest/setRating`, { + id, + rating, + }) + .then(it => it.status == "ok"); + + scrobble = (credentials: Credentials, id: string, submission: boolean) => + this.getJSON(credentials, `/rest/scrobble`, { + id, + submission, + }) + .then(it => it.status == "ok") + + stream = (credentials: Credentials, id: string, c: string, range: string | undefined) => + this.get( + credentials, + `/rest/stream`, + { + id, + c, + }, + { + headers: pipe( + range, + O.fromNullable, + O.map((range) => ({ + "User-Agent": USER_AGENT, + Range: range, + })), + O.getOrElse(() => ({ + "User-Agent": USER_AGENT, + })) + ), + responseType: "stream", + } + ) + .then((stream) => ({ + status: stream.status, + headers: { + "content-type": stream.headers["content-type"], + "content-length": stream.headers["content-length"], + "content-range": stream.headers["content-range"], + "accept-ranges": stream.headers["accept-ranges"], + }, + stream: stream.data, + })); + + playlists = (credentials: Credentials) => + this.getJSON(credentials, "/rest/getPlaylists") + .then(({ playlists }) => (playlists.playlist || []).map( it => ({ + id: it.id, + name: it.name, + coverArt: coverArtURN(it.coverArt), + })) + ); + + playlist = (credentials: Credentials, id: string) => + this.getJSON(credentials, "/rest/getPlaylist", { + id, + }) + .then(({ playlist }) => { + let trackNumber = 1; + return { + id: playlist.id, + name: playlist.name, + coverArt: coverArtURN(playlist.coverArt), + entries: (playlist.entry || []).map((entry) => ({ + ...asTrack( + { + id: entry.albumId!, + name: entry.album!, + year: entry.year, + genre: maybeAsGenre(entry.genre), + artistName: entry.artist, + artistId: entry.artistId, + coverArt: coverArtURN(entry.coverArt), + }, + entry, + this.customPlayers + ), + number: trackNumber++, + })), + }; + }); + + createPlayList = (credentials: Credentials, name: string) => + this.getJSON(credentials, "/rest/createPlaylist", { + name, + }) + .then(({ playlist }) => ({ + id: playlist.id, + name: playlist.name, + coverArt: coverArtURN(playlist.coverArt), + })); + + deletePlayList = (credentials: Credentials, id: string) => + this.getJSON(credentials, "/rest/deletePlaylist", { + id, + }) + .then(it => it.status == "ok"); + + updatePlaylist = ( + credentials: Credentials, + playlistId: string, + changes : Partial<{ songIdToAdd: string | undefined, songIndexToRemove: number[] | undefined }> = {} + ) => + this.getJSON(credentials, "/rest/updatePlaylist", { + playlistId, + ...changes + }) + .then(it => it.status == "ok"); + + getSimilarSongs2 = (credentials: Credentials, id: string) => + this.getJSON( + credentials, + "/rest/getSimilarSongs2", + //todo: remove this hard coded 50? + { id, count: 50 } + ) + .then((it) => + (it.similarSongs2.song || []).map(it => asTrackSummary(it, this.customPlayers)) + ); + + getTopSongs = (credentials: Credentials, artist: string) => + this.getJSON( + credentials, + "/rest/getTopSongs", + //todo: remove this hard coded 50? + { artist, count: 50 } + ) + .then((it) => + (it.topSongs.song || []).map(it => asTrackSummary(it, this.customPlayers)) + ); + + getInternetRadioStations = (credentials: Credentials) => + this.getJSON( + credentials, + "/rest/getInternetRadioStations" + ) + .then((it) => it.internetRadioStations.internetRadioStation || []) + .then((stations) => + stations.map((it) => ({ + id: it.id, + name: it.name, + url: it.streamUrl, + homePage: it.homePageUrl, + })) + ); +}; diff --git a/src/subsonic_music_library.ts b/src/subsonic_music_library.ts new file mode 100644 index 0000000..7f47206 --- /dev/null +++ b/src/subsonic_music_library.ts @@ -0,0 +1,282 @@ +import { taskEither as TE } from "fp-ts"; +import { pipe } from "fp-ts/lib/function"; +import { + Credentials, + MusicService, + ArtistSummary, + Result, + slice2, + AlbumQuery, + ArtistQuery, + MusicLibrary, + Album, + AlbumSummary, + Rating, + Artist, + AuthFailure, + AuthSuccess, +} from "./music_library"; +import { + Subsonic, + CustomPlayers, + NO_CUSTOM_PLAYERS, + asToken, + parseToken, + artistImageURN, + asYear, + isValidImage +} from "./subsonic"; +import _ from "underscore"; + +import logger from "./logger"; +import { assertSystem, BUrn } from "./burn"; + +export class SubsonicMusicService implements MusicService { + subsonic: Subsonic; + customPlayers: CustomPlayers; + + constructor( + subsonic: Subsonic, + customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS + ) { + this.subsonic = subsonic; + this.customPlayers = customPlayers; + } + + generateToken = ( + credentials: Credentials + ): TE.TaskEither => + pipe( + this.subsonic.ping(credentials), + TE.map(() => ({ + serviceToken: asToken(credentials), + userId: credentials.username, + nickname: credentials.username, + })) + ); + + refreshToken = (serviceToken: string) => + this.generateToken(parseToken(serviceToken)); + + login = async (token: string) => this.libraryFor(parseToken(token)); + + private libraryFor = ( + credentials: Credentials + ): Promise => { + return Promise.resolve(new SubsonicMusicLibrary( + this.subsonic, + credentials, + this.customPlayers + )); + }; +} + +export class SubsonicMusicLibrary implements MusicLibrary { + subsonic: Subsonic; + credentials: Credentials; + customPlayers: CustomPlayers; + + constructor( + subsonic: Subsonic, + credentials: Credentials, + customPlayers: CustomPlayers + ) { + this.subsonic = subsonic; + this.credentials = credentials; + this.customPlayers = customPlayers; + } + + // todo: q needs to support greater than the max page size supported by subsonic + // maybe subsonic should error? + artists = (q: ArtistQuery): Promise> => + this.subsonic + .getArtists(this.credentials) + .then(slice2(q)) + .then(([page, total]) => ({ + total, + results: page, + })); + + artist = async (id: string): Promise => + Promise.all([ + this.subsonic.getArtist(this.credentials, id), + this.subsonic.getArtistInfo(this.credentials, id), + ]).then(([artist, artistInfo]) => ({ + id: artist.id, + name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: [ + artist.artistImageUrl, + // todo: subsonic.artistInfo should just return a valid image or undefined, then the music lib just chooses first undefined + // out of artist.image and artistInfo.image + artistInfo.images.l, + artistInfo.images.m, + artistInfo.images.s, + // todo: do we still need this isValidImage? + ].find(isValidImage), + }), + albums: artist.albums, + similarArtists: artistInfo.similarArtist, + })); + + albums = async (q: AlbumQuery): Promise> => + this.subsonic.getAlbumList2(this.credentials, q); + + album = (id: string): Promise => + this.subsonic.getAlbum(this.credentials, id); + + genres = () => + this.subsonic.getGenres(this.credentials); + + track = (trackId: string) => + this.subsonic.getTrack(this.credentials, trackId); + + rate = (trackId: string, rating: Rating) => + // todo: this is a bit odd + Promise.resolve(true) + .then(() => { + if (rating.stars >= 0 && rating.stars <= 5) { + return this.subsonic.getTrack(this.credentials, trackId); + } else { + throw `Invalid rating.stars value of ${rating.stars}`; + } + }) + .then((track) => { + const thingsToUpdate = []; + if (track.rating.love != rating.love) { + thingsToUpdate.push( + (rating.love ? this.subsonic.star : this.subsonic.unstar)(this.credentials,{ id: trackId }) + ); + } + if (track.rating.stars != rating.stars) { + thingsToUpdate.push( + this.subsonic.setRating(this.credentials, trackId, rating.stars) + ); + } + return Promise.all(thingsToUpdate); + }) + .then(() => true) + .catch(() => false); + + stream = async ({ + trackId, + range, + }: { + trackId: string; + range: string | undefined; + }) => + this.subsonic + .getTrack(this.credentials, trackId) + .then((track) => + this.subsonic.stream(this.credentials, trackId, track.encoding.player, range) + ); + + coverArt = async (coverArtURN: BUrn, size?: number) => + Promise.resolve(coverArtURN) + .then((it) => assertSystem(it, "subsonic")) + .then((it) => + this.subsonic.getCoverArt( + this.credentials, + it.resource.split(":")[1]!, + size + ) + ) + .then((res) => ({ + contentType: res.headers["content-type"], + data: Buffer.from(res.data, "binary"), + })) + .catch((e) => { + logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`); + return undefined; + }); + + // todo: unit test the difference between scrobble and nowPlaying + scrobble = async (id: string) => + this.subsonic.scrobble(this.credentials, id, true); + + nowPlaying = async (id: string) => + this.subsonic.scrobble(this.credentials, id, false); + + searchArtists = async (query: string) => + this.subsonic + .search3(this.credentials, { query, artistCount: 20 }) + .then(({ artists }) => + artists.map((artist) => ({ + id: artist.id, + name: artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.artistImageUrl, + }), + })) + ); + + searchAlbums = async (query: string) => + this.subsonic + .search3(this.credentials, { query, albumCount: 20 }) + .then(({ albums }) => this.subsonic.toAlbumSummary(albums)); + + searchTracks = async (query: string) => + this.subsonic + .search3(this.credentials, { query, songCount: 20 }) + .then(({ songs }) => + Promise.all( + songs.map((it) => this.subsonic.getTrack(this.credentials, it.id)) + ) + ); + + playlists = async () => + this.subsonic.playlists(this.credentials); + + playlist = async (id: string) => + this.subsonic.playlist(this.credentials, id); + + createPlaylist = async (name: string) => + this.subsonic.createPlayList(this.credentials, name); + + deletePlaylist = async (id: string) => + this.subsonic.deletePlayList(this.credentials, id); + + addToPlaylist = async (playlistId: string, trackId: string) => + this.subsonic.updatePlaylist(this.credentials, playlistId, { songIdToAdd: trackId }); + + removeFromPlaylist = async (playlistId: string, indicies: number[]) => + this.subsonic.updatePlaylist(this.credentials, playlistId, { songIndexToRemove: indicies }); + + similarSongs = async (id: string) => + this.subsonic.getSimilarSongs2(this.credentials, id) + + topSongs = async (artistId: string) => + this.subsonic.getArtist(this.credentials, artistId) + .then(({ name }) => + this.subsonic.getTopSongs(this.credentials, name) + ); + + radioStations = async () => + this.subsonic.getInternetRadioStations(this.credentials); + + radioStation = async (id: string) => + this.radioStations().then((it) => it.find((station) => station.id === id)!); + + years = async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100000, // FIXME: better than this, probably doesnt work anyway as max _count is 500 or something + type: "alphabeticalByArtist", + }; + const years = this.subsonic + .getAlbumList2(this.credentials, q) + .then(({ results }) => + results + .map((album) => album.year || "?") + .filter((item, i, ar) => ar.indexOf(item) === i) + .sort() + .map((year) => ({ + ...asYear(year), + })) + .reverse() + ); + return years; + }; +} diff --git a/tests/builders.ts b/tests/builders.ts index fcdf19d..ec6dbba 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -8,14 +8,14 @@ import { Album, Artist, Track, - albumToAlbumSummary, - artistToArtistSummary, PlaylistSummary, Playlist, SimilarArtist, AlbumSummary, - RadioStation -} from "../src/music_service"; + RadioStation, + ArtistSummary, + TrackSummary +} from "../src/music_library"; import { b64Encode } from "../src/b64"; import { artistImageURN } from "../src/subsonic"; @@ -115,13 +115,26 @@ export function aSimilarArtist( }; } -export function anArtist(fields: Partial = {}): Artist { +export function anArtistSummary(fields: Partial = {}): ArtistSummary { const id = fields.id || uuid(); - const artist = { + return { id, name: `Artist ${id}`, - albums: [anAlbum(), anAlbum(), anAlbum()], image: { system: "subsonic", resource: `art:${id}` }, + } +} + +export function anArtist(fields: Partial = {}): Artist { + const id = fields.id || uuid(); + const name = `Artist ${randomstring.generate()}` + const albums = fields.albums || [ + anAlbumSummary({ artistId: id, artistName: name }), + anAlbumSummary({ artistId: id, artistName: name }), + anAlbumSummary({ artistId: id, artistName: name }) + ]; + const artist = { + ...anArtistSummary({ id, name }), + albums, similarArtists: [ aSimilarArtist({ id: uuid(), name: "Similar artist1", inLibrary: true }), aSimilarArtist({ id: uuid(), name: "Similar artist2", inLibrary: true }), @@ -165,9 +178,9 @@ export const SAMPLE_GENRES = [ ]; export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)]; -export function aTrack(fields: Partial = {}): Track { +export function aTrackSummary(fields: Partial = {}): TrackSummary { const id = uuid(); - const artist = anArtist(); + const artist = fields.artist || anArtistSummary(); const genre = fields.genre || randomGenre(); const rating = { love: false, stars: Math.floor(Math.random() * 5) }; return { @@ -180,28 +193,53 @@ export function aTrack(fields: Partial = {}): Track { duration: randomInt(500), number: randomInt(100), genre, - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary( - anAlbum({ artistId: artist.id, artistName: artist.name, genre }) - ), + artist, coverArt: { system: "subsonic", resource: `art:${uuid()}`}, rating, ...fields, }; -} +}; -export function anAlbum(fields: Partial = {}): Album { +export function aTrack(fields: Partial = {}): Track { + const summary = aTrackSummary(fields); + const album = fields.album || anAlbumSummary({ artistId: summary.artist.id, artistName: summary.artist.name, genre: summary.genre }) + return { + ...summary, + album, + ...fields + }; +}; + +export function anAlbumSummary(fields: Partial = {}): AlbumSummary { const id = uuid(); return { id, name: `Album ${id}`, - genre: randomGenre(), year: `19${randomInt(99)}`, + genre: randomGenre(), + coverArt: { system: "subsonic", resource: `art:${uuid()}` }, artistId: `Artist ${uuid()}`, artistName: `Artist ${randomstring.generate()}`, - coverArt: { system: "subsonic", resource: `art:${uuid()}` }, + ...fields + }; +}; + +export function anAlbum(fields: Partial = {}): Album { + const albumSummary = anAlbumSummary() + const album = { + ...albumSummary, + tracks: [], ...fields, }; + const artistSummary = anArtistSummary({ id: album.artistId, name: album.artistName }) + const tracks = fields.tracks || [ + aTrack({ album: albumSummary, artist: artistSummary }), + aTrack({ album: albumSummary, artist: artistSummary }) + ] + return { + ...album, + tracks + }; }; export function aRadioStation(fields: Partial = {}): RadioStation { @@ -215,20 +253,6 @@ export function aRadioStation(fields: Partial = {}): RadioStation } } -export function anAlbumSummary(fields: Partial = {}): AlbumSummary { - const id = uuid(); - return { - id, - name: `Album ${id}`, - year: `19${randomInt(99)}`, - genre: randomGenre(), - coverArt: { system: "subsonic", resource: `art:${uuid()}` }, - artistId: `Artist ${uuid()}`, - artistName: `Artist ${randomstring.generate()}`, - ...fields - } -}; - export const BLONDIE_ID = uuid(); export const BLONDIE_NAME = "Blondie"; export const BLONDIE: Artist = { diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index bddf417..7a53f5a 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -5,8 +5,7 @@ import { InMemoryMusicService } from "./in_memory_music_service"; import { MusicLibrary, artistToArtistSummary, - albumToAlbumSummary, -} from "../src/music_service"; +} from "../src/music_library"; import { v4 as uuid } from "uuid"; import { anArtist, @@ -17,6 +16,7 @@ import { METAL, HIP_HOP, SKA, + anAlbumSummary, } from "./builders"; import _ from "underscore"; @@ -167,23 +167,6 @@ describe("InMemoryMusicService", () => { service.hasTracks(track1, track2, track3, track4); }); - describe("fetching tracks for an album", () => { - it("should return only tracks on that album", async () => { - expect(await musicLibrary.tracks(artist1Album1.id)).toEqual([ - { ...track1, rating: { love: false, stars: 0 } }, - { ...track2, rating: { love: false, stars: 0 } }, - ]); - }); - }); - - describe("fetching tracks for an album that doesnt exist", () => { - it("should return empty array", async () => { - expect(await musicLibrary.tracks("non existant album id")).toEqual( - [] - ); - }); - }); - describe("fetching a single track", () => { describe("when it exists", () => { it("should return the track", async () => { @@ -194,16 +177,16 @@ describe("InMemoryMusicService", () => { }); describe("albums", () => { - const artist1_album1 = anAlbum({ genre: POP }); - const artist1_album2 = anAlbum({ genre: ROCK }); - const artist1_album3 = anAlbum({ genre: METAL }); - const artist1_album4 = anAlbum({ genre: POP }); - const artist1_album5 = anAlbum({ genre: POP }); + const artist1_album1 = anAlbumSummary({ genre: POP }); + const artist1_album2 = anAlbumSummary({ genre: ROCK }); + const artist1_album3 = anAlbumSummary({ genre: METAL }); + const artist1_album4 = anAlbumSummary({ genre: POP }); + const artist1_album5 = anAlbumSummary({ genre: POP }); - const artist2_album1 = anAlbum({ genre: METAL }); + const artist2_album1 = anAlbumSummary({ genre: METAL }); - const artist3_album1 = anAlbum({ genre: HIP_HOP }); - const artist3_album2 = anAlbum({ genre: POP }); + const artist3_album1 = anAlbumSummary({ genre: HIP_HOP }); + const artist3_album2 = anAlbumSummary({ genre: POP }); const artist1 = anArtist({ name: "artist1", @@ -212,8 +195,8 @@ describe("InMemoryMusicService", () => { artist1_album2, artist1_album3, artist1_album4, - artist1_album5, - ], + artist1_album5 + ] }); const artist2 = anArtist({ name: "artist2", albums: [artist2_album1] }); const artist3 = anArtist({ @@ -275,16 +258,16 @@ describe("InMemoryMusicService", () => { }) ).toEqual({ results: [ - albumToAlbumSummary(artist1_album1), - albumToAlbumSummary(artist1_album2), - albumToAlbumSummary(artist1_album3), - albumToAlbumSummary(artist1_album4), - albumToAlbumSummary(artist1_album5), + artist1_album1, + artist1_album2, + artist1_album3, + artist1_album4, + artist1_album5, - albumToAlbumSummary(artist2_album1), + artist2_album1, - albumToAlbumSummary(artist3_album1), - albumToAlbumSummary(artist3_album2), + artist3_album1, + artist3_album2, ], total: totalAlbumCount, }); @@ -300,7 +283,7 @@ describe("InMemoryMusicService", () => { type: "alphabeticalByName", }) ).toEqual({ - results: _.sortBy(allAlbums, "name").map(albumToAlbumSummary), + results: _.sortBy(allAlbums, "name"), total: totalAlbumCount, }); }); @@ -317,9 +300,9 @@ describe("InMemoryMusicService", () => { }) ).toEqual({ results: [ - albumToAlbumSummary(artist1_album5), - albumToAlbumSummary(artist2_album1), - albumToAlbumSummary(artist3_album1), + artist1_album5, + artist2_album1, + artist3_album1, ], total: totalAlbumCount, }); @@ -336,8 +319,8 @@ describe("InMemoryMusicService", () => { }) ).toEqual({ results: [ - albumToAlbumSummary(artist3_album1), - albumToAlbumSummary(artist3_album2), + artist3_album1, + artist3_album2, ], total: totalAlbumCount, }); @@ -357,10 +340,10 @@ describe("InMemoryMusicService", () => { }) ).toEqual({ results: [ - albumToAlbumSummary(artist1_album1), - albumToAlbumSummary(artist1_album4), - albumToAlbumSummary(artist1_album5), - albumToAlbumSummary(artist3_album2), + artist1_album1, + artist1_album4, + artist1_album5, + artist3_album2, ], total: 4, }); @@ -379,8 +362,8 @@ describe("InMemoryMusicService", () => { }) ).toEqual({ results: [ - albumToAlbumSummary(artist1_album4), - albumToAlbumSummary(artist1_album5), + artist1_album4, + artist1_album5, ], total: 4, }); @@ -397,7 +380,7 @@ describe("InMemoryMusicService", () => { _count: 100, }) ).toEqual({ - results: [albumToAlbumSummary(artist3_album2)], + results: [artist3_album2], total: 4, }); }); @@ -424,7 +407,10 @@ describe("InMemoryMusicService", () => { describe("when it exists", () => { it("should provide an album", async () => { expect(await musicLibrary.album(artist1_album5.id)).toEqual( - artist1_album5 + { + ...artist1_album5, + tracks: [] + } ); }); }); diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 7a39683..d1fede7 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -19,11 +19,10 @@ import { slice2, asResult, artistToArtistSummary, - albumToAlbumSummary, Track, Genre, Rating, -} from "../src/music_service"; +} from "../src/music_library"; import { BUrn } from "../src/burn"; export class InMemoryMusicService implements MusicService { @@ -97,14 +96,13 @@ export class InMemoryMusicService implements MusicService { } }) .then((matches) => matches.map((it) => it.album)) - .then((it) => it.map(albumToAlbumSummary)) .then(slice2(q)) .then(asResult), album: (id: string) => pipe( this.artists.flatMap((it) => it.albums).find((it) => it.id === id), O.fromNullable, - O.map((it) => Promise.resolve(it)), + O.map((it) => Promise.resolve({ ...it, tracks: [] })), O.getOrElse(() => Promise.reject(`No album with id '${id}'`)) ), genres: () => @@ -119,12 +117,6 @@ export class InMemoryMusicService implements MusicService { A.sort(fromCompare((x, y) => ordString.compare(x.id, y.id))) ) ), - tracks: (albumId: string) => - Promise.resolve( - this.tracks - .filter((it) => it.album.id === albumId) - .map((it) => ({ ...it, rating: { love: false, stars: 0 } })) - ), rate: (_: string, _2: Rating) => Promise.resolve(false), track: (trackId: string) => pipe( diff --git a/tests/music_service.test.ts b/tests/music_library.test.ts similarity index 88% rename from tests/music_service.test.ts rename to tests/music_library.test.ts index f3f1d42..aefcd83 100644 --- a/tests/music_service.test.ts +++ b/tests/music_library.test.ts @@ -1,7 +1,7 @@ import { v4 as uuid } from "uuid"; import { anArtist } from "./builders"; -import { artistToArtistSummary } from "../src/music_service"; +import { artistToArtistSummary } from "../src/music_library"; describe("artistToArtistSummary", () => { it("should map fields correctly", () => { diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index 12dece7..f7cfceb 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -18,7 +18,7 @@ import { } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryLinkCodes } from "../src/link_codes"; -import { Credentials } from "../src/music_service"; +import { Credentials } from "../src/music_library"; import makeServer from "../src/server"; import { Service, bonobService, Sonos } from "../src/sonos"; import supersoap from "./supersoap"; diff --git a/tests/server.test.ts b/tests/server.test.ts index b6194ba..85f0c46 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -4,7 +4,7 @@ import request from "supertest"; import Image from "image-js"; import { either as E, taskEither as TE } from "fp-ts"; -import { AuthFailure, MusicService } from "../src/music_service"; +import { AuthFailure, MusicService } from "../src/music_library"; import makeServer, { BONOB_ACCESS_TOKEN_HEADER, RangeBytesFromFilter, diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 5e8d4f2..b9c49fe 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -41,6 +41,8 @@ import { PUNK, aPlaylist, aRadioStation, + anArtistSummary, + anAlbumSummary, } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import supersoap from "./supersoap"; @@ -49,7 +51,7 @@ import { artistToArtistSummary, MusicService, playlistToPlaylistSummary, -} from "../src/music_service"; +} from "../src/music_library"; import { APITokens } from "../src/api_tokens"; import dayjs from "dayjs"; import url, { URLBuilder } from "../src/url_builder"; @@ -2396,10 +2398,8 @@ describe("wsdl api", () => { }); describe("asking for an album", () => { - const album = anAlbum(); - const artist = anArtist({ - albums: [album], - }); + const album = anAlbumSummary(); + const artist = anArtistSummary(); const track1 = aTrack({ artist, album, number: 1 }); const track2 = aTrack({ artist, album, number: 2 }); @@ -2410,7 +2410,12 @@ describe("wsdl api", () => { const tracks = [track1, track2, track3, track4, track5]; beforeEach(() => { - musicLibrary.tracks.mockResolvedValue(tracks); + musicLibrary.album.mockResolvedValue(anAlbum({ + ...album, + artistName: artist.name, + artistId: artist.id, + tracks + })); }); describe("asking for all for an album", () => { @@ -2434,7 +2439,7 @@ describe("wsdl api", () => { total: tracks.length, }) ); - expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id); + expect(musicLibrary.album).toHaveBeenCalledWith(album.id); }); }); @@ -2461,7 +2466,7 @@ describe("wsdl api", () => { total: tracks.length, }) ); - expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id); + expect(musicLibrary.album).toHaveBeenCalledWith(album.id); }); }); }); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 85c663f..772bd5d 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -1,73 +1,47 @@ -import { Md5 } from "ts-md5"; +import { option as O, either as E } from "fp-ts"; import { v4 as uuid } from "uuid"; +import { Md5 } from "ts-md5"; import tmp from "tmp"; import fse from "fs-extra"; import path from "path"; -import { pipe } from "fp-ts/lib/function"; -import { option as O, taskEither as TE, task as T, either as E } from "fp-ts"; +import { pipe } from "fp-ts/lib/function"; + +import sharp from "sharp"; +jest.mock("sharp"); + +import axios from "axios"; +jest.mock("axios"); +import randomstring from "randomstring"; +jest.mock("randomstring"); + +import { URLBuilder } from "../src/url_builder"; import { isValidImage, - Subsonic, t, DODGY_IMAGE_NAME, - asGenre, asURLSearchParams, cachingImageFetcher, asTrack, artistImageURN, - images, song, - PingResponse, - parseToken, - asToken, TranscodingCustomPlayers, CustomPlayers, NO_CUSTOM_PLAYERS, - SubsonicMusicService, - SubsonicMusicLibrary + Subsonic, + asGenre, + PingResponse } from "../src/subsonic"; -import axios from "axios"; -jest.mock("axios"); - -import sharp from "sharp"; -jest.mock("sharp"); - -import randomstring from "randomstring"; -jest.mock("randomstring"); +import { getArtistJson, getArtistInfoJson, asArtistsJson } from "./subsonic_music_library.test"; -import { - Album, - Artist, - albumToAlbumSummary, - asArtistAlbumPairs, - Track, - AlbumSummary, - artistToArtistSummary, - AlbumQuery, - PlaylistSummary, - Playlist, - SimilarArtist, - Credentials, - AuthFailure, - RadioStation -} from "../src/music_service"; -import { - aGenre, - anAlbum, - anArtist, - aPlaylist, - aPlaylistSummary, - aSimilarArtist, - aTrack, - POP, - ROCK, - aRadioStation -} from "./builders"; import { b64Encode } from "../src/b64"; + +import { Album, Artist, Track, AlbumSummary, AuthFailure } from "../src/music_library"; +import { anAlbum, aTrack, anAlbumSummary, anArtistSummary, anArtist, aSimilarArtist, POP } from "./builders"; import { BUrn } from "../src/burn"; -import { URLBuilder } from "../src/url_builder"; + + describe("t", () => { it("should be an md5 of the password and the salt", () => { @@ -97,26 +71,33 @@ describe("isValidImage", () => { }); }); - describe("StreamClient(s)", () => { describe("CustomStreamClientApplications", () => { - const customClients = TranscodingCustomPlayers.from("audio/flac,audio/mp3>audio/ogg") - + const customClients = TranscodingCustomPlayers.from( + "audio/flac,audio/mp3>audio/ogg" + ); + describe("clientFor", () => { describe("when there is a match", () => { it("should return the match", () => { - expect(customClients.encodingFor({ mimeType: "audio/flac" })).toEqual(O.of({player: "bonob+audio/flac", mimeType:"audio/flac"})) - expect(customClients.encodingFor({ mimeType: "audio/mp3" })).toEqual(O.of({player: "bonob+audio/mp3", mimeType:"audio/ogg"})) + expect(customClients.encodingFor({ mimeType: "audio/flac" })).toEqual( + O.of({ player: "bonob+audio/flac", mimeType: "audio/flac" }) + ); + expect(customClients.encodingFor({ mimeType: "audio/mp3" })).toEqual( + O.of({ player: "bonob+audio/mp3", mimeType: "audio/ogg" }) + ); }); }); - + describe("when there is no match", () => { it("should return undefined", () => { - expect(customClients.encodingFor({ mimeType: "audio/bob" })).toEqual(O.none) + expect(customClients.encodingFor({ mimeType: "audio/bob" })).toEqual( + O.none + ); }); }); }); - }); + }); }); describe("asURLSearchParams", () => { @@ -188,7 +169,8 @@ describe("cachingImageFetcher", () => { toBuffer: () => Promise.resolve(pngImage), }); - const result = await cachingImageFetcher(dir.name, delegate)(url); + // todo: the fact that I need to pass the sharp mock in here isnt correct + const result = await cachingImageFetcher(dir.name, delegate, sharp)(url); expect(result!.contentType).toEqual("image/png"); expect(result!.data).toEqual(pngImage); @@ -233,71 +215,13 @@ describe("cachingImageFetcher", () => { }); }); -const ok = (data: string | object) => ({ - status: 200, - data, -}); - -const asSimilarArtistJson = (similarArtist: SimilarArtist) => { - if (similarArtist.inLibrary) - return { - id: similarArtist.id, - name: similarArtist.name, - albumCount: 3, - }; - else - return { - id: -1, - name: similarArtist.name, - albumCount: 3, - }; -}; - -const getArtistInfoJson = ( - artist: Artist, - images: images = { - smallImageUrl: undefined, - mediumImageUrl: undefined, - largeImageUrl: undefined, - } -) => - subsonicOK({ - artistInfo2: { - ...images, - similarArtist: artist.similarArtists.map(asSimilarArtistJson), - }, - }); - -const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => pipe( - coverArt, - O.fromNullable, - O.map(it => it.resource.split(":")[1]), - O.getOrElseW(() => "") -) - -const asAlbumJson = ( - artist: { id: string | undefined; name: string | undefined }, - album: AlbumSummary, - tracks: Track[] = [] -) => ({ - id: album.id, - parent: artist.id, - isDir: "true", - title: album.name, - name: album.name, - album: album.name, - artist: artist.name, - genre: album.genre?.name, - coverArt: maybeIdFromCoverArtUrn(album.coverArt), - duration: "123", - playCount: "4", - year: album.year, - created: "2021-01-07T08:19:55.834207205Z", - artistId: artist.id, - songCount: "19", - isVideo: false, - song: tracks.map(asSongJson), -}); +const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => + pipe( + coverArt, + O.fromNullable, + O.map((it) => it.resource.split(":")[1]), + O.getOrElseW(() => "") + ); const asSongJson = (track: Track) => ({ id: track.id, @@ -326,270 +250,34 @@ const asSongJson = (track: Track) => ({ year: "", }); -const getAlbumListJson = (albums: [Artist, Album][]) => - subsonicOK({ - albumList2: { - album: albums.map(([artist, album]) => asAlbumJson(artist, album)), - }, - }); - -type ArtistExtras = { artistImageUrl: string | undefined } - -const asArtistJson = ( - artist: Artist, - extras: ArtistExtras = { artistImageUrl: undefined } -) => ({ - id: artist.id, - name: artist.name, - albumCount: artist.albums.length, - album: artist.albums.map((it) => asAlbumJson(artist, it)), - ...extras, -}); - -const getArtistJson = (artist: Artist, extras: ArtistExtras = { artistImageUrl: undefined }) => - subsonicOK({ - artist: asArtistJson(artist, extras), - }); - -const getRadioStationsJson = (radioStations: RadioStation[]) => - subsonicOK({ - internetRadioStations: { - internetRadioStation: radioStations.map((it) => ({ - id: it.id, - name: it.name, - streamUrl: it.url, - homePageUrl: it.homePage - })) - }, - }); - -const asGenreJson = (genre: { name: string; albumCount: number }) => ({ - songCount: 1475, - albumCount: genre.albumCount, - value: genre.name, -}); - -const getGenresJson = (genres: { name: string; albumCount: number }[]) => - subsonicOK({ - genres: { - genre: genres.map(asGenreJson), - }, - }); - -const getAlbumJson = (artist: Artist, album: Album, tracks: Track[]) => - subsonicOK({ album: asAlbumJson(artist, album, tracks) }); - -const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); - -// const getStarredJson = ({ albums }: { albums: Album[] }) => subsonicOK({starred2: { -// album: albums.map(it => asAlbumJson({ id: it.artistId, name: it.artistName }, it, [])), -// song: [], -// }}) - -const subsonicOK = (body: any = {}) => ({ - "subsonic-response": { - status: "ok", - version: "1.16.1", - type: "subsonic", - serverVersion: "0.45.1 (c55e6590)", - ...body, - }, -}); - -const getSimilarSongsJson = (tracks: Track[]) => - subsonicOK({ similarSongs2: { song: tracks.map(asSongJson) } }); - -const getTopSongsJson = (tracks: Track[]) => - subsonicOK({ topSongs: { song: tracks.map(asSongJson) } }); - export type ArtistWithAlbum = { artist: Artist; album: Album; }; -const asPlaylistJson = (playlist: PlaylistSummary) => ({ - id: playlist.id, - name: playlist.name, - songCount: 1, - duration: 190, - public: true, - owner: "bob", - created: "2021-05-06T02:07:24.308007023Z", - changed: "2021-05-06T02:08:06Z", -}); - -const getPlayListsJson = (playlists: PlaylistSummary[]) => - subsonicOK({ - playlists: { - playlist: playlists.map(asPlaylistJson), - }, - }); - -const createPlayListJson = (playlist: PlaylistSummary) => - subsonicOK({ - playlist: asPlaylistJson(playlist), - }); - -const getPlayListJson = (playlist: Playlist) => - subsonicOK({ - playlist: { - id: playlist.id, - name: playlist.name, - songCount: playlist.entries.length, - duration: 627, - public: true, - owner: "bob", - created: "2021-05-06T02:07:30.460465988Z", - changed: "2021-05-06T02:40:04Z", - entry: playlist.entries.map((it) => ({ - id: it.id, - parent: "...", - isDir: false, - title: it.name, - album: it.album.name, - artist: it.artist.name, - track: it.number, - year: it.album.year, - genre: it.album.genre?.name, - coverArt: maybeIdFromCoverArtUrn(it.coverArt), - size: 123, - contentType: it.encoding.mimeType, - suffix: "mp3", - duration: it.duration, - bitRate: 128, - path: "...", - discNumber: 1, - created: "2019-09-04T04:07:00.138169924Z", - albumId: it.album.id, - artistId: it.artist.id, - type: "music", - isVideo: false, - starred: it.rating.love ? "sometime" : undefined, - userRating: it.rating.stars, - })), - }, - }); - -const getSearchResult3Json = ({ - artists, - albums, - tracks, -}: Partial<{ - artists: Artist[]; - albums: ArtistWithAlbum[]; - tracks: Track[]; -}>) => - subsonicOK({ - searchResult3: { - artist: (artists || []).map((it) => asArtistJson({ ...it, albums: [] })), - album: (albums || []).map((it) => asAlbumJson(it.artist, it.album, [])), - song: (tracks || []).map((it) => asSongJson(it)), - }, - }); - -const asArtistsJson = (artists: Artist[]) => { - const as: Artist[] = []; - const bs: Artist[] = []; - const cs: Artist[] = []; - const rest: Artist[] = []; - artists.forEach((it) => { - const firstChar = it.name.toLowerCase()[0]; - switch (firstChar) { - case "a": - as.push(it); - break; - case "b": - bs.push(it); - break; - case "c": - cs.push(it); - break; - default: - rest.push(it); - break; - } - }); - - const asArtistSummary = (artist: Artist) => ({ - id: artist.id, - name: artist.name, - albumCount: artist.albums.length, - }); - - return subsonicOK({ - artists: { - index: [ - { - name: "A", - artist: as.map(asArtistSummary), - }, - { - name: "B", - artist: bs.map(asArtistSummary), - }, - { - name: "C", - artist: cs.map(asArtistSummary), - }, - { - name: "D-Z", - artist: rest.map(asArtistSummary), - }, - ], - }, - }); -}; - -const error = (code: string, message: string) => ({ - "subsonic-response": { - status: "failed", - version: "1.16.1", - type: "subsonic", - serverVersion: "0.45.1 (c55e6590)", - error: { code, message }, - }, -}); - -const EMPTY = { - "subsonic-response": { - status: "ok", - version: "1.16.1", - type: "subsonic", - serverVersion: "0.45.1 (c55e6590)", - }, -}; - -const FAILURE = { - "subsonic-response": { - status: "failed", - version: "1.16.1", - type: "subsonic", - serverVersion: "0.45.1 (c55e6590)", - error: { code: 10, message: 'Missing required parameter "v"' }, - }, -}; - - - const pingJson = (pingResponse: Partial = {}) => ({ "subsonic-response": { status: "ok", version: "1.16.1", type: "subsonic", serverVersion: "0.45.1 (c55e6590)", - ...pingResponse - } -}) - -const PING_OK = pingJson({ status: "ok" }); + ...pingResponse, + }, +}); -describe("artistURN", () => { +describe("artistImageURN", () => { describe("when artist URL is", () => { describe("a valid external URL", () => { it("should return an external URN", () => { expect( - artistImageURN({ artistId: "someArtistId", artistImageURL: "http://example.com/image.jpg" }) - ).toEqual({ system: "external", resource: "http://example.com/image.jpg" }); + artistImageURN({ + artistId: "someArtistId", + artistImageURL: "http://example.com/image.jpg", + }) + ).toEqual({ + system: "external", + resource: "http://example.com/image.jpg", + }); }); }); @@ -599,7 +287,7 @@ describe("artistURN", () => { expect( artistImageURN({ artistId: "someArtistId", - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}`, }) ).toEqual({ system: "subsonic", resource: "art:someArtistId" }); }); @@ -610,7 +298,7 @@ describe("artistURN", () => { expect( artistImageURN({ artistId: "-1", - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}`, }) ).toBeUndefined(); }); @@ -621,7 +309,7 @@ describe("artistURN", () => { expect( artistImageURN({ artistId: undefined, - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}`, }) ).toBeUndefined(); }); @@ -631,19 +319,28 @@ describe("artistURN", () => { describe("undefined", () => { describe("and artistId is valid", () => { it("should return artist art by artist id URN", () => { - expect(artistImageURN({ artistId: "someArtistId", artistImageURL: undefined })).toEqual({system:"subsonic", resource:"art:someArtistId"}); + expect( + artistImageURN({ + artistId: "someArtistId", + artistImageURL: undefined, + }) + ).toEqual({ system: "subsonic", resource: "art:someArtistId" }); }); }); describe("and artistId is -1", () => { it("should return error icon", () => { - expect(artistImageURN({ artistId: "-1", artistImageURL: undefined })).toBeUndefined(); + expect( + artistImageURN({ artistId: "-1", artistImageURL: undefined }) + ).toBeUndefined(); }); }); describe("and artistId is undefined", () => { it("should return error icon", () => { - expect(artistImageURN({ artistId: undefined, artistImageURL: undefined })).toBeUndefined(); + expect( + artistImageURN({ artistId: undefined, artistImageURL: undefined }) + ).toBeUndefined(); }); }); }); @@ -658,10 +355,20 @@ describe("asTrack", () => { describe("when the song has no artistId", () => { const album = anAlbum(); - const track = aTrack({ artist: { id: undefined, name: "Not in library so no id", image: undefined }}); + const track = aTrack({ + artist: { + id: undefined, + name: "Not in library so no id", + image: undefined, + }, + }); it("should provide no artistId", () => { - const result = asTrack(album, { ...asSongJson(track) }, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { ...asSongJson(track) }, + NO_CUSTOM_PLAYERS + ); expect(result.artist.id).toBeUndefined(); expect(result.artist.name).toEqual("Not in library so no id"); expect(result.artist.image).toBeUndefined(); @@ -672,7 +379,11 @@ describe("asTrack", () => { const album = anAlbum(); it("should provide a ? to sonos", () => { - const result = asTrack(album, { id: '1' } as any as song, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { id: "1" } as any as song, + NO_CUSTOM_PLAYERS + ); expect(result.artist.id).toBeUndefined(); expect(result.artist.name).toEqual("?"); expect(result.artist.image).toBeUndefined(); @@ -685,14 +396,22 @@ describe("asTrack", () => { describe("a value greater than 5", () => { it("should be returned as 0", () => { - const result = asTrack(album, { ...asSongJson(track), userRating: 6 }, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { ...asSongJson(track), userRating: 6 }, + NO_CUSTOM_PLAYERS + ); expect(result.rating.stars).toEqual(0); }); }); describe("a value less than 0", () => { it("should be returned as 0", () => { - const result = asTrack(album, { ...asSongJson(track), userRating: -1 }, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { ...asSongJson(track), userRating: -1 }, + NO_CUSTOM_PLAYERS + ); expect(result.rating.stars).toEqual(0); }); }); @@ -705,387 +424,264 @@ describe("asTrack", () => { describe("when there are no custom players", () => { describe("when subsonic reports no transcodedContentType", () => { it("should use the default client and default contentType", () => { - const result = asTrack(album, { - ...asSongJson(track), - contentType: "nonTranscodedContentType", - transcodedContentType: undefined - }, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: undefined, + }, + NO_CUSTOM_PLAYERS + ); - expect(result.encoding).toEqual({ player: "bonob", mimeType: "nonTranscodedContentType" }) + expect(result.encoding).toEqual({ + player: "bonob", + mimeType: "nonTranscodedContentType", + }); }); }); describe("when subsonic reports a transcodedContentType", () => { it("should use the default client and transcodedContentType", () => { - const result = asTrack(album, { - ...asSongJson(track), - contentType: "nonTranscodedContentType", - transcodedContentType: "transcodedContentType" - }, NO_CUSTOM_PLAYERS); + const result = asTrack( + album, + { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: "transcodedContentType", + }, + NO_CUSTOM_PLAYERS + ); - expect(result.encoding).toEqual({ player: "bonob", mimeType: "transcodedContentType" }) + expect(result.encoding).toEqual({ + player: "bonob", + mimeType: "transcodedContentType", + }); }); }); }); describe("when there are custom players registered", () => { const streamClient = { - encodingFor: jest.fn() - } + encodingFor: jest.fn(), + }; describe("however no player is found for the default mimeType", () => { describe("and there is no transcodedContentType", () => { it("should use the default player with the default content type", () => { - streamClient.encodingFor.mockReturnValue(O.none) + streamClient.encodingFor.mockReturnValue(O.none); - const result = asTrack(album, { - ...asSongJson(track), - contentType: "nonTranscodedContentType", - transcodedContentType: undefined - }, streamClient as unknown as CustomPlayers); - - expect(result.encoding).toEqual({ player: "bonob", mimeType: "nonTranscodedContentType" }); - expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "nonTranscodedContentType" }); + const result = asTrack( + album, + { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: undefined, + }, + streamClient as unknown as CustomPlayers + ); + + expect(result.encoding).toEqual({ + player: "bonob", + mimeType: "nonTranscodedContentType", + }); + expect(streamClient.encodingFor).toHaveBeenCalledWith({ + mimeType: "nonTranscodedContentType", + }); }); }); describe("and there is a transcodedContentType", () => { it("should use the default player with the transcodedContentType", () => { - streamClient.encodingFor.mockReturnValue(O.none) + streamClient.encodingFor.mockReturnValue(O.none); - const result = asTrack(album, { - ...asSongJson(track), - contentType: "nonTranscodedContentType", - transcodedContentType: "transcodedContentType1" - }, streamClient as unknown as CustomPlayers); - - expect(result.encoding).toEqual({ player: "bonob", mimeType: "transcodedContentType1" }); - expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "nonTranscodedContentType" }); + const result = asTrack( + album, + { + ...asSongJson(track), + contentType: "nonTranscodedContentType", + transcodedContentType: "transcodedContentType1", + }, + streamClient as unknown as CustomPlayers + ); + + expect(result.encoding).toEqual({ + player: "bonob", + mimeType: "transcodedContentType1", + }); + expect(streamClient.encodingFor).toHaveBeenCalledWith({ + mimeType: "nonTranscodedContentType", + }); }); }); }); describe("there is a player with the matching content type", () => { it("should use it", () => { - const customEncoding = { player: "custom-player", mimeType: "audio/some-mime-type" }; + const customEncoding = { + player: "custom-player", + mimeType: "audio/some-mime-type", + }; streamClient.encodingFor.mockReturnValue(O.of(customEncoding)); - - const result = asTrack(album, { - ...asSongJson(track), - contentType: "sourced-from/subsonic", - transcodedContentType: "sourced-from/subsonic2" - }, streamClient as unknown as CustomPlayers); - + + const result = asTrack( + album, + { + ...asSongJson(track), + contentType: "sourced-from/subsonic", + transcodedContentType: "sourced-from/subsonic2", + }, + streamClient as unknown as CustomPlayers + ); + expect(result.encoding).toEqual(customEncoding); - expect(streamClient.encodingFor).toHaveBeenCalledWith({ mimeType: "sourced-from/subsonic" }); - }); + expect(streamClient.encodingFor).toHaveBeenCalledWith({ + mimeType: "sourced-from/subsonic", + }); + }); }); }); }); }); -describe("SubsonicMusicService", () => { - const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); - const username = `user1-${uuid()}`; - const password = `pass1-${uuid()}`; - const salt = "saltysalty"; - - const customPlayers = { - encodingFor: jest.fn() +const subsonicResponse = (response : Partial<{ status: string, body: any }> = { }) => { + const status = response.status || "ok" + const body = response.body || {} + return { + "subsonic-response": { + status, + version: "1.16.1", + type: "subsonic", + serverVersion: "0.45.1 (c55e6590)", + ...body, + }, }; +}; - const subsonic = new SubsonicMusicService( - new Subsonic(url, customPlayers), - customPlayers as unknown as CustomPlayers - ); - - const mockRandomstring = jest.fn(); - const mockGET = jest.fn(); - const mockPOST = jest.fn(); - - beforeEach(() => { - jest.clearAllMocks(); - jest.resetAllMocks(); +const subsonicOK = (body: any = {}) => subsonicResponse({ status: "ok", body }); - randomstring.generate = mockRandomstring; - axios.get = mockGET; - axios.post = mockPOST; +const asGenreJson = (genre: { name: string; albumCount: number }) => ({ + songCount: 1475, + albumCount: genre.albumCount, + value: genre.name, +}); - mockRandomstring.mockReturnValue(salt); +const getGenresJson = (genres: { name: string; albumCount: number }[]) => + subsonicOK({ + genres: { + genre: genres.map(asGenreJson), + }, }); - const authParams = { - u: username, - v: "1.16.1", - c: "bonob", - t: t(password, salt), - s: salt, - }; - - const authParamsPlusJson = { - ...authParams, - f: "json", - }; - - const headers = { - "User-Agent": "bonob", - }; - - - const tokenFor = (credentials: Credentials) => pipe( - subsonic.generateToken(credentials), - TE.fold(e => { throw e }, T.of) - ) - - - describe("generateToken", () => { - describe("when the credentials are valid", () => { - describe("when the backend is generic subsonic", () => { - it("should be able to generate a token and then login using it", async () => { - (axios.get as jest.Mock).mockResolvedValue(ok(PING_OK)); - - const token = await tokenFor({ - username, - password, - })() - - expect(token.serviceToken).toBeDefined(); - expect(token.nickname).toEqual(username); - expect(token.userId).toEqual(username); - - expect(parseToken(token.serviceToken)).toEqual({ username, password, type: PING_OK["subsonic-response"].type }) - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/ping.view' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - - it("should store the type of the subsonic server on the token", async () => { - const type = "someSubsonicClone"; - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type }))); - - const token = await tokenFor({ - username, - password, - })() - - expect(token.serviceToken).toBeDefined(); - expect(token.nickname).toEqual(username); - expect(token.userId).toEqual(username); - - expect(parseToken(token.serviceToken)).toEqual({ username, password, type }) - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/ping.view' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when the backend is navidrome", () => { - it("should login to nd and get the nd bearer token", async () => { - const navidromeToken = `nd-${uuid()}`; - - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type: "navidrome" }))); - (axios.post as jest.Mock).mockResolvedValue(ok({ token: navidromeToken })); - - const token = await tokenFor({ - username, - password, - })() - - expect(token.serviceToken).toBeDefined(); - expect(token.nickname).toEqual(username); - expect(token.userId).toEqual(username); - - expect(parseToken(token.serviceToken)).toEqual({ username, password, type: "navidrome", bearer: navidromeToken }) - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/ping.view' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - expect(axios.post).toHaveBeenCalledWith(url.append({ pathname: '/auth/login' }).href(), { - username, - password, - }); - }); - }); - }); - - describe("when the credentials are not valid", () => { - it("should be able to generate a token and then login using it", async () => { - (axios.get as jest.Mock).mockResolvedValue({ - status: 200, - data: error("40", "Wrong username or password"), - }); - - const token = await subsonic.generateToken({ username, password })(); - expect(token).toEqual(E.left(new AuthFailure("Subsonic error:Wrong username or password"))); - }); - }); - }); - - describe("refreshToken", () => { - describe("when the credentials are valid", () => { - describe("when the backend is generic subsonic", () => { - it("should be able to generate a token and then login using it", async () => { - const type = `subsonic-clone-${uuid()}`; - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type }))); - - const credentials = { username, password, type: "foo", bearer: undefined }; - const originalToken = asToken(credentials) - - const refreshedToken = await pipe( - subsonic.refreshToken(originalToken), - TE.fold(e => { throw e }, T.of) - )(); - - expect(refreshedToken.serviceToken).toBeDefined(); - expect(refreshedToken.nickname).toEqual(credentials.username); - expect(refreshedToken.userId).toEqual(credentials.username); - - expect(parseToken(refreshedToken.serviceToken)).toEqual({ username, password, type }) - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/ping.view' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when the backend is navidrome", () => { - it("should login to nd and get the nd bearer token", async () => { - const navidromeToken = `nd-${uuid()}`; - - (axios.get as jest.Mock).mockResolvedValue(ok(pingJson({ type: "navidrome" }))); - (axios.post as jest.Mock).mockResolvedValue(ok({ token: navidromeToken })); - - const credentials = { username, password, type: "navidrome", bearer: undefined }; - const originalToken = asToken(credentials) - - const refreshedToken = await pipe( - subsonic.refreshToken(originalToken), - TE.fold(e => { throw e }, T.of) - )(); - - expect(refreshedToken.serviceToken).toBeDefined(); - expect(refreshedToken.nickname).toEqual(username); - expect(refreshedToken.userId).toEqual(username); - - expect(parseToken(refreshedToken.serviceToken)).toEqual({ username, password, type: "navidrome", bearer: navidromeToken }) - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/ping.view' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - expect(axios.post).toHaveBeenCalledWith(url.append({ pathname: '/auth/login' }).href(), { - username, - password, - }); - }); - }); - }); - - describe("when the credentials are not valid", () => { - it("should be able to generate a token and then login using it", async () => { - (axios.get as jest.Mock).mockResolvedValue({ - status: 200, - data: error("40", "Wrong username or password"), - }); - - const credentials = { username, password, type: "foo", bearer: undefined }; - const originalToken = asToken(credentials) - - const token = await subsonic.refreshToken(originalToken)(); - expect(token).toEqual(E.left(new AuthFailure("Subsonic error:Wrong username or password"))); - }); - }); - }); - - describe("login", () => { - describe("when the token is for generic subsonic", () => { - it("should return a subsonic client", async () => { - const client = await subsonic.login(asToken({ username: "foo", password: "bar", type: "subsonic", bearer: undefined })); - expect(client.flavour()).toEqual("subsonic"); - }); - }); - - describe("when the token is for navidrome", () => { - it("should return a navidrome client", async () => { - const client = await subsonic.login(asToken({ username: "foo", password: "bar", type: "navidrome", bearer: undefined })); - expect(client.flavour()).toEqual("navidrome"); - }); - }); - - describe("when the token is for gonic", () => { - it("should return a subsonic client", async () => { - const client = await subsonic.login(asToken({ username: "foo", password: "bar", type: "gonic", bearer: undefined })); - expect(client.flavour()).toEqual("subsonic"); - }); - }); - }); - - describe("bearerToken", () => { - describe("when flavour is generic subsonic", () => { - it("should return undefined", async () => { - const credentials = { username: "foo", password: "bar" }; - const token = { ...credentials, type: "subsonic", bearer: undefined } - const client = await subsonic.login(asToken(token)); - - const bearerToken = await pipe(client.bearerToken(credentials))(); - expect(bearerToken).toStrictEqual(E.right(undefined)); - }); - }); - - describe("when flavour is navidrome", () => { - it("should get a bearerToken from navidrome", async () => { - const credentials = { username: "foo", password: "bar" }; - const token = { ...credentials, type: "navidrome", bearer: undefined } - const client = await subsonic.login(asToken(token)); - - mockPOST.mockImplementationOnce(() => Promise.resolve(ok({ token: 'theBearerToken' }))) - - const bearerToken = await pipe(client.bearerToken(credentials))(); - expect(bearerToken).toStrictEqual(E.right('theBearerToken')); +const ok = (data: string | object) => ({ + status: 200, + data, +}); - expect(axios.post).toHaveBeenCalledWith(url.append({ pathname: '/auth/login' }).href(), credentials) - }); - }); - }); +export const asArtistAlbumJson = ( + artist: { id: string | undefined; name: string | undefined }, + album: AlbumSummary +) => ({ + id: album.id, + parent: artist.id, + isDir: "true", + title: album.name, + name: album.name, + album: album.name, + artist: artist.name, + genre: album.genre?.name, + duration: "123", + playCount: "4", + year: album.year, + created: "2021-01-07T08:19:55.834207205Z", + artistId: artist.id, + songCount: "19", +}); +export const asAlbumJson = ( + artist: { id: string | undefined; name: string | undefined }, + album: Album +) => ({ + id: album.id, + parent: artist.id, + isDir: "true", + title: album.name, + name: album.name, + album: album.name, + artist: artist.name, + genre: album.genre?.name, + coverArt: maybeIdFromCoverArtUrn(album.coverArt), + duration: "123", + playCount: "4", + year: album.year, + created: "2021-01-07T08:19:55.834207205Z", + artistId: artist.id, + songCount: "19", + isVideo: false, + song: album.tracks.map(asSongJson), }); -describe("SubsonicMusicLibrary", () => { - const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); - const username = `user1-${uuid()}`; - const password = `pass1-${uuid()}`; - const salt = "saltysalty"; +export const getAlbumJson = (album: Album) => + subsonicOK({ album: { + id: album.id, + parent: album.artistId, + album: album.name, + title: album.name, + name: album.name, + isDir: true, + coverArt: maybeIdFromCoverArtUrn(album.coverArt), + songCount: 19, + created: "2021-01-07T08:19:55.834207205Z", + duration: 123, + playCount: 4, + artistId: album.artistId, + artist: album.artistName, + year: album.year, + genre: album.genre?.name, + song: album.tracks.map(track => ({ + id: track.id, + parent: track.album.id, + title: track.name, + isDir: false, + isVideo: false, + type: "music", + albumId: track.album.id, + album: track.album.name, + artistId: track.artist.id, + artist: track.artist.name, + coverArt: maybeIdFromCoverArtUrn(track.coverArt), + duration: track.duration, + bitRate: 128, + bitDepth: 16, + samplingRate: 555, + channelCount: 2, + track: track.number, + year: 1900, + genre: track.genre?.name, + size: 5624132, + discNumer: 1, + suffix: "mp3", + contentType: track.encoding.mimeType, + path: "ACDC/High voltage/ACDC - The Jack.mp3" + })), + } }); + +describe("Subsonic", () => { + const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); const customPlayers = { - encodingFor: jest.fn() + encodingFor: jest.fn(), }; - - const subsonic = new SubsonicMusicLibrary( - new Subsonic(url, customPlayers), - { username, password }, - customPlayers as unknown as CustomPlayers - ); + const username = `user1-${uuid()}`; + const password = `pass1-${uuid()}`; + const credentials = { username, password }; + const subsonic = new Subsonic(url, customPlayers); const mockRandomstring = jest.fn(); const mockGET = jest.fn(); const mockPOST = jest.fn(); - beforeEach(() => { - jest.clearAllMocks(); - jest.resetAllMocks(); - - randomstring.generate = mockRandomstring; - axios.get = mockGET; - axios.post = mockPOST; - - mockRandomstring.mockReturnValue(salt); - }); + const salt = "saltysalty"; const authParams = { u: username, @@ -1104,3880 +700,968 @@ describe("SubsonicMusicLibrary", () => { "User-Agent": "bonob", }; + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); - describe("getting genres", () => { - describe("when there are none", () => { + randomstring.generate = mockRandomstring; + axios.get = mockGET; + axios.post = mockPOST; + + mockRandomstring.mockReturnValue(salt); + }); + + describe("ping", () => { + describe("when authenticates and status is ok", () => { beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(getGenresJson([])))); + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(pingJson({ + status: "ok", + type: "subsonic-that-works" + }))) + ); }); - it("should return empty array", async () => { - const result = await subsonic.genres(); + it("should return authenticated", async () => { + const result = await subsonic.ping(credentials)(); + expect(result).toEqual(E.right({ authenticated: true, type: "subsonic-that-works" })); + }); + }); - expect(result).toEqual([]); + describe("when authenticates however status is not ok", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(pingJson({ + status: "i am not ok", + type: "subsonic-that-doesnt-works" + }))) + ); + }); - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getGenres' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); + it("should return an error", async () => { + const result = await subsonic.ping(credentials)(); + expect(result).toEqual(E.left(new AuthFailure("Not authenticated, status not 'ok'"))); }); }); + }); - describe("when there is only 1 that has an albumCount > 0", () => { - const genres = [ - { name: "genre1", albumCount: 1 }, - { name: "genreWithNoAlbums", albumCount: 0 }, - ]; - + describe("getting artists", () => { + describe("when there are indexes, but no artists", () => { beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getGenresJson(genres))) - ); + mockGET.mockImplementationOnce(() => + Promise.resolve( + ok( + subsonicOK({ + artists: { + index: [ + { + name: "#", + }, + { + name: "A", + }, + { + name: "B", + }, + ], + }, + }) + ) + ) + ); }); - it("should return them alphabetically sorted", async () => { - const result = await subsonic.genres(); - - expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); + it("should return empty", async () => { + const artists = await subsonic.getArtists(credentials); - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getGenres' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); + expect(artists).toEqual([]); }); }); - describe("when there are many that have an albumCount > 0", () => { - const genres = [ - { name: "g1", albumCount: 1 }, - { name: "g2", albumCount: 1 }, - { name: "g3", albumCount: 1 }, - { name: "g4", albumCount: 1 }, - { name: "someGenreWithNoAlbums", albumCount: 0 }, - ]; - + describe("when there no indexes and no artists", () => { beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getGenresJson(genres))) - ); + mockGET.mockImplementationOnce(() => + Promise.resolve( + ok( + subsonicOK({ + artists: {}, + }) + ) + ) + ); }); - it("should return them alphabetically sorted", async () => { - const result = await subsonic.genres(); - - expect(result).toEqual([ - { id: b64Encode("g1"), name: "g1" }, - { id: b64Encode("g2"), name: "g2" }, - { id: b64Encode("g3"), name: "g3" }, - { id: b64Encode("g4"), name: "g4" }, - ]); + it("should return empty", async () => { + const artists = await subsonic.getArtists(credentials); - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getGenres' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); + expect(artists).toEqual([]); }); }); - }); - - describe("getting an artist", () => { - describe("when the artist exists", () => { - describe("and has many similar artists", () => { - const album1: Album = anAlbum({ genre: asGenre("Pop") }); - const album2: Album = anAlbum({ genre: asGenre("Pop") }); + describe("when there are artists", () => { + const artist1 = anArtist({ name: "A Artist", albums: [anAlbum()] }); + const artist2 = anArtist({ name: "B Artist", albums: [anAlbum(), anAlbum()] }); + const artist3 = anArtist({ name: "C Artist" }); + const artist4 = anArtist({ name: "D Artist" }); + const artists = [artist1, artist2, artist3, artist4]; - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [ - aSimilarArtist({ - id: "similar1.id", - name: "similar1", - inLibrary: true, - }), - aSimilarArtist({ id: "-1", name: "similar2", inLibrary: false }), - aSimilarArtist({ - id: "similar3.id", - name: "similar3", - inLibrary: true, - }), - aSimilarArtist({ id: "-1", name: "similar4", inLibrary: false }), - ], - }); + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ); + }); - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); + it("should return all the artists", async () => { + const artists = await subsonic.getArtists(credentials); - it("should return the similar artists", async () => { - const result: Artist = await subsonic.artist(artist.id!); + const expectedResults = [artist1, artist2, artist3, artist4].map( + (it) => ({ + id: it.id, + image: it.image, + name: it.name, + albumCount: it.albums.length + }) + ); - expect(result).toEqual({ - id: `${artist.id}`, - name: artist.name, - image: { system:"subsonic", resource:`art:${artist.id}` }, - albums: artist.albums, - similarArtists: artist.similarArtists, - }); + expect(artists).toEqual(expectedResults); - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has one similar artist", () => { - const album1: Album = anAlbum({ genre: asGenre("G1") }); - - const album2: Album = anAlbum({ genre: asGenre("G2") }); - - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [ - aSimilarArtist({ - id: "similar1.id", - name: "similar1", - inLibrary: true, - }), - ], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return the similar artists", async () => { - const result: Artist = await subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system:"subsonic", resource:`art:${artist.id}` }, - albums: artist.albums, - similarArtists: artist.similarArtists, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has no similar artists", () => { - const album1: Album = anAlbum({ genre: asGenre("Jock") }); - - const album2: Album = anAlbum({ genre: asGenre("Mock") }); - - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return the similar artists", async () => { - const result: Artist = await subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system:"subsonic", resource: `art:${artist.id}` }, - albums: artist.albums, - similarArtists: artist.similarArtists, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has dodgy looking artist image uris", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl}))) - ); - }); - - it("should return remove the dodgy looking image uris and return urn for artist:id", async () => { - const result: Artist = await subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { - system: "subsonic", - resource: `art:${artist.id}`, - }, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); + } + ); }); + }); + }); - describe("and has a good external image uri from getArtist route", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: 'http://example.com:1234/good/looking/image.png' }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: dodgyImageUrl }))) - ); - }); - - it("should use the external url", async () => { - const result: Artist = await subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system: "external", resource: 'http://example.com:1234/good/looking/image.png' }, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has a good large external image uri from getArtistInfo route", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: dodgyImageUrl, largeImageUrl: 'http://example.com:1234/good/large/image.png' }))) - ); - }); - - it("should use the external url", async () => { - const result: Artist = await subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system: "external", resource: 'http://example.com:1234/good/large/image.png' }, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - - describe("and has a good medium external image uri from getArtistInfo route", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - const dodgyImageUrl = `http://localhost:1234/${DODGY_IMAGE_NAME}`; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist, { artistImageUrl: dodgyImageUrl }))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist, { smallImageUrl: dodgyImageUrl, mediumImageUrl: 'http://example.com:1234/good/medium/image.png', largeImageUrl: dodgyImageUrl }))) - ); - }); - - it("should use the external url", async () => { - const result: Artist = await subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: { system:"external", resource: 'http://example.com:1234/good/medium/image.png' }, - albums: artist.albums, - similarArtists: [], + describe("getArtist", () => { + describe("when the artist exists", () => { + describe("and has multiple albums", () => { + const album1 = anAlbumSummary({ genre: asGenre("Pop") }); + + const album2 = anAlbumSummary({ genre: asGenre("Flop") }); + + const artist: Artist = anArtist({ + albums: [album1, album2] }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, + + it("should return it", async () => { + const result = await subsonic.getArtist(credentials, artist.id!); + + expect(result).toEqual({ id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - - describe("and has multiple albums", () => { - const album1: Album = anAlbum({ genre: asGenre("Pop") }); - - const album2: Album = anAlbum({ genre: asGenre("Flop") }); - - const artist: Artist = anArtist({ - albums: [album1, album2], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) + name: artist.name, + artistImageUrl: undefined, + albums: artist.albums + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } ); - }); - - it("should return it", async () => { - const result: Artist = await subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, }); }); - }); - - describe("and has only 1 album", () => { - const album: Album = anAlbum({ genre: asGenre("Pop") }); - - const artist: Artist = anArtist({ - albums: [album], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return it", async () => { - const result: Artist = await subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: artist.albums, - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, + + describe("and has only 1 album", () => { + const album = anAlbumSummary({ genre: POP }); + + const artist: Artist = anArtist({ + albums: [album] }); - }); - }); - - describe("and has no albums", () => { - const artist: Artist = anArtist({ - albums: [], - similarArtists: [], - }); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); - - it("should return it", async () => { - const result: Artist = await subsonic.artist(artist.id!); - - expect(result).toEqual({ - id: artist.id, - name: artist.name, - image: artist.image, - albums: [], - similarArtists: [], - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, - }); - }); - }); - }); - }); - - describe("getting artists", () => { - describe("when there are indexes, but no artists", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve( - ok( - subsonicOK({ - artists: { - index: [ - { - name: "#", - }, - { - name: "A", - }, - { - name: "B", - }, - ], - }, - }) - ) - ) - ); - }); - - it("should return empty", async () => { - const artists = await subsonic.artists({ _index: 0, _count: 100 }); - - expect(artists).toEqual({ - results: [], - total: 0, - }); - }); - }); - - describe("when there no indexes and no artists", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve( - ok( - subsonicOK({ - artists: {}, - }) - ) - ) - ); - }); - - it("should return empty", async () => { - const artists = await subsonic.artists({ _index: 0, _count: 100 }); - - expect(artists).toEqual({ - results: [], - total: 0, - }); - }); - }); - - describe("when there is one index and one artist", () => { - const artist1 = anArtist({albums:[anAlbum(), anAlbum(), anAlbum(), anAlbum()]}); - - const asArtistsJson = subsonicOK({ - artists: { - index: [ - { - name: "#", - artist: [ - { - id: artist1.id, - name: artist1.name, - albumCount: artist1.albums.length, - }, - ], - }, - ], - }, - }); - - describe("when it all fits on one page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson))); - }); - - it("should return the single artist", async () => { - const artists = await subsonic.artists({ _index: 0, _count: 100 }); - - const expectedResults = [{ - id: artist1.id, - image: artist1.image, - name: artist1.name, - }]; - - expect(artists).toEqual({ - results: expectedResults, - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - }); - - describe("when there are artists", () => { - const artist1 = anArtist({ name: "A Artist", albums:[anAlbum()] }); - const artist2 = anArtist({ name: "B Artist" }); - const artist3 = anArtist({ name: "C Artist" }); - const artist4 = anArtist({ name: "D Artist" }); - const artists = [artist1, artist2, artist3, artist4]; - - describe("when no paging is in effect", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ); - }); - - it("should return all the artists", async () => { - const artists = await subsonic.artists({ _index: 0, _count: 100 }); - - const expectedResults = [artist1, artist2, artist3, artist4].map( - (it) => ({ - id: it.id, - image: it.image, - name: it.name, - }) - ); - - expect(artists).toEqual({ - results: expectedResults, - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - - describe("when paging specified", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ); - }); - - it("should return only the correct page of artists", async () => { - const artists = await subsonic.artists({ _index: 1, _count: 2 }); - - const expectedResults = [artist2, artist3].map((it) => ({ - id: it.id, - image: it.image, - name: it.name, - })); - - expect(artists).toEqual({ results: expectedResults, total: 4 }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - }); - }); - - describe("getting albums", () => { - describe("filtering", () => { - const album1 = anAlbum({ id: "album1", genre: asGenre("Pop") }); - const album2 = anAlbum({ id: "album2", genre: asGenre("Rock") }); - const album3 = anAlbum({ id: "album3", genre: asGenre("Pop") }); - const album4 = anAlbum({ id: "album4", genre: asGenre("Pop") }); - const album5 = anAlbum({ id: "album5", genre: asGenre("Pop") }); - - const artist = anArtist({ - albums: [album1, album2, album3, album4, album5], - }); - - describe("by genre", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist, album1], - // album2 is not Pop - [artist, album3], - ]) - ) - ) - ); - }); - - it("should map the 64 encoded genre back into the subsonic genre", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - genre: b64Encode("Pop"), - type: "byGenre", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album1, album3].map(albumToAlbumSummary), - total: 2, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "byGenre", - genre: "Pop", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by newest", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist, album3], - [artist, album2], - [artist, album1], - ]) - ) - ) - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "recentlyAdded", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album3, album2, album1].map(albumToAlbumSummary), - total: 3, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "newest", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by recently played", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist, album3], - [artist, album2], - // album1 never played - ]) - ) - ) - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "recentlyPlayed", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album3, album2].map(albumToAlbumSummary), - total: 2, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "recent", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by frequently played", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce( - () => - // album1 never played - Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) - // album3 never played - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "mostPlayed" }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album2].map(albumToAlbumSummary), - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "frequent", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("by starred", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson([artist]))) - ) - .mockImplementationOnce( - () => - // album1 never played - Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) - // album3 never played - ); - }); - - it("should pass the filter to navidrome", async () => { - const q: AlbumQuery = { _index: 0, _count: 100, type: "starred" }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album2].map(albumToAlbumSummary), - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "highest", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - }); - - describe("when the artist has only 1 album", () => { - const artist = anArtist({ - name: "one hit wonder", - albums: [anAlbum({ genre: asGenre("Pop") })], - }); - const artists = [artist]; - const albums = artists.flatMap((artist) => artist.albums); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) - ); - }); - - it("should return the album", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: albums, - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("when the only artist has no albums", () => { - const artist = anArtist({ - name: "no hit wonder", - albums: [], - }); - const artists = [artist]; - const albums = artists.flatMap((artist) => artist.albums); - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) - ); - }); - - it("should return the album", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: albums, - total: 0, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("when there are 6 albums in total", () => { - const genre1 = asGenre("genre1"); - const genre2 = asGenre("genre2"); - const genre3 = asGenre("genre3"); - - const artist1 = anArtist({ - name: "abba", - albums: [ - anAlbum({ name: "album1", genre: genre1 }), - anAlbum({ name: "album2", genre: genre2 }), - anAlbum({ name: "album3", genre: genre3 }), - ], - }); - const artist2 = anArtist({ - name: "babba", - albums: [ - anAlbum({ name: "album4", genre: genre1 }), - anAlbum({ name: "album5", genre: genre2 }), - anAlbum({ name: "album6", genre: genre3 }), - ], - }); - const artists = [artist1, artist2]; - const albums = artists.flatMap((artist) => artist.albums); - - describe("querying for all of them", () => { - it("should return all of them with corrent paging information", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) - ); - - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: albums, - total: 6, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 0, - }), - headers, - }); - }); - }); - - describe("querying for a page of them", () => { - it("should return the page with the corrent paging information", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, artist1.albums[2]!], - [artist2, artist2.albums[0]!], - // due to pre-fetch will get next 2 albums also - [artist2, artist2.albums[1]!], - [artist2, artist2.albums[2]!], - ]) - ) - ) - ); - - const q: AlbumQuery = { - _index: 2, - _count: 2, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [artist1.albums[2], artist2.albums[0]], - total: 6, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbumList2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: 2, - }), - headers, - }); - }); - }); - }); - - describe("when the number of albums reported by getArtists does not match that of getAlbums", () => { - const genre = asGenre("lofi"); - - const album1 = anAlbum({ name: "album1", genre }); - const album2 = anAlbum({ name: "album2", genre }); - const album3 = anAlbum({ name: "album3", genre }); - const album4 = anAlbum({ name: "album4", genre }); - const album5 = anAlbum({ name: "album5", genre }); - - // the artists have 5 albums in the getArtists endpoint - const artist1 = anArtist({ - albums: [album1, album2, album3, album4], - }); - const artist2 = anArtist({ - albums: [album5], - }); - const artists = [artist1, artist2]; - - describe("when the number of albums returned from getAlbums is less the number of albums in the getArtists endpoint", () => { - describe("when the query comes back on 1 page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - [artist1, album3], - // album4 is missing from the albums end point for some reason - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album1, album2, album3, album5], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the first page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - // album3 & album5 is returned due to the prefetch - [artist1, album3], - // album4 is missing from the albums end point for some reason - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should filter out the pre-fetched albums", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 2, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album1, album2], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the last page only", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - // album1 is on the first page - // album2 is on the first page - [artist1, album3], - // album4 is missing from the albums end point for some reason - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the last page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 2, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album3, album5], - total: 4, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - }); - - describe("when the number of albums returned from getAlbums is more than the number of albums in the getArtists endpoint", () => { - describe("when the query comes back on 1 page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve( - ok( - asArtistsJson([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - [artist1, album3], - [artist1, album4], - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album1, album2, album3, album4, album5], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the first page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve( - ok( - asArtistsJson([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album1], - [artist1, album2], - [artist1, album3], - [artist1, album4], - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should filter out the pre-fetched albums", async () => { - const q: AlbumQuery = { - _index: 0, - _count: 2, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album1, album2], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - - describe("when the query is for the last page only", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve( - ok( - asArtistsJson([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve( - ok( - getAlbumListJson([ - [artist1, album3], - [artist1, album4], - [artist2, album5], - ]) - ) - ) - ); - }); - - it("should return the last page of albums, updating the total to be accurate", async () => { - const q: AlbumQuery = { - _index: 2, - _count: 100, - type: "alphabeticalByArtist", - }; - const result = await subsonic.albums(q); - - expect(result).toEqual({ - results: [album3, album4, album5], - total: 5, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getAlbumList2' }).href(), - { - params: asURLSearchParams({ - ...authParamsPlusJson, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - } - ); - }); - }); - }); - }); - }); - - describe("getting an album", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - describe("when it exists", () => { - const genre = asGenre("Pop"); - - const album = anAlbum({ genre }); - - const artist = anArtist({ albums: [album] }); - - const tracks = [ - aTrack({ artist, album, genre }), - aTrack({ artist, album, genre }), - aTrack({ artist, album, genre }), - aTrack({ artist, album, genre }), - ]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should return the album", async () => { - const result = await subsonic.album(album.id); - - expect(result).toEqual(album); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - }); - - describe("getting tracks", () => { - describe("for an album", () => { - describe("when there are no custom players", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - describe("when the album has multiple tracks, some of which are rated", () => { - const hipHop = asGenre("Hip-Hop"); - const tripHop = asGenre("Trip-Hop"); - - const album = anAlbum({ id: "album1", name: "Burnin", genre: hipHop }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const track1 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: hipHop, - rating: { - love: true, - stars: 3, - }, - }); - const track2 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: hipHop, - rating: { - love: false, - stars: 0, - }, - }); - const track3 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: tripHop, - rating: { - love: true, - stars: 5, - }, - }); - const track4 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: tripHop, - rating: { - love: false, - stars: 1, - }, - }); - - const tracks = [track1, track2, track3, track4]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should return the album", async () => { - const result = await subsonic.tracks(album.id); - - expect(result).toEqual([track1, track2, track3, track4]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - - describe("when the album has only 1 track", () => { - const flipFlop = asGenre("Flip-Flop"); - - const album = anAlbum({ - id: "album1", - name: "Burnin", - genre: flipFlop, - }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: flipFlop, - }); - - const tracks = [track]; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should return the album", async () => { - const result = await subsonic.tracks(album.id); - - expect(result).toEqual([track]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - - describe("when the album has only no tracks", () => { - const album = anAlbum({ id: "album1", name: "Burnin" }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const tracks: Track[] = []; - - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, tracks))) - ); - }); - - it("should empty array", async () => { - const result = await subsonic.tracks(album.id); - - expect(result).toEqual([]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - }); - - describe("when a custom player is configured for the mime type", () => { - const hipHop = asGenre("Hip-Hop"); - const tripHop = asGenre("Trip-Hop"); - - const album = anAlbum({ id: "album1", name: "Burnin", genre: hipHop }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - const alac = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - encoding: { - player: "bonob", - mimeType: "audio/alac" - }, - genre: hipHop, - rating: { - love: true, - stars: 3, - }, - }); - const m4a = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - encoding: { - player: "bonob", - mimeType: "audio/m4a" - }, - genre: hipHop, - rating: { - love: false, - stars: 0, - }, - }); - const mp3 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - encoding: { - player: "bonob", - mimeType: "audio/mp3" - }, - genre: tripHop, - rating: { - love: true, - stars: 5, - }, - }); - - beforeEach(() => { - customPlayers.encodingFor - .mockReturnValueOnce(O.of({ player: "bonob+audio/alac", mimeType: "audio/flac" })) - .mockReturnValueOnce(O.of({ player: "bonob+audio/m4a", mimeType: "audio/opus" })) - .mockReturnValueOnce(O.none) - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [alac, m4a, mp3]))) - ); - }); - - it("should return the album with custom players applied", async () => { - const result = await subsonic.tracks(album.id); - - expect(result).toEqual([ - { - ...alac, - encoding: { - player: "bonob+audio/alac", - mimeType: "audio/flac" - } - }, - { - ...m4a, - encoding: { - player: "bonob+audio/m4a", - mimeType: "audio/opus" - } - }, - { - ...mp3, - encoding: { - player: "bonob", - mimeType: "audio/mp3" - } - }, - ]); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - - expect(customPlayers.encodingFor).toHaveBeenCalledTimes(3); - expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(1, { mimeType: "audio/alac" }) - expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(2, { mimeType: "audio/m4a" }) - expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(3, { mimeType: "audio/mp3" }) - }); - }); - }); - - describe("a single track", () => { - const pop = asGenre("Pop"); - - const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - - describe("when there are no custom players", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - describe("that is starred", () => { - it("should return the track", async () => { - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - rating: { - love: true, - stars: 4, - }, - }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await subsonic.track(track.id); - - expect(result).toEqual({ - ...track, - rating: { love: true, stars: 4 }, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSong' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: track.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - - describe("that is not starred", () => { - it("should return the track", async () => { - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - rating: { - love: false, - stars: 0, - }, - }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await subsonic.track(track.id); - - expect(result).toEqual({ - ...track, - rating: { love: false, stars: 0 }, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSong' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: track.id, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getAlbum' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: album.id, - }), - headers, - }); - }); - }); - }); - }); - }); - - describe("streaming a track", () => { - - const trackId = uuid(); - const genre = aGenre("foo"); - - const album = anAlbum({ genre }); - const artist = anArtist({ - albums: [album] - }); - const track = aTrack({ - id: trackId, - album: albumToAlbumSummary(album), - artist: artistToArtistSummary(artist), - genre, - }); - - describe("when there are no custom players registered", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - describe("content-range, accept-ranges or content-length", () => { - describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => { - it("should return undefined values", async () => { - const stream = { - pipe: jest.fn(), - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await subsonic.stream({ trackId, range: undefined }); - - expect(result.headers).toEqual({ - "content-type": "audio/mpeg", - "content-length": undefined, - "content-range": undefined, - "accept-ranges": undefined, - }); - }); - }); - - describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => { - it("should return undefined values", async () => { - const stream = { - pipe: jest.fn(), - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - "content-length": undefined, - "content-range": undefined, - "accept-ranges": undefined, - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await subsonic.stream({ trackId, range: undefined }); - - expect(result.headers).toEqual({ - "content-type": "audio/mpeg", - "content-length": undefined, - "content-range": undefined, - "accept-ranges": undefined, - }); - }); - }); - - describe("with no range specified", () => { - describe("navidrome returns a 200", () => { - it("should return the content", async () => { - const stream = { - pipe: jest.fn(), - }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - "content-length": "1667", - "content-range": "-200", - "accept-ranges": "bytes", - "some-other-header": "some-value", - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await subsonic.stream({ trackId, range: undefined }); - - expect(result.headers).toEqual({ - "content-type": "audio/mpeg", - "content-length": "1667", - "content-range": "-200", - "accept-ranges": "bytes", - }); - expect(result.stream).toEqual(stream); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { - params: asURLSearchParams({ - ...authParams, - id: trackId, - }), - headers: { - "User-Agent": "bonob", - }, - responseType: "stream", - }); - }); - }); - - describe("navidrome returns something other than a 200", () => { - it("should fail", async () => { - const trackId = "track123"; - - const streamResponse = { - status: 400, - headers: { - 'content-type': 'text/html', - 'content-length': '33' - } - }; - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - - return expect( - subsonic.stream({ trackId, range: undefined }) - ).rejects.toEqual(`Subsonic failed with a 400 status`); - }); - }); - - describe("io exception occurs", () => { - it("should fail", async () => { - const trackId = "track123"; - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.reject("IO error occured")); - - return expect( - subsonic.stream({ trackId, range: undefined }) - ).rejects.toEqual(`Subsonic failed with: IO error occured`); - }); - }); - }); - - describe("with range specified", () => { - it("should send the range to navidrome", async () => { - const stream = { - pipe: jest.fn(), - }; - - const range = "1000-2000"; - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/flac", - "content-length": "66", - "content-range": "100-200", - "accept-ranges": "none", - "some-other-header": "some-value", - }, - data: stream, - }; - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await subsonic.stream({ trackId, range }); - - expect(result.headers).toEqual({ - "content-type": "audio/flac", - "content-length": "66", - "content-range": "100-200", - "accept-ranges": "none", - }); - expect(result.stream).toEqual(stream); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { - params: asURLSearchParams({ - ...authParams, - id: trackId, - }), - headers: { - "User-Agent": "bonob", - Range: range, - }, - responseType: "stream", - }); - }); - }); - }); - }); - - describe("when there are custom players registered", () => { - const customEncoding = { - player: `bonob-${uuid()}`, - mimeType: "transocodedMimeType" - }; - const trackWithCustomPlayer: Track = { - ...track, - encoding: customEncoding - }; - - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.of(customEncoding)); - }); - - describe("when no range specified", () => { - it("should user the custom client specified by the stream client", async () => { - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - }, - data: Buffer.from("the track", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [trackWithCustomPlayer]))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - await subsonic.stream({ trackId, range: undefined }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { - params: asURLSearchParams({ - ...authParams, - id: trackId, - c: trackWithCustomPlayer.encoding.player, - }), - headers: { - "User-Agent": "bonob", - }, - responseType: "stream", - }); - }); - }); - - describe("when range specified", () => { - it("should user the custom client specified by the stream client", async () => { - const range = "1000-2000"; - - const streamResponse = { - status: 200, - headers: { - "content-type": "audio/mpeg", - }, - data: Buffer.from("the track", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, [trackWithCustomPlayer]))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - await subsonic.stream({ trackId, range }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/stream' }).href(), { - params: asURLSearchParams({ - ...authParams, - id: trackId, - c: trackWithCustomPlayer.encoding.player, - }), - headers: { - "User-Agent": "bonob", - Range: range, - }, - responseType: "stream", - }); - }); - }); - }); - }); - - describe("fetching cover art", () => { - describe("fetching album art", () => { - describe("when no size is specified", () => { - it("should fetch the image", async () => { - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - const coverArtId = "someCoverArt"; - const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await subsonic.coverArt(coverArtURN); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getCoverArt' }).href(), { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - }), - headers, - responseType: "arraybuffer", - }); - }); - }); - - describe("when size is specified", () => { - it("should fetch the image", async () => { - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - const coverArtId = uuid(); - const coverArtURN = { system: "subsonic", resource: `art:${coverArtId}` } - const size = 1879; - - mockGET - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await subsonic.coverArt(coverArtURN, size); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getCoverArt' }).href(), { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - size, - }), - headers, - responseType: "arraybuffer", - }); - }); - }); - - describe("when an unexpected error occurs", () => { - it("should return undefined", async () => { - const size = 1879; - - mockGET - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await subsonic.coverArt({ system: "external", resource: "http://localhost:404" }, size); - - expect(result).toBeUndefined(); - }); - }); - }); - - describe("fetching cover art", () => { - describe("when urn.resource is not subsonic", () => { - it("should be undefined", async () => { - const covertArtURN = { system: "notSubsonic", resource: `art:${uuid()}` }; - - const result = await subsonic.coverArt(covertArtURN, 190); - - expect(result).toBeUndefined(); - }); - }); - - describe("when no size is specified", () => { - it("should fetch the image", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await subsonic.coverArt(covertArtURN); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - }), - headers, - responseType: "arraybuffer", - } - ); - }); - - describe("and an error occurs fetching the uri", () => { - it("should return undefined", async () => { - const coverArtId = uuid() - const covertArtURN = { system:"subsonic", resource: `art:${coverArtId}` }; - - mockGET - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await subsonic.coverArt(covertArtURN); - - expect(result).toBeUndefined(); - }); - }); - }); - - describe("when size is specified", () => { - const size = 189; - - it("should fetch the image", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; - - mockGET - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await subsonic.coverArt(covertArtURN, size); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith( - url.append({ pathname: '/rest/getCoverArt' }).href(), - { - params: asURLSearchParams({ - ...authParams, - id: coverArtId, - size - }), - headers, - responseType: "arraybuffer", - } - ); - }); - - describe("and an error occurs fetching the uri", () => { - it("should return undefined", async () => { - const coverArtId = uuid() - const covertArtURN = { system: "subsonic", resource: `art:${coverArtId}` }; - - mockGET - .mockImplementationOnce(() => Promise.reject("BOOOM")); - - const result = await subsonic.coverArt(covertArtURN, size); - - expect(result).toBeUndefined(); - }); - }); - }); - }); - }); - - describe("rate", () => { - const trackId = uuid(); - - const artist = anArtist(); - const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); - - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - describe("rating a track", () => { - describe("loving a track that isnt already loved", () => { - it("should mark the track as loved", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: false, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await subsonic.rate(trackId, { love: true, stars: 0 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/star' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - }), - headers, - }); - }); - }); - - describe("unloving a track that is loved", () => { - it("should mark the track as loved", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await subsonic.rate(trackId, { love: false, stars: 0 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/unstar' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - }), - headers, - }); - }); - }); - - describe("loving a track that is already loved", () => { - it("shouldn't do anything", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await subsonic.rate(trackId, { love: true, stars: 0 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledTimes(2); - }); - }); - - describe("rating a track with a different rating", () => { - it("should add the new rating", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: false, stars: 0 }, - }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await subsonic.rate(trackId, { love: false, stars: 3 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/setRating' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - rating: 3, - }), - headers, - }); - }); - }); - - describe("rating a track with the same rating it already has", () => { - it("shouldn't do anything", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 3 }, - }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await subsonic.rate(trackId, { love: true, stars: 3 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledTimes(2); - }); - }); - - describe("loving and rating a track", () => { - it("should return true", async () => { - const track = aTrack({ - id: trackId, - artist, - album: albumToAlbumSummary(album), - rating: { love: true, stars: 3 }, - }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await subsonic.rate(trackId, { love: false, stars: 5 }); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/unstar' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - }), - headers, - }); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/setRating' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: trackId, - rating: 5, - }), - headers, - }); - }); - }); - - describe("invalid star values", () => { - describe("stars of -1", () => { - it("should return false", async () => { - const result = await subsonic.rate(trackId, { love: true, stars: -1 }); - expect(result).toEqual(false); - }); - }); - - describe("stars of 6", () => { - it("should return false", async () => { - const result = await subsonic.rate(trackId, { love: true, stars: -1 }); - expect(result).toEqual(false); - }); - }); - }); - - describe("when fails", () => { - it("should return false", async () => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(FAILURE))) - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await subsonic.rate(trackId, { love: true, stars: 0 }); - - expect(result).toEqual(false); - }); - }); - }); - }); - - describe("scrobble", () => { - describe("when succeeds", () => { - it("should return true", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await subsonic.scrobble(id); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/scrobble' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: true, - }), - headers, - }); - }); - }); - - describe("when fails", () => { - it("should return false", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => - Promise.resolve({ - status: 500, - data: {}, - }) - ); - - const result = await subsonic.scrobble(id); - - expect(result).toEqual(false); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/scrobble' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: true, - }), - headers, - }); - }); - }); - }); - - describe("nowPlaying", () => { - describe("when succeeds", () => { - it("should return true", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await subsonic.nowPlaying(id); - - expect(result).toEqual(true); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/scrobble' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: false, - }), - headers, - }); - }); - }); - - describe("when fails", () => { - it("should return false", async () => { - const id = uuid(); - - mockGET - .mockImplementationOnce(() => - Promise.resolve({ - status: 500, - data: {}, - }) - ); - - const result = await subsonic.nowPlaying(id); - - expect(result).toEqual(false); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/scrobble' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - submission: false, - }), - headers, - }); - }); - }); - }); - - describe("searchArtists", () => { - describe("when there is 1 search results", () => { - it("should return true", async () => { - const artist1 = anArtist({ name: "foo woo" }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ artists: [artist1] }))) - ); - - const result = await subsonic.searchArtists("foo"); - - expect(result).toEqual([artistToArtistSummary(artist1)]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 20, - albumCount: 0, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are many search results", () => { - it("should return true", async () => { - const artist1 = anArtist({ name: "foo woo" }); - const artist2 = anArtist({ name: "foo choo" }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve( - ok(getSearchResult3Json({ artists: [artist1, artist2] })) - ) - ); - - const result = await subsonic.searchArtists("foo"); - - expect(result).toEqual([ - artistToArtistSummary(artist1), - artistToArtistSummary(artist2), - ]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 20, - albumCount: 0, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) - ); - - const result = await subsonic.searchArtists("foo"); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 20, - albumCount: 0, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - }); - - describe("searchAlbums", () => { - describe("when there is 1 search results", () => { - it("should return true", async () => { - const album = anAlbum({ - name: "foo woo", - genre: { id: b64Encode("pop"), name: "pop" }, - }); - const artist = anArtist({ name: "#1", albums: [album] }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve( - ok(getSearchResult3Json({ albums: [{ artist, album }] })) - ) - ); - - const result = await subsonic.searchAlbums("foo"); - - expect(result).toEqual([albumToAlbumSummary(album)]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 20, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are many search results", () => { - it("should return true", async () => { - const album1 = anAlbum({ - name: "album1", - genre: { id: b64Encode("pop"), name: "pop" }, - }); - const artist1 = anArtist({ name: "artist1", albums: [album1] }); - - const album2 = anAlbum({ - name: "album2", - genre: { id: b64Encode("pop"), name: "pop" }, - }); - const artist2 = anArtist({ name: "artist2", albums: [album2] }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve( - ok( - getSearchResult3Json({ - albums: [ - { artist: artist1, album: album1 }, - { artist: artist2, album: album2 }, - ], - }) - ) - ) - ); - - const result = await subsonic.searchAlbums("moo"); - - expect(result).toEqual([ - albumToAlbumSummary(album1), - albumToAlbumSummary(album2), - ]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 20, - songCount: 0, - query: "moo", - }), - headers, - }); - }); - }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ albums: [] }))) - ); - - const result = await subsonic.searchAlbums("foo"); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 20, - songCount: 0, - query: "foo", - }), - headers, - }); - }); - }); - }); - - describe("searchSongs", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - describe("when there is 1 search results", () => { - it("should return true", async () => { - const pop = asGenre("Pop"); - - const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album], - }); - const track = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ tracks: [track] }))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track)))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album, []))) - ); - - const result = await subsonic.searchTracks("foo"); - - expect(result).toEqual([track]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 0, - songCount: 20, - query: "foo", - }), - headers, - }); - }); - }); - - describe("when there are many search results", () => { - it("should return true", async () => { - const pop = asGenre("Pop"); - - const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist1 = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album1], - }); - const track1 = aTrack({ - id: "track1", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, - }); - - const album2 = anAlbum({ id: "album2", name: "Bobbin", genre: pop }); - const artist2 = anArtist({ - id: "artist2", - name: "Jane Marley", - albums: [album2], - }); - const track2 = aTrack({ - id: "track2", - artist: artistToArtistSummary(artist2), - album: albumToAlbumSummary(album2), - genre: pop, - }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve( - ok( - getSearchResult3Json({ - tracks: [track1, track2], - }) - ) - ) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track1))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getSongJson(track2))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist2, album2, []))) - ); - - const result = await subsonic.searchTracks("moo"); - - expect(result).toEqual([track1, track2]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 0, - songCount: 20, - query: "moo", - }), - headers, - }); - }); - }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ tracks: [] }))) - ); - - const result = await subsonic.searchTracks("foo"); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 0, - songCount: 20, - query: "foo", - }), - headers, - }); - }); - }); - }); - - describe("playlists", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - describe("getting playlists", () => { - describe("when there is 1 playlist results", () => { - it("should return it", async () => { - const playlist = aPlaylistSummary(); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson([playlist]))) + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + }); + + it("should return it", async () => { + const result = await subsonic.getArtist(credentials, artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + artistImageUrl: undefined, + albums: artist.albums, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } ); - - const result = await subsonic.playlists(); - - expect(result).toEqual([playlist]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, }); }); - }); - - describe("when there are many playlists", () => { - it("should return them", async () => { - const playlist1 = aPlaylistSummary(); - const playlist2 = aPlaylistSummary(); - const playlist3 = aPlaylistSummary(); - const playlists = [playlist1, playlist2, playlist3]; - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson(playlists))) + + describe("and has no albums", () => { + const artist: Artist = anArtist({ + albums: [], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + }); + + it("should return it", async () => { + const result = await subsonic.getArtist(credentials, artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + artistImageUrl: undefined, + albums: [] + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } ); - - const result = await subsonic.playlists(); - - expect(result).toEqual(playlists); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, }); }); - }); - describe("when there are no playlists", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListsJson([]))) + describe("and has an artistImageUrl", () => { + const artist: Artist = anArtist({ + albums: [] + }); + + const artistImageUrl = `http://localhost:1234/somewhere.jpg`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve( + ok(getArtistJson(artist, { artistImageUrl })) + ) + ) + }); + + it("should return the artist image url", async () => { + const result = await subsonic.getArtist(credentials, artist.id!); + + expect(result).toEqual({ + id: artist.id, + name: artist.name, + artistImageUrl, + albums: [], + }); + + // todo: these are everywhere?? + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + }), + headers, + } ); - - const result = await subsonic.playlists(); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, }); - }); + }); }); + + // todo: what happens when the artist doesnt exist? }); - describe("getting a single playlist", () => { - describe("when there is no playlist with the id", () => { - it("should raise error", async () => { - const id = "id404"; + describe("getArtistInfo", () => { + // todo: what happens when the artist doesnt exist? - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(error("70", "data not found"))) + describe("when the artist exists", () => { + describe("and has many similar artists", () => { + const artist = anArtist({ + similarArtists: [ + aSimilarArtist({ + id: "similar1.id", + name: "similar1", + inLibrary: true, + }), + aSimilarArtist({ id: "-1", name: "similar2", inLibrary: false }), + aSimilarArtist({ + id: "similar3.id", + name: "similar3", + inLibrary: true, + }), + aSimilarArtist({ id: "-1", name: "similar4", inLibrary: false }), + ], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ) + }); + + it("should return the similar artists", async () => { + const result = await subsonic.getArtistInfo(credentials, artist.id!); + + expect(result).toEqual({ + similarArtist: artist.similarArtists, + images: { + l: undefined, + m: undefined, + s: undefined + } + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + } ); - - return expect( - subsonic.playlist(id) - ).rejects.toEqual("Subsonic error:data not found"); + }); }); - }); - - describe("when there is a playlist with the id", () => { - describe("and it has tracks", () => { - it("should return the playlist with entries", async () => { - const id = uuid(); - const name = "Great Playlist"; - const artist1 = anArtist(); - const album1 = anAlbum({ - artistId: artist1.id, - artistName: artist1.name, - genre: POP, - }); - const track1 = aTrack({ - genre: POP, - number: 66, - coverArt: album1.coverArt, - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - }); - - const artist2 = anArtist(); - const album2 = anAlbum({ - artistId: artist2.id, - artistName: artist2.name, - genre: ROCK, - }); - const track2 = aTrack({ - genre: ROCK, - number: 77, - coverArt: album2.coverArt, - artist: artistToArtistSummary(artist2), - album: albumToAlbumSummary(album2), - }); - + + describe("and has one similar artist", () => { + const artist = anArtist({ + similarArtists: [ + aSimilarArtist({ + id: "similar1.id", + name: "similar1", + inLibrary: true, + }), + ], + }); + + beforeEach(() => { mockGET .mockImplementationOnce(() => - Promise.resolve( - ok( - getPlayListJson({ - id, - name, - entries: [track1, track2], - }) - ) - ) + Promise.resolve(ok(getArtistInfoJson(artist))) ); - - const result = await subsonic.playlist(id); - + }); + + it("should return the similar artists", async () => { + const result = await subsonic.getArtistInfo(credentials, artist.id!); + expect(result).toEqual({ - id, - name, - entries: [ - { ...track1, number: 1 }, - { ...track2, number: 2 }, - ], - }); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - }), - headers, + similarArtist: artist.similarArtists, + images: { + l: undefined, + m: undefined, + s: undefined + } }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + } + ); }); }); - - describe("and it has no tracks", () => { - it("should return the playlist with empty entries", async () => { - const playlist = aPlaylist({ - entries: [], + + describe("and has no similar artists", () => { + const artist = anArtist({ + similarArtists: [], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ); + }); + + it("should return the similar artists", async () => { + const result = await subsonic.getArtistInfo(credentials, artist.id!); + + expect(result).toEqual({ + similarArtist: artist.similarArtists, + images: { + l: undefined, + m: undefined, + s: undefined + } }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + } + ); + }); + }); + + describe("and has some images", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); + const smallImageUrl = "http://small"; + const mediumImageUrl = "http://medium"; + const largeImageUrl = "http://large" + + + beforeEach(() => { mockGET .mockImplementationOnce(() => - Promise.resolve(ok(getPlayListJson(playlist))) + Promise.resolve( + ok( + getArtistInfoJson(artist, { + smallImageUrl, + mediumImageUrl, + largeImageUrl, + }) + ) + ) ); - - const result = await subsonic.playlist(playlist.id); - - expect(result).toEqual(playlist); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: playlist.id, - }), - headers, + }); + + it("should fetch the images", async () => { + const result = await subsonic.getArtistInfo(credentials, artist.id!); + + expect(result).toEqual({ + similarArtist: [], + images: { + s: smallImageUrl, + m: mediumImageUrl, + l: largeImageUrl + } }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtistInfo2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: artist.id, + count: 50, + includeNotPresent: true, + }), + headers, + } + ); }); }); }); }); - describe("creating a playlist", () => { - it("should create a playlist with the given name", async () => { - const name = "ThePlaylist"; - const id = uuid(); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(createPlayListJson({ id, name }))) - ); - - const result = await subsonic.createPlaylist(name); - - expect(result).toEqual({ id, name }); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/createPlaylist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - f: "json", - name, - }), - headers, - }); + describe("getting genres", () => { + describe("when there are none", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson([]))) + ); }); - }); - describe("deleting a playlist", () => { - it("should delete the playlist by id", async () => { - const id = "id-to-delete"; - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); - - const result = await subsonic.deletePlaylist(id); + it("should return empty array", async () => { + const result = await subsonic.getGenres(credentials); - expect(result).toEqual(true); + expect(result).toEqual([]); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/deletePlaylist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - }), - headers, - }); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getGenres" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); }); }); - describe("editing playlists", () => { - describe("adding a track to a playlist", () => { - it("should add it", async () => { - const playlistId = uuid(); - const trackId = uuid(); + describe("when there is only 1 that has an albumCount > 0", () => { + const genres = [ + { name: "genre1", albumCount: 1 }, + { name: "genreWithNoAlbums", albumCount: 0 }, + ]; - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); + }); - const result = await subsonic.addToPlaylist(playlistId, trackId); + it("should return them alphabetically sorted", async () => { + const result = await subsonic.getGenres(credentials); - expect(result).toEqual(true); + expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/updatePlaylist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - playlistId, - songIdToAdd: trackId, - }), + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getGenres" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), headers, - }); - }); + } + ); }); + }); - describe("removing a track from a playlist", () => { - it("should remove it", async () => { - const playlistId = uuid(); - const indicies = [6, 100, 33]; + describe("when there are many that have an albumCount > 0", () => { + const genres = [ + { name: "g1", albumCount: 1 }, + { name: "g2", albumCount: 1 }, + { name: "g3", albumCount: 1 }, + { name: "g4", albumCount: 1 }, + { name: "someGenreWithNoAlbums", albumCount: 0 }, + ]; - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); + }); - const result = await subsonic.removeFromPlaylist(playlistId, indicies); + it("should return them alphabetically sorted", async () => { + const result = await subsonic.getGenres(credentials); - expect(result).toEqual(true); + expect(result).toEqual([ + { id: b64Encode("g1"), name: "g1" }, + { id: b64Encode("g2"), name: "g2" }, + { id: b64Encode("g3"), name: "g3" }, + { id: b64Encode("g4"), name: "g4" }, + ]); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/updatePlaylist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - playlistId, - songIndexToRemove: indicies, - }), + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getGenres" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), headers, - }); - }); + } + ); }); }); }); - describe("similarSongs", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - describe("when there is one similar songs", () => { - it("should return it", async () => { - const id = "idWithTracks"; - const pop = asGenre("Pop"); - - const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist1 = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album1], + describe("getting an album", () => { + describe("when there are no custom players", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("when the album has some tracks", () => { + const artistId = "artist6677" + const artistName = "Fizzy Wizzy" + + const albumSummary = anAlbumSummary({ artistId, artistName }) + const artistSumamry = anArtistSummary({ id: artistId, name: artistName }) + + // todo: fix these ratings + const tracks = [ + aTrack({ artist: artistSumamry, album: albumSummary, rating: { love: false, stars: 0 } }), + aTrack({ artist: artistSumamry, album: albumSummary, rating: { love: false, stars: 0 } }), + aTrack({ artist: artistSumamry, album: albumSummary, rating: { love: false, stars: 0 } }), + aTrack({ artist: artistSumamry, album: albumSummary, rating: { love: false, stars: 0 } }), + ]; + + const album = anAlbum({ + ...albumSummary, + tracks, + artistId, + artistName, + }); + + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ); }); - - const track1 = aTrack({ - id: "track1", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, + + it("should return the album", async () => { + const result = await subsonic.getAlbum(credentials, album.id); + + expect(result).toEqual(album); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + } + ); }); + }); - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([track1]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) + describe("when the album has no tracks", () => { + const artistId = "artist6677" + const artistName = "Fizzy Wizzy" + + const albumSummary = anAlbumSummary({ artistId, artistName }) + + const album = anAlbum({ + ...albumSummary, + tracks: [], + artistId, + artistName, + }); + + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ); + }); + + it("should return the album", async () => { + const result = await subsonic.getAlbum(credentials, album.id); + + expect(result).toEqual(album); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + } ); - - const result = await subsonic.similarSongs(id); - - expect(result).toEqual([track1]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSimilarSongs2' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, }); }); + }); - describe("when there are similar songs", () => { - it("should return them", async () => { - const id = "idWithTracks"; - const pop = asGenre("Pop"); + describe("when a custom player is configured for the mime type", () => { + const hipHop = asGenre("Hip-Hop"); + const tripHop = asGenre("Trip-Hop"); - const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); - const artist1 = anArtist({ - id: "artist1", - name: "Bob Marley", - albums: [album1], - }); + const albumSummary = anAlbumSummary({ id: "album1", name: "Burnin", genre: hipHop }); - const album2 = anAlbum({ id: "album2", name: "Walking", genre: pop }); - const artist2 = anArtist({ - id: "artist2", - name: "Bob Jane", - albums: [album2], + const artistSummary = anArtistSummary({ + id: "artist1", + name: "Bob Marley" }); - const track1 = aTrack({ - id: "track1", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, - }); - const track2 = aTrack({ - id: "track2", - artist: artistToArtistSummary(artist2), - album: albumToAlbumSummary(album2), - genre: pop, + const alac = aTrack({ + artist: artistSummary, + album: albumSummary, + encoding: { + player: "bonob", + mimeType: "audio/alac", + }, + genre: hipHop, + rating: { + love: true, + stars: 3, + }, }); - const track3 = aTrack({ - id: "track3", - artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1), - genre: pop, + const m4a = aTrack({ + artist: artistSummary, + album: albumSummary, + encoding: { + player: "bonob", + mimeType: "audio/m4a", + }, + genre: hipHop, + rating: { + love: false, + stars: 0, + }, }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([track1, track2, track3]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist2, album2, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ); - - const result = await subsonic.similarSongs(id); - - expect(result).toEqual([track1, track2, track3]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSimilarSongs2' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, + const mp3 = aTrack({ + artist: artistSummary, + album: albumSummary, + encoding: { + player: "bonob", + mimeType: "audio/mp3", + }, + genre: tripHop, + rating: { + love: true, + stars: 5, + }, }); - }); - }); - describe("when there are no similar songs", () => { - it("should return []", async () => { - const id = "idWithNoTracks"; + const album = anAlbum({ + ...albumSummary, + tracks: [alac, m4a, mp3] + }) + + beforeEach(() => { + customPlayers.encodingFor + .mockReturnValueOnce( + O.of({ player: "bonob+audio/alac", mimeType: "audio/flac" }) + ) + .mockReturnValueOnce( + O.of({ player: "bonob+audio/m4a", mimeType: "audio/opus" }) + ) + .mockReturnValueOnce(O.none); - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([]))) + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) ); - - const result = await subsonic.similarSongs(id); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSimilarSongs2' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, }); - }); - }); - describe("when the id doesnt exist", () => { - it("should fail", async () => { - const id = "idThatHasAnError"; + it("should return the album with custom players applied", async () => { + const result = await subsonic.getAlbum(credentials, album.id); + + expect(result).toEqual({ + ...album, + tracks: [ + { + ...alac, + encoding: { + player: "bonob+audio/alac", + mimeType: "audio/flac", + }, + // todo: this doesnt seem right? why dont the ratings come back? + rating: { + love: false, + stars: 0 + } + }, + { + ...m4a, + encoding: { + player: "bonob+audio/m4a", + mimeType: "audio/opus", + }, + rating: { + love: false, + stars: 0 + } + }, + { + ...mp3, + encoding: { + player: "bonob", + mimeType: "audio/mp3", + }, + rating: { + love: false, + stars: 0 + } + }, + ] + }); - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(error("70", "data not found"))) + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + } ); - return expect( - subsonic.similarSongs(id) - ).rejects.toEqual("Subsonic error:data not found"); - }); + expect(customPlayers.encodingFor).toHaveBeenCalledTimes(3); + expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(1, { + mimeType: "audio/alac", + }); + expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(2, { + mimeType: "audio/m4a", + }); + expect(customPlayers.encodingFor).toHaveBeenNthCalledWith(3, { + mimeType: "audio/mp3", + }); + }); }); - }); + }); - describe("topSongs", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); + describe("stars and unstars", () => { + const id = uuid(); - describe("when there is one top song", () => { - it("should return it", async () => { - const artistId = "bobMarleyId"; - const artistName = "Bob Marley"; - const pop = asGenre("Pop"); - - const album1 = anAlbum({ name: "Burnin", genre: pop }); - const artist = anArtist({ - id: artistId, - name: artistName, - albums: [album1], + describe("staring a track", () => { + describe("when ok", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "ok" }))) + ); }); - const track1 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album1), - genre: pop, + it("should return true", async () => { + const result = await subsonic.star(credentials, { id }); + + expect(result).toEqual(true); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/star" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id + }), + headers, + } + ); }); + }); - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getTopSongsJson([track1]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) + describe("when not ok", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "not-ok" }))) ); + }); - const result = await subsonic.topSongs(artistId); - - expect(result).toEqual([track1]); + it("should return false", async () => { + const result = await subsonic.star(credentials, { id }); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getTopSongs' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, + expect(result).toEqual(false); }); }); }); + }); - describe("when there are many top songs", () => { - it("should return them", async () => { - const artistId = "bobMarleyId"; - const artistName = "Bob Marley"; - - const album1 = anAlbum({ name: "Burnin", genre: POP }); - const album2 = anAlbum({ name: "Churning", genre: POP }); - - const artist = anArtist({ - id: artistId, - name: artistName, - albums: [album1, album2], - }); - - const track1 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album1), - genre: POP, - }); - - const track2 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album2), - genre: POP, - }); + describe("setting ratings", () => { + const id = uuid(); - const track3 = aTrack({ - artist: artistToArtistSummary(artist), - album: albumToAlbumSummary(album1), - genre: POP, + describe("when the rating is valid", () => { + describe("when response is ok", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "ok" }))) + ); }); - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getTopSongsJson([track1, track2, track3]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album2, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) + it("should return true", async () => { + const result = await subsonic.setRating(credentials, id, 4); + + expect(result).toEqual(true); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/setRating" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + rating: 4 + }), + headers, + } ); - - const result = await subsonic.topSongs(artistId); - - expect(result).toEqual([track1, track2, track3]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getTopSongs' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, }); }); - }); - - describe("when there are no similar songs", () => { - it("should return []", async () => { - const artistId = "bobMarleyId"; - const artistName = "Bob Marley"; - const pop = asGenre("Pop"); - - const album1 = anAlbum({ name: "Burnin", genre: pop }); - const artist = anArtist({ - id: artistId, - name: artistName, - albums: [album1], - }); - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getTopSongsJson([]))) + describe("when response is not ok", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "not-ok" }))) ); + }); - const result = await subsonic.topSongs(artistId); - - expect(result).toEqual([]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getTopSongs' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, + it("should return false", async () => { + const result = await subsonic.setRating(credentials, id, 2); + + expect(result).toEqual(false); }); }); }); - }); + }); - describe("radioStations", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); + describe("scrobble", () => { + const id = uuid(); - describe("when there some radio stations", () => { - const station1 = aRadioStation(); - const station2 = aRadioStation(); - const station3 = aRadioStation(); + describe("with submission", () => { + const submission = true; beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getRadioStationsJson([ - station1, - station2, - station3, - ]))) + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "ok" }))) ); }); - describe("asking for all of them", () => { - it("should return them all", async () => { - const result = await subsonic.radioStations(); - - expect(result).toEqual([station1, station2, station3]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), { + it("should scrobble and return true", async () => { + const result = await subsonic.scrobble(credentials, id, submission); + + expect(result).toEqual(true); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/scrobble" }).href(), + { params: asURLSearchParams({ - ...authParams, - f: "json" + ...authParamsPlusJson, + id, + submission }), headers, - }); - }); + } + ); }); + }); - describe("asking for one of them", () => { - it("should return it", async () => { - const result = await subsonic.radioStation(station2.id); - - expect(result).toEqual(station2); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), { + describe("without submission", () => { + const submission = false; + + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "ok" }))) + ); + }); + + it("should scrobble and return true", async () => { + const result = await subsonic.scrobble(credentials, id, submission); + + expect(result).toEqual(true); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/scrobble" }).href(), + { params: asURLSearchParams({ - ...authParams, - f: "json" + ...authParamsPlusJson, + id, + submission }), headers, - }); - }); + } + ); }); }); - describe("when there are no radio stations", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getRadioStationsJson([]))) - ); - - const result = await subsonic.radioStations(); + describe("when fails", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "not-ok" }))) + ); + }); - expect(result).toEqual([]); + it("should return false", async () => { + const result = await subsonic.scrobble(credentials, id, false); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json" - }), - headers, - }); + expect(result).toEqual(false); }); }); - }); - + }); }); diff --git a/tests/subsonic_music_library.test.ts b/tests/subsonic_music_library.test.ts new file mode 100644 index 0000000..ee85e87 --- /dev/null +++ b/tests/subsonic_music_library.test.ts @@ -0,0 +1,3626 @@ +import { v4 as uuid } from "uuid"; +import { pipe } from "fp-ts/lib/function"; +import { option as O, taskEither as TE, either as E } from "fp-ts"; + +import axios from "axios"; +jest.mock("axios"); + +import randomstring from "randomstring"; +jest.mock("randomstring"); + +import { + Subsonic, + t, + asGenre, + asURLSearchParams, + asToken, + CustomPlayers, + images, + artistImageURN +} from "../src/subsonic"; + +import { + SubsonicMusicService, + SubsonicMusicLibrary, +} from "../src/subsonic_music_library"; + +import { + Album, + Artist, + albumToAlbumSummary, + asArtistAlbumPairs, + Track, + artistToArtistSummary, + AlbumQuery, + PlaylistSummary, + Playlist, + SimilarArtist, + AuthFailure, + RadioStation, + AlbumSummary, + trackToTrackSummary, +} from "../src/music_library"; +import { + aGenre, + anAlbum, + anArtist, + aPlaylist, + aPlaylistSummary, + aTrack, + POP, + ROCK, + aRadioStation, + anAlbumSummary, + anArtistSummary, +} from "./builders"; +import { b64Encode } from "../src/b64"; +import { BUrn } from "../src/burn"; +import { URLBuilder } from "../src/url_builder"; + +import { getAlbumJson } from "./subsonic.test"; + +const EMPTY = { + "subsonic-response": { + status: "ok", + version: "1.16.1", + type: "subsonic", + serverVersion: "0.45.1 (c55e6590)", + }, +}; + +const FAILURE = { + "subsonic-response": { + status: "failed", + version: "1.16.1", + type: "subsonic", + serverVersion: "0.45.1 (c55e6590)", + error: { code: 10, message: 'Missing required parameter "v"' }, + }, +}; + +const ok = (data: string | object) => ({ + status: 200, + data, +}); + + +const error = (code: string, message: string) => ({ + "subsonic-response": { + status: "failed", + version: "1.16.1", + type: "subsonic", + serverVersion: "0.45.1 (c55e6590)", + error: { code, message }, + }, +}); + + +const maybeIdFromCoverArtUrn = (coverArt: BUrn | undefined) => + pipe( + coverArt, + O.fromNullable, + O.map((it) => it.resource.split(":")[1]), + O.getOrElseW(() => "") + ); + + + +const getSongJson = (track: Track) => subsonicOK({ song: asSongJson(track) }); + +export const getArtistJson = ( + artist: Artist, + extras: ArtistExtras = { artistImageUrl: undefined } +) => + subsonicOK({ + artist: { + id: artist.id, + name: artist.name, + coverArt: "art-123", + albumCount: artist.albums.length, + artistImageUrl: extras.artistImageUrl, + starred: "sometime", + album: artist.albums.map((album) => ({ + id: album.id, + parent: artist.id, + album: album.name, + title: album.name, + name: album.name, + isDir: "true", + coverArt: maybeIdFromCoverArtUrn(album.coverArt), + songCount: 19, + created: "2021-01-07T08:19:55.834207205Z", + duration: 123, + playCount: 4, + artistId: artist.id, + artist: artist.name, + year: album.year, + genre: album.genre?.name, + userRating: 5, + averageRating: 3, + starred: "2021-01-07T08:19:55.834207205Z", + })) + }, + }); + +const getRadioStationsJson = (radioStations: RadioStation[]) => + subsonicOK({ + internetRadioStations: { + internetRadioStation: radioStations.map((it) => ({ + id: it.id, + name: it.name, + streamUrl: it.url, + homePageUrl: it.homePage, + })), + }, + }); + +const subsonicOK = (body: any = {}) => ({ + "subsonic-response": { + status: "ok", + version: "1.16.1", + type: "subsonic", + serverVersion: "0.45.1 (c55e6590)", + ...body, + }, +}); + +const getSimilarSongsJson = (tracks: Track[]) => + subsonicOK({ similarSongs2: { song: tracks.map(asSongJson) } }); + +const getTopSongsJson = (tracks: Track[]) => + subsonicOK({ topSongs: { song: tracks.map(asSongJson) } }); + +const asPlaylistJson = (playlist: PlaylistSummary) => ({ + id: playlist.id, + name: playlist.name, + songCount: 1, + duration: 190, + public: true, + owner: "bob", + created: "2021-05-06T02:07:24.308007023Z", + changed: "2021-05-06T02:08:06Z", +}); + +export type ArtistWithAlbum = { + artist: Artist; + album: Album; +}; + +type ArtistExtras = { artistImageUrl: string | undefined }; + +const asSongJson = (track: Track) => ({ + id: track.id, + parent: track.album.id, + title: track.name, + album: track.album.name, + artist: track.artist.name, + track: track.number, + genre: track.genre?.name, + isDir: "false", + coverArt: maybeIdFromCoverArtUrn(track.coverArt), + created: "2004-11-08T23:36:11", + duration: track.duration, + bitRate: 128, + size: "5624132", + suffix: "mp3", + contentType: track.encoding.mimeType, + transcodedContentType: undefined, + isVideo: "false", + path: "ACDC/High voltage/ACDC - The Jack.mp3", + albumId: track.album.id, + artistId: track.artist.id, + type: "music", + starred: track.rating.love ? "sometime" : undefined, + userRating: track.rating.stars, + year: "", +}); + +export const getArtistInfoJson = ( + artist: Artist, + images: images = { + smallImageUrl: undefined, + mediumImageUrl: undefined, + largeImageUrl: undefined, + } +) => + subsonicOK({ + artistInfo2: { + ...images, + similarArtist: artist.similarArtists.map(asSimilarArtistJson), + }, + }); + +const asSimilarArtistJson = (similarArtist: SimilarArtist) => { + if (similarArtist.inLibrary) + return { + id: similarArtist.id, + name: similarArtist.name, + albumCount: 3, + }; + else + return { + id: -1, + name: similarArtist.name, + albumCount: 3, + }; +}; + +const getPlayListsJson = (playlists: PlaylistSummary[]) => + subsonicOK({ + playlists: { + playlist: playlists.map(asPlaylistJson), + }, + }); + +const createPlayListJson = (playlist: PlaylistSummary) => + subsonicOK({ + playlist: asPlaylistJson(playlist), + }); + + +const getAlbumListJson = (albums: [Artist, AlbumSummary][]) => + subsonicOK({ + albumList2: { + album: albums.map(([artist, album]) => ({ + id: album.id, + name: album.name, + coverArt: maybeIdFromCoverArtUrn(album.coverArt), + songCount: "19", + created: "2021-01-07T08:19:55.834207205Z", + duration: "123", + artist: artist.name, + artistId: artist.id, + year: album.year, + genre: album.genre?.name + })), + }, + }); + +const getPlayListJson = (playlist: Playlist) => + subsonicOK({ + playlist: { + id: playlist.id, + name: playlist.name, + songCount: playlist.entries.length, + duration: 627, + public: true, + owner: "bob", + created: "2021-05-06T02:07:30.460465988Z", + changed: "2021-05-06T02:40:04Z", + entry: playlist.entries.map((it) => ({ + id: it.id, + parent: "...", + isDir: false, + title: it.name, + album: it.album.name, + artist: it.artist.name, + track: it.number, + year: it.album.year, + genre: it.album.genre?.name, + coverArt: maybeIdFromCoverArtUrn(it.coverArt), + size: 123, + contentType: it.encoding.mimeType, + suffix: "mp3", + duration: it.duration, + bitRate: 128, + path: "...", + discNumber: 1, + created: "2019-09-04T04:07:00.138169924Z", + albumId: it.album.id, + artistId: it.artist.id, + type: "music", + isVideo: false, + starred: it.rating.love ? "sometime" : undefined, + userRating: it.rating.stars, + })), + }, + }); + +const getSearchResult3Json = ({ + artists, + albums, + tracks, +}: Partial<{ + artists: Artist[]; + albums: ArtistWithAlbum[]; + tracks: Track[]; +}>) => + subsonicOK({ + searchResult3: { + artist: (artists || []).map((it) => ({ + id: it.id, + name: it.name, + // coverArt?? + albumCount: it.albums.length, + userRating: -1, + //artistImageUrl? + })), + album: (albums || []).map(({ artist, album }) => ({ + id: album.id, + name: album.name, + artist: artist.name, + year: album.year, + coverArt: maybeIdFromCoverArtUrn(album.coverArt), + //starred + //duration + //playCount + //played + //created + artistId: artist.id, + //userRating + songCount: album.tracks.length + })), + song: (tracks || []).map((track) => ({ + id: track.id, + parent: track.album.id, + isDir: "false", + title: track.name, + album: track.album.name, + artist: track.artist.name, + track: track.number, + year: "", + coverArt: maybeIdFromCoverArtUrn(track.coverArt), + size: "5624132", + contentType: track.encoding.mimeType, + suffix: "mp3", + starred: track.rating.love ? "sometime" : undefined, + duration: track.duration, + bitRate: 128, + //bitDepth + //samplingRate + //channelCount + path: "ACDC/High voltage/ACDC - The Jack.mp3", + //path + //playCount + //played + //discNumber + created: "2004-11-08T23:36:11", + albumId: track.album.id, + artistId: track.artist.id, + type: "music", + isVideo: "false", + })), + }, + }); + +export const asArtistsJson = (artists: Artist[]) => { + const as: Artist[] = []; + const bs: Artist[] = []; + const cs: Artist[] = []; + const rest: Artist[] = []; + artists.forEach((it) => { + const firstChar = it.name.toLowerCase()[0]; + switch (firstChar) { + case "a": + as.push(it); + break; + case "b": + bs.push(it); + break; + case "c": + cs.push(it); + break; + default: + rest.push(it); + break; + } + }); + + const asArtistSummary = (artist: Artist) => ({ + id: artist.id, + name: artist.name, + albumCount: artist.albums.length, + }); + + return subsonicOK({ + artists: { + index: [ + { + name: "A", + artist: as.map(asArtistSummary), + }, + { + name: "B", + artist: bs.map(asArtistSummary), + }, + { + name: "C", + artist: cs.map(asArtistSummary), + }, + { + name: "D-Z", + artist: rest.map(asArtistSummary), + }, + ], + }, + }); +}; + +describe("SubsonicMusicService", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + const customPlayers = {} + + const subsonic = { + ping: jest.fn() + }; + + const service = new SubsonicMusicService( + subsonic as unknown as Subsonic, + customPlayers as unknown as CustomPlayers + ); + + describe("when the credentials are valid", () => { + const credentials = { username:"bob", password: "password123" }; + + beforeEach(() => { + subsonic.ping.mockReturnValue(TE.right({ authenticated: true, type: "subsonic" })); + }); + + it("should be able to generate a token", async () => { + const result = await service.generateToken(credentials)() + expect(result).toEqual(E.right({ + serviceToken: asToken(credentials), + userId: credentials.username, + nickname: credentials.username + })); + }); + + it("should be able to refresh a token", async () => { + const result = await service.refreshToken(asToken(credentials))() + expect(result).toEqual(E.right({ + serviceToken: asToken(credentials), + userId: credentials.username, + nickname: credentials.username + })); + }); + }); + + describe("when the credentials are not valid", () => { + const credentials = { username:"user", password: "is not valid" }; + + beforeEach(() => { + subsonic.ping.mockReturnValue(TE.left(new AuthFailure("Wrong username or password"))); + }); + + it("should fail to generate an auth token", async () => { + const result = await service.generateToken(credentials)() + expect(result).toEqual(E.left(new AuthFailure("Wrong username or password"))); + }); + + it("should fail to refresh the token", async () => { + const result = await service.refreshToken(asToken(credentials))() + expect(result).toEqual(E.left(new AuthFailure("Wrong username or password"))); + }); + }); +}); + +describe("SubsonicMusicLibrary_new", () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + }); + + const credentials = { username: `user-${uuid()}`, password: `pw-${uuid()}` } + + const customPlayers = {} + + const subsonic = { + getArtist: jest.fn(), + getArtistInfo: jest.fn(), + getArtists: jest.fn(), + }; + + const library = new SubsonicMusicLibrary( + subsonic as unknown as Subsonic, + credentials, + customPlayers as unknown as CustomPlayers + ); + + describe("getting an artist", () => { + const id = `artist-${uuid()}`; + const name = `artistName-${uuid()}`; + + // todo: what happens when the artist is missing? + describe("when the artist exists", () => { + describe("when the artist has albums, similar artists and a valid artistImageUrl" , () => { + const artistImageUrl = "http://someImage"; + const albums = [ + anAlbumSummary(), + anAlbumSummary(), + ]; + const similarArtist = [ + { ...anArtistSummary(), isInLibrary: true }, + { ...anArtistSummary(), isInLibrary: true }, + { ...anArtistSummary(), isInLibrary: true }, + ]; + + beforeEach(() => { + subsonic.getArtist.mockResolvedValue({ id, name, artistImageUrl, albums }); + subsonic.getArtistInfo.mockResolvedValue({ similarArtist, images: { s: "s", m: "m", l: "l" }}); + }); + + it("should fetch the artist and artistInfo and merge", async () => { + const result = await library.artist(id) + + expect(result).toEqual({ + id, + name, + image: artistImageURN({ artistImageURL: artistImageUrl }), + albums, + similarArtists: similarArtist + }); + + expect(subsonic.getArtist).toHaveBeenCalledWith(credentials, id); + expect(subsonic.getArtistInfo).toHaveBeenCalledWith(credentials, id); + }); + }); + + describe("when the artist has no valid artistImageUrl, or valid images in artistInfo" , () => { + it("should use the artistId for the image", async () => { + subsonic.getArtist.mockResolvedValue({ id, name, artistImageUrl: undefined, albums: [] }); + subsonic.getArtistInfo.mockResolvedValue({ similarArtist: [], images: { s: undefined, m: undefined, l: undefined }}); + + const result = await library.artist(id) + + expect(result.image).toEqual(artistImageURN({ artistId: id })); + }); + }); + + describe("when the artist has a valid image.s value" , () => { + it("should use the artistId for the image", async () => { + subsonic.getArtist.mockResolvedValue({ id, name, artistImageUrl: undefined, albums: [] }); + subsonic.getArtistInfo.mockResolvedValue({ similarArtist: [], images: { s: "http://smallimage", m: undefined, l: undefined }}); + + const result = await library.artist(id) + + expect(result.image).toEqual(artistImageURN({ artistImageURL: "http://smallimage" })); + }); + }); + + describe("when the artist has a valid image.m value" , () => { + it("should use the artistId for the image", async () => { + subsonic.getArtist.mockResolvedValue({ id, name, artistImageUrl: undefined, albums: [] }); + subsonic.getArtistInfo.mockResolvedValue({ similarArtist: [], images: { s: "http://smallimage", m: "http://mediumimage", l: undefined }}); + + const result = await library.artist(id) + + expect(result.image).toEqual(artistImageURN({ artistImageURL: "http://mediumimage" })); + }); + }); + + describe("when the artist has a valid image.l value" , () => { + it("should use the artistId for the image", async () => { + subsonic.getArtist.mockResolvedValue({ id, name, artistImageUrl: undefined, albums: [] }); + subsonic.getArtistInfo.mockResolvedValue({ similarArtist: [], images: { s: "http://smallimage", m: "http://mediumimage", l: "http://largeimage" }}); + + const result = await library.artist(id) + + expect(result.image).toEqual(artistImageURN({ artistImageURL: "http://largeimage" })); + }); + }); + }); + }); + + describe("getting artists", () => { + describe("when there are no artists", () => { + beforeEach(() => { + subsonic.getArtists.mockResolvedValue([]) + }); + + it("should return empty", async () => { + const result = await library.artists({ _index: 0, _count: 100 }); + + expect(result).toEqual({ + results: [], + total: 0, + }); + + expect(subsonic.getArtists).toHaveBeenCalledWith(credentials) + }); + }); + + describe("when there is one artist", () => { + const artist = { id: "1", name: "bob1", albumCount: 1, image: undefined } + + describe("when it all fits on one page", () => { + beforeEach(() => { + subsonic.getArtists.mockResolvedValue([artist]) + }); + + it("should return the single artist", async () => { + const result = await library.artists({ _index: 0, _count: 100 }); + + expect(result).toEqual({ + results: [artist], + total: 1, + }); + }); + }); + }); + + describe("when there are artists", () => { + const artist1 = { id: "1", name: "bob1", albumCount: 1, image: undefined } + const artist2 = { id: "2", name: "bob2", albumCount: 2, image: undefined } + const artist3 = { id: "3", name: "bob3", albumCount: 3, image: undefined } + const artist4 = { id: "4", name: "bob4", albumCount: 4, image: undefined } + const artists = [artist1, artist2, artist3, artist4]; + + beforeEach(() => { + subsonic.getArtists.mockResolvedValue(artists) + }); + + describe("when no paging is in effect", () => { + it("should return all the artists", async () => { + const result = await library.artists({ _index: 0, _count: 100 }); + + expect(result).toEqual({ + results: artists, + total: 4, + }); + }); + }); + + describe("when paging specified", () => { + it("should return only the correct page of artists", async () => { + const artists = await library.artists({ _index: 1, _count: 2 }); + + expect(artists).toEqual({ + results: [artist2, artist3], + total: 4 + }); + }); + }); + }); + }); +}); + +describe("SubsonicMusicLibrary", () => { + const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); + const username = `user1-${uuid()}`; + const password = `pass1-${uuid()}`; + const salt = "saltysalty"; + + const customPlayers = { + encodingFor: jest.fn(), + }; + + const subsonic = new SubsonicMusicLibrary( + // todo: this should be a mock... + new Subsonic(url, customPlayers), + { username, password }, + customPlayers as unknown as CustomPlayers + ); + + const mockRandomstring = jest.fn(); + const mockGET = jest.fn(); + const mockPOST = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); + + randomstring.generate = mockRandomstring; + axios.get = mockGET; + axios.post = mockPOST; + + mockRandomstring.mockReturnValue(salt); + }); + + const authParams = { + u: username, + v: "1.16.1", + c: "bonob", + t: t(password, salt), + s: salt, + }; + + const authParamsPlusJson = { + ...authParams, + f: "json", + }; + + const headers = { + "User-Agent": "bonob", + }; + + + describe("getting albums", () => { + describe("filtering", () => { + const album1 = anAlbum({ id: "album1", genre: asGenre("Pop") }); + const album2 = anAlbum({ id: "album2", genre: asGenre("Rock") }); + const album3 = anAlbum({ id: "album3", genre: asGenre("Pop") }); + const album4 = anAlbum({ id: "album4", genre: asGenre("Pop") }); + const album5 = anAlbum({ id: "album5", genre: asGenre("Pop") }); + + const artist = anArtist({ + albums: [album1, album2, album3, album4, album5], + }); + + describe("by genre", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist, album1], + // album2 is not Pop + [artist, album3], + ]) + ) + ) + ); + }); + + it("should map the 64 encoded genre back into the subsonic genre", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + genre: b64Encode("Pop"), + type: "byGenre", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album1, album3].map(albumToAlbumSummary), + total: 2, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "byGenre", + genre: "Pop", + size: 500, + offset: 0, + }), + headers, + } + ); + }); + }); + + describe("by newest", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist, album3], + [artist, album2], + [artist, album1], + ]) + ) + ) + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "recentlyAdded", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album3, album2, album1].map(albumToAlbumSummary), + total: 3, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "newest", + size: 500, + offset: 0, + }), + headers, + } + ); + }); + }); + + describe("by recently played", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist, album3], + [artist, album2], + // album1 never played + ]) + ) + ) + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "recentlyPlayed", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album3, album2].map(albumToAlbumSummary), + total: 2, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "recent", + size: 500, + offset: 0, + }), + headers, + } + ); + }); + }); + + describe("by frequently played", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce( + () => + // album1 never played + Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) + // album3 never played + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { _index: 0, _count: 100, type: "mostPlayed" }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album2].map(albumToAlbumSummary), + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "frequent", + size: 500, + offset: 0, + }), + headers, + } + ); + }); + }); + + describe("by starred", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson([artist]))) + ) + .mockImplementationOnce( + () => + // album1 never played + Promise.resolve(ok(getAlbumListJson([[artist, album2]]))) + // album3 never played + ); + }); + + it("should pass the filter to navidrome", async () => { + const q: AlbumQuery = { _index: 0, _count: 100, type: "starred" }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album2].map(albumToAlbumSummary), + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "highest", + size: 500, + offset: 0, + }), + headers, + } + ); + }); + }); + }); + + describe("when the artist has only 1 album", () => { + const artist = anArtist({ + name: "one hit wonder", + albums: [anAlbumSummary({ genre: asGenre("Pop") })], + }); + const artists = [artist]; + const albums = artists.flatMap((artist) => artist.albums); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) + ); + }); + + it("should return the album", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 0, + }), + headers, + } + ); + }); + }); + + describe("when the only artist has no albums", () => { + const artist = anArtist({ + name: "no hit wonder", + albums: [], + }); + const artists = [artist]; + const albums = artists.flatMap((artist) => artist.albums); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) + ); + }); + + it("should return the album", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 0, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 0, + }), + headers, + } + ); + }); + }); + + describe("when there are 6 albums in total", () => { + const genre1 = asGenre("genre1"); + const genre2 = asGenre("genre2"); + const genre3 = asGenre("genre3"); + + const artist1 = anArtist({ + name: "abba", + albums: [ + anAlbumSummary({ name: "album1", genre: genre1 }), + anAlbumSummary({ name: "album2", genre: genre2 }), + anAlbumSummary({ name: "album3", genre: genre3 }), + ], + }); + const artist2 = anArtist({ + name: "babba", + albums: [ + anAlbumSummary({ name: "album4", genre: genre1 }), + anAlbumSummary({ name: "album5", genre: genre2 }), + anAlbumSummary({ name: "album6", genre: genre3 }), + ], + }); + const artists = [artist1, artist2]; + const albums = artists.flatMap((artist) => artist.albums); + + describe("querying for all of them", () => { + it("should return all of them with corrent paging information", async () => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumListJson(asArtistAlbumPairs(artists)))) + ); + + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: albums, + total: 6, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 0, + }), + headers, + } + ); + }); + }); + + describe("querying for a page of them", () => { + it("should return the page with the corrent paging information", async () => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, artist1.albums[2]!], + [artist2, artist2.albums[0]!], + // due to pre-fetch will get next 2 albums also + [artist2, artist2.albums[1]!], + [artist2, artist2.albums[2]!], + ]) + ) + ) + ); + + const q: AlbumQuery = { + _index: 2, + _count: 2, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [artist1.albums[2], artist2.albums[0]], + total: 6, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: 2, + }), + headers, + } + ); + }); + }); + }); + + describe("when the number of albums reported by getArtists does not match that of getAlbums", () => { + const genre = asGenre("lofi"); + + const album1 = anAlbumSummary({ name: "album1", genre }); + const album2 = anAlbumSummary({ name: "album2", genre }); + const album3 = anAlbumSummary({ name: "album3", genre }); + const album4 = anAlbumSummary({ name: "album4", genre }); + const album5 = anAlbumSummary({ name: "album5", genre }); + + // the artists have 5 albums in the getArtists endpoint + const artist1 = anArtist({ + albums: [album1, album2, album3, album4], + }); + const artist2 = anArtist({ + albums: [album5], + }); + const artists = [artist1, artist2]; + + describe("when the number of albums returned from getAlbums is less the number of albums in the getArtists endpoint", () => { + describe("when the query comes back on 1 page", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + [artist1, album3], + // album4 is missing from the albums end point for some reason + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album1, album2, album3, album5], + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the first page", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + // album3 & album5 is returned due to the prefetch + [artist1, album3], + // album4 is missing from the albums end point for some reason + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should filter out the pre-fetched albums", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 2, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album1, album2], + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the last page only", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + // album1 is on the first page + // album2 is on the first page + [artist1, album3], + // album4 is missing from the albums end point for some reason + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the last page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 2, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album3, album5], + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + }); + + describe("when the number of albums returned from getAlbums is more than the number of albums in the getArtists endpoint", () => { + describe("when the query comes back on 1 page", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve( + ok( + asArtistsJson([ + // artist1 has lost 2 albums on the getArtists end point + { ...artist1, albums: [album1, album2] }, + artist2, + ]) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + [artist1, album3], + [artist1, album4], + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album1, album2, album3, album4, album5], + total: 5, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the first page", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve( + ok( + asArtistsJson([ + // artist1 has lost 2 albums on the getArtists end point + { ...artist1, albums: [album1, album2] }, + artist2, + ]) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album1], + [artist1, album2], + [artist1, album3], + [artist1, album4], + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should filter out the pre-fetched albums", async () => { + const q: AlbumQuery = { + _index: 0, + _count: 2, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [album1, album2], + total: 5, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + + describe("when the query is for the last page only", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve( + ok( + asArtistsJson([ + // artist1 has lost 2 albums on the getArtists end point + anArtist({ ...artist1, albums: [album1, album2] }), + artist2, + ]) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok( + getAlbumListJson([ + [artist1, album3], + [artist1, album4], + [artist2, album5], + ]) + ) + ) + ); + }); + + it("should return the last page of albums, updating the total to be accurate", async () => { + const q: AlbumQuery = { + _index: 2, + _count: 100, + type: "alphabeticalByArtist", + }; + const result = await subsonic.albums(q); + + expect(result).toEqual({ + results: [ + album3, + album4, + album5 + ], + total: 5, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbumList2" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); + }); + }); + }); + }); + }); + + describe("getting tracks", () => { + describe("a single track", () => { + const pop = asGenre("Pop"); + + const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + + describe("when there are no custom players", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("that is starred", () => { + it("should return the track", async () => { + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + rating: { + love: true, + stars: 4, + }, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ); + + const result = await subsonic.track(track.id); + + expect(result).toEqual({ + ...track, + rating: { love: true, stars: 4 }, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getSong" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + } + ); + }); + }); + + describe("that is not starred", () => { + it("should return the track", async () => { + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + rating: { + love: false, + stars: 0, + }, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ); + + const result = await subsonic.track(track.id); + + expect(result).toEqual({ + ...track, + rating: { love: false, stars: 0 }, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getSong" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: track.id, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: album.id, + }), + headers, + } + ); + }); + }); + }); + }); + }); + + describe("streaming a track", () => { + const trackId = uuid(); + const genre = aGenre("foo"); + + const album = anAlbum({ genre }); + const artist = anArtist({ + albums: [album], + }); + const track = aTrack({ + id: trackId, + album: albumToAlbumSummary(album), + artist: artistToArtistSummary(artist), + genre, + }); + + describe("when there are no custom players registered", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("content-range, accept-ranges or content-length", () => { + describe("when navidrome doesnt return a content-range, accept-ranges or content-length", () => { + it("should return undefined values", async () => { + const stream = { + pipe: jest.fn(), + }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + }, + data: stream, + }; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await subsonic.stream({ trackId, range: undefined }); + + expect(result.headers).toEqual({ + "content-type": "audio/mpeg", + "content-length": undefined, + "content-range": undefined, + "accept-ranges": undefined, + }); + }); + }); + + describe("when navidrome returns a undefined for content-range, accept-ranges or content-length", () => { + it("should return undefined values", async () => { + const stream = { + pipe: jest.fn(), + }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + "content-length": undefined, + "content-range": undefined, + "accept-ranges": undefined, + }, + data: stream, + }; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await subsonic.stream({ trackId, range: undefined }); + + expect(result.headers).toEqual({ + "content-type": "audio/mpeg", + "content-length": undefined, + "content-range": undefined, + "accept-ranges": undefined, + }); + }); + }); + + describe("with no range specified", () => { + describe("navidrome returns a 200", () => { + it("should return the content", async () => { + const stream = { + pipe: jest.fn(), + }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + "content-length": "1667", + "content-range": "-200", + "accept-ranges": "bytes", + "some-other-header": "some-value", + }, + data: stream, + }; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await subsonic.stream({ + trackId, + range: undefined, + }); + + expect(result.headers).toEqual({ + "content-type": "audio/mpeg", + "content-length": "1667", + "content-range": "-200", + "accept-ranges": "bytes", + }); + expect(result.stream).toEqual(stream); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/stream" }).href(), + { + params: asURLSearchParams({ + ...authParams, + id: trackId, + }), + headers: { + "User-Agent": "bonob", + }, + responseType: "stream", + } + ); + }); + }); + + describe("navidrome returns something other than a 200", () => { + it("should fail", async () => { + const trackId = "track123"; + + const streamResponse = { + status: 400, + headers: { + "content-type": "text/html", + "content-length": "33", + }, + }; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + return expect( + subsonic.stream({ trackId, range: undefined }) + ).rejects.toEqual(`Subsonic failed with a 400 status`); + }); + }); + + describe("io exception occurs", () => { + it("should fail", async () => { + const trackId = "track123"; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ) + .mockImplementationOnce(() => + Promise.reject("IO error occured") + ); + + return expect( + subsonic.stream({ trackId, range: undefined }) + ).rejects.toEqual(`Subsonic failed with: IO error occured`); + }); + }); + }); + + describe("with range specified", () => { + it("should send the range to navidrome", async () => { + const stream = { + pipe: jest.fn(), + }; + + const range = "1000-2000"; + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/flac", + "content-length": "66", + "content-range": "100-200", + "accept-ranges": "none", + "some-other-header": "some-value", + }, + data: stream, + }; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await subsonic.stream({ trackId, range }); + + expect(result.headers).toEqual({ + "content-type": "audio/flac", + "content-length": "66", + "content-range": "100-200", + "accept-ranges": "none", + }); + expect(result.stream).toEqual(stream); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/stream" }).href(), + { + params: asURLSearchParams({ + ...authParams, + id: trackId, + }), + headers: { + "User-Agent": "bonob", + Range: range, + }, + responseType: "stream", + } + ); + }); + }); + }); + }); + + describe("when there are custom players registered", () => { + const customEncoding = { + player: `bonob-${uuid()}`, + mimeType: "transocodedMimeType", + }; + const trackWithCustomPlayer: Track = { + ...track, + encoding: customEncoding, + }; + + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.of(customEncoding)); + }); + + describe("when no range specified", () => { + it("should user the custom client specified by the stream client", async () => { + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + }, + data: Buffer.from("the track", "ascii"), + }; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok(getAlbumJson(album)) + ) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + await subsonic.stream({ trackId, range: undefined }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/stream" }).href(), + { + params: asURLSearchParams({ + ...authParams, + id: trackId, + c: trackWithCustomPlayer.encoding.player, + }), + headers: { + "User-Agent": "bonob", + }, + responseType: "stream", + } + ); + }); + }); + + describe("when range specified", () => { + it("should user the custom client specified by the stream client", async () => { + const range = "1000-2000"; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + }, + data: Buffer.from("the track", "ascii"), + }; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) + ) + .mockImplementationOnce(() => + Promise.resolve( + ok(getAlbumJson(album)) + ) + ) + .mockImplementationOnce(() => Promise.resolve(streamResponse)); + + await subsonic.stream({ trackId, range }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/stream" }).href(), + { + params: asURLSearchParams({ + ...authParams, + id: trackId, + c: trackWithCustomPlayer.encoding.player, + }), + headers: { + "User-Agent": "bonob", + Range: range, + }, + responseType: "stream", + } + ); + }); + }); + }); + }); + + describe("fetching cover art", () => { + describe("fetching album art", () => { + describe("when no size is specified", () => { + it("should fetch the image", async () => { + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + const coverArtId = "someCoverArt"; + const coverArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; + + mockGET.mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await subsonic.coverArt(coverArtURN); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getCoverArt" }).href(), + { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + }), + headers, + responseType: "arraybuffer", + } + ); + }); + }); + + describe("when size is specified", () => { + it("should fetch the image", async () => { + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + const coverArtId = uuid(); + const coverArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; + const size = 1879; + + mockGET.mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await subsonic.coverArt(coverArtURN, size); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getCoverArt" }).href(), + { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + size, + }), + headers, + responseType: "arraybuffer", + } + ); + }); + }); + + describe("when an unexpected error occurs", () => { + it("should return undefined", async () => { + const size = 1879; + + mockGET.mockImplementationOnce(() => Promise.reject("BOOOM")); + + const result = await subsonic.coverArt( + { system: "external", resource: "http://localhost:404" }, + size + ); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe("fetching cover art", () => { + describe("when urn.resource is not subsonic", () => { + it("should be undefined", async () => { + const covertArtURN = { + system: "notSubsonic", + resource: `art:${uuid()}`, + }; + + const result = await subsonic.coverArt(covertArtURN, 190); + + expect(result).toBeUndefined(); + }); + }); + + describe("when no size is specified", () => { + it("should fetch the image", async () => { + const coverArtId = uuid(); + const covertArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + + mockGET.mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await subsonic.coverArt(covertArtURN); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getCoverArt`, { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + }), + headers, + responseType: "arraybuffer", + }); + }); + + describe("and an error occurs fetching the uri", () => { + it("should return undefined", async () => { + const coverArtId = uuid(); + const covertArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; + + mockGET.mockImplementationOnce(() => Promise.reject("BOOOM")); + + const result = await subsonic.coverArt(covertArtURN); + + expect(result).toBeUndefined(); + }); + }); + }); + + describe("when size is specified", () => { + const size = 189; + + it("should fetch the image", async () => { + const coverArtId = uuid(); + const covertArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; + + mockGET.mockImplementationOnce(() => Promise.resolve(streamResponse)); + + const result = await subsonic.coverArt(covertArtURN, size); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getCoverArt" }).href(), + { + params: asURLSearchParams({ + ...authParams, + id: coverArtId, + size, + }), + headers, + responseType: "arraybuffer", + } + ); + }); + + describe("and an error occurs fetching the uri", () => { + it("should return undefined", async () => { + const coverArtId = uuid(); + const covertArtURN = { + system: "subsonic", + resource: `art:${coverArtId}`, + }; + + mockGET.mockImplementationOnce(() => Promise.reject("BOOOM")); + + const result = await subsonic.coverArt(covertArtURN, size); + + expect(result).toBeUndefined(); + }); + }); + }); + }); + }); + + describe("rate", () => { + const trackId = uuid(); + + const artist = anArtist(); + const album = anAlbum({ id: "album1", name: "Burnin", genre: POP }); + + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("rating a track", () => { + describe("loving a track that isnt already loved", () => { + it("should mark the track as loved", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: false, stars: 0 }, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await subsonic.rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/star" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + } + ); + }); + }); + + describe("unloving a track that is loved", () => { + it("should mark the track as loved", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 0 }, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await subsonic.rate(trackId, { + love: false, + stars: 0, + }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/unstar" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + } + ); + }); + }); + + describe("loving a track that is already loved", () => { + it("shouldn't do anything", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 0 }, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ); + + const result = await subsonic.rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledTimes(2); + }); + }); + + describe("rating a track with a different rating", () => { + it("should add the new rating", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: false, stars: 0 }, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await subsonic.rate(trackId, { + love: false, + stars: 3, + }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/setRating" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + rating: 3, + }), + headers, + } + ); + }); + }); + + describe("rating a track with the same rating it already has", () => { + it("shouldn't do anything", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 3 }, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ); + + const result = await subsonic.rate(trackId, { love: true, stars: 3 }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledTimes(2); + }); + }); + + describe("loving and rating a track", () => { + it("should return true", async () => { + const track = aTrack({ + id: trackId, + artist, + album: albumToAlbumSummary(album), + rating: { love: true, stars: 3 }, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await subsonic.rate(trackId, { + love: false, + stars: 5, + }); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/unstar" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + }), + headers, + } + ); + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/setRating" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: trackId, + rating: 5, + }), + headers, + } + ); + }); + }); + + describe("invalid star values", () => { + describe("stars of -1", () => { + it("should return false", async () => { + const result = await subsonic.rate(trackId, { + love: true, + stars: -1, + }); + expect(result).toEqual(false); + }); + }); + + describe("stars of 6", () => { + it("should return false", async () => { + const result = await subsonic.rate(trackId, { + love: true, + stars: -1, + }); + expect(result).toEqual(false); + }); + }); + }); + + describe("when fails", () => { + it("should return false", async () => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(FAILURE))) + .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await subsonic.rate(trackId, { love: true, stars: 0 }); + + expect(result).toEqual(false); + }); + }); + }); + }); + + describe("searchArtists", () => { + describe("when there is 1 search results", () => { + it("should return true", async () => { + const artist1 = anArtist({ name: "foo woo" }); + + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ artists: [artist1] }))) + ); + + const result = await subsonic.searchArtists("foo"); + + expect(result).toEqual([artistToArtistSummary(artist1)]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 20, + albumCount: 0, + songCount: 0, + query: "foo", + }), + headers, + } + ); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const artist1 = anArtist({ name: "foo woo" }); + const artist2 = anArtist({ name: "foo choo" }); + + mockGET.mockImplementationOnce(() => + Promise.resolve( + ok(getSearchResult3Json({ artists: [artist1, artist2] })) + ) + ); + + const result = await subsonic.searchArtists("foo"); + + expect(result).toEqual([ + artistToArtistSummary(artist1), + artistToArtistSummary(artist2), + ]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 20, + albumCount: 0, + songCount: 0, + query: "foo", + }), + headers, + } + ); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) + ); + + const result = await subsonic.searchArtists("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 20, + albumCount: 0, + songCount: 0, + query: "foo", + }), + headers, + } + ); + }); + }); + }); + + describe("searchAlbums", () => { + describe("when there is 1 search results", () => { + it("should return true", async () => { + const album = anAlbum({ name: "foo woo" }); + const artist = anArtist({ name: "#1", albums: [album] }); + + mockGET.mockImplementationOnce(() => + Promise.resolve( + ok(getSearchResult3Json({ albums: [{ artist, album }] })) + ) + ); + + const result = await subsonic.searchAlbums("foo"); + + expect(result).toEqual([ + { + ...albumToAlbumSummary(album), + genre: undefined + } + ]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "foo", + }), + headers, + } + ); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const album1 = anAlbum({ name: "album1" }); + const artist1 = anArtist({ name: "artist1", albums: [album1] }); + + const album2 = anAlbum({ name: "album2" }); + const artist2 = anArtist({ name: "artist2", albums: [album2] }); + + mockGET.mockImplementationOnce(() => + Promise.resolve( + ok( + getSearchResult3Json({ + albums: [ + { artist: artist1, album: album1 }, + { artist: artist2, album: album2 }, + ], + }) + ) + ) + ); + + const result = await subsonic.searchAlbums("moo"); + + expect(result).toEqual([ + { + ...albumToAlbumSummary(album1), + genre: undefined + }, + { + ...albumToAlbumSummary(album2), + genre: undefined + }, + ]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "moo", + }), + headers, + } + ); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ albums: [] }))) + ); + + const result = await subsonic.searchAlbums("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 20, + songCount: 0, + query: "foo", + }), + headers, + } + ); + }); + }); + }); + + describe("searchSongs", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("when there is 1 search results", () => { + it("should return true", async () => { + const pop = asGenre("Pop"); + + const album = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album], + }); + const track = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album), + genre: pop, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ tracks: [track] }))) + ) + .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track)))) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ); + + const result = await subsonic.searchTracks("foo"); + + expect(result).toEqual([track]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 0, + songCount: 20, + query: "foo", + }), + headers, + } + ); + }); + }); + + describe("when there are many search results", () => { + it("should return true", async () => { + const pop = asGenre("Pop"); + + const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist1 = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album1], + }); + const track1 = aTrack({ + id: "track1", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + const album2 = anAlbum({ id: "album2", name: "Bobbin", genre: pop }); + const artist2 = anArtist({ + id: "artist2", + name: "Jane Marley", + albums: [album2], + }); + const track2 = aTrack({ + id: "track2", + artist: artistToArtistSummary(artist2), + album: albumToAlbumSummary(album2), + genre: pop, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve( + ok( + getSearchResult3Json({ + tracks: [track1, track2], + }) + ) + ) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track1))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track2))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album1))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album2))) + ); + + const result = await subsonic.searchTracks("moo"); + + expect(result).toEqual([track1, track2]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 0, + songCount: 20, + query: "moo", + }), + headers, + } + ); + }); + }); + + describe("when there are no search results", () => { + it("should return []", async () => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getSearchResult3Json({ tracks: [] }))) + ); + + const result = await subsonic.searchTracks("foo"); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/search3" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + artistCount: 0, + albumCount: 0, + songCount: 20, + query: "foo", + }), + headers, + } + ); + }); + }); + }); + + describe("playlists", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("getting playlists", () => { + describe("when there is 1 playlist results", () => { + it("should return it", async () => { + const playlist = aPlaylistSummary(); + + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson([playlist]))) + ); + + const result = await subsonic.playlists(); + + expect(result).toEqual([playlist]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getPlaylists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + }); + }); + + describe("when there are many playlists", () => { + it("should return them", async () => { + const playlist1 = aPlaylistSummary(); + const playlist2 = aPlaylistSummary(); + const playlist3 = aPlaylistSummary(); + const playlists = [playlist1, playlist2, playlist3]; + + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson(playlists))) + ); + + const result = await subsonic.playlists(); + + expect(result).toEqual(playlists); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getPlaylists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + }); + }); + + describe("when there are no playlists", () => { + it("should return []", async () => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListsJson([]))) + ); + + const result = await subsonic.playlists(); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getPlaylists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + headers, + } + ); + }); + }); + }); + + describe("getting a single playlist", () => { + describe("when there is no playlist with the id", () => { + it("should raise error", async () => { + const id = "id404"; + + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(error("70", "data not found"))) + ); + + return expect(subsonic.playlist(id)).rejects.toEqual( + "Subsonic error:data not found" + ); + }); + }); + + describe("when there is a playlist with the id", () => { + describe("and it has tracks", () => { + it("should return the playlist with entries", async () => { + const id = uuid(); + const name = "Great Playlist"; + const artist1 = anArtist(); + const album1 = anAlbum({ + artistId: artist1.id, + artistName: artist1.name, + genre: POP, + }); + const track1 = aTrack({ + genre: POP, + number: 66, + coverArt: album1.coverArt, + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + }); + + const artist2 = anArtist(); + const album2 = anAlbum({ + artistId: artist2.id, + artistName: artist2.name, + genre: ROCK, + }); + const track2 = aTrack({ + genre: ROCK, + number: 77, + coverArt: album2.coverArt, + artist: artistToArtistSummary(artist2), + album: albumToAlbumSummary(album2), + }); + + mockGET.mockImplementationOnce(() => + Promise.resolve( + ok( + getPlayListJson({ + id, + name, + entries: [track1, track2], + }) + ) + ) + ); + + const result = await subsonic.playlist(id); + + expect(result).toEqual({ + id, + name, + entries: [ + { ...track1, number: 1 }, + { ...track2, number: 2 }, + ], + }); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getPlaylist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + }), + headers, + } + ); + }); + }); + + describe("and it has no tracks", () => { + it("should return the playlist with empty entries", async () => { + const playlist = aPlaylist({ + entries: [], + }); + + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getPlayListJson(playlist))) + ); + + const result = await subsonic.playlist(playlist.id); + + expect(result).toEqual(playlist); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getPlaylist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id: playlist.id, + }), + headers, + } + ); + }); + }); + }); + }); + + describe("creating a playlist", () => { + it("should create a playlist with the given name", async () => { + const name = "ThePlaylist"; + const id = uuid(); + + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(createPlayListJson({ id, name }))) + ); + + const result = await subsonic.createPlaylist(name); + + expect(result).toEqual({ id, name }); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/createPlaylist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + f: "json", + name, + }), + headers, + } + ); + }); + }); + + describe("deleting a playlist", () => { + it("should delete the playlist by id", async () => { + const id = "id-to-delete"; + + mockGET.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await subsonic.deletePlaylist(id); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/deletePlaylist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + id, + }), + headers, + } + ); + }); + }); + + describe("editing playlists", () => { + describe("adding a track to a playlist", () => { + it("should add it", async () => { + const playlistId = uuid(); + const trackId = uuid(); + + mockGET.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await subsonic.addToPlaylist(playlistId, trackId); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/updatePlaylist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + playlistId, + songIdToAdd: trackId, + }), + headers, + } + ); + }); + }); + + describe("removing a track from a playlist", () => { + it("should remove it", async () => { + const playlistId = uuid(); + const indicies = [6, 100, 33]; + + mockGET.mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + + const result = await subsonic.removeFromPlaylist( + playlistId, + indicies + ); + + expect(result).toEqual(true); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/updatePlaylist" }).href(), + { + params: asURLSearchParams({ + ...authParamsPlusJson, + playlistId, + songIndexToRemove: indicies, + }), + headers, + } + ); + }); + }); + }); + }); + + describe("similarSongs", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("when there is one similar songs", () => { + it("should return it", async () => { + const id = "idWithTracks"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist1 = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album1], + }); + + const track1 = aTrack({ + id: "track1", + artist: artistToArtistSummary(artist1), + album: album1, + genre: pop, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([track1]))) + ); + + const result = await subsonic.similarSongs(id); + + expect(result).toEqual([trackToTrackSummary(track1)]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getSimilarSongs2" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + id, + count: 50, + }), + headers, + } + ); + }); + }); + + describe("when there are similar songs", () => { + it("should return them", async () => { + const id = "idWithTracks"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop }); + const artist1 = anArtist({ + id: "artist1", + name: "Bob Marley", + albums: [album1], + }); + + const album2 = anAlbum({ id: "album2", name: "Walking", genre: pop }); + const artist2 = anArtist({ + id: "artist2", + name: "Bob Jane", + albums: [album2], + }); + + const track1 = aTrack({ + id: "track1", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + const track2 = aTrack({ + id: "track2", + artist: artistToArtistSummary(artist2), + album: albumToAlbumSummary(album2), + genre: pop, + }); + const track3 = aTrack({ + id: "track3", + artist: artistToArtistSummary(artist1), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([track1, track2, track3]))) + ); + + const result = await subsonic.similarSongs(id); + + expect(result).toEqual([ + trackToTrackSummary(track1), + trackToTrackSummary(track2), + trackToTrackSummary(track3), + ]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getSimilarSongs2" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + id, + count: 50, + }), + headers, + } + ); + }); + }); + + describe("when there are no similar songs", () => { + it("should return []", async () => { + const id = "idWithNoTracks"; + + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getSimilarSongsJson([]))) + ); + + const result = await subsonic.similarSongs(id); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getSimilarSongs2" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + id, + count: 50, + }), + headers, + } + ); + }); + }); + + describe("when the id doesnt exist", () => { + it("should fail", async () => { + const id = "idThatHasAnError"; + + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(error("70", "data not found"))) + ); + + return expect(subsonic.similarSongs(id)).rejects.toEqual( + "Subsonic error:data not found" + ); + }); + }); + }); + + describe("topSongs", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("when there is one top song", () => { + it("should return it", async () => { + const artistId = "bobMarleyId"; + const artistName = "Bob Marley"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ name: "Burnin", genre: pop }); + const artist = anArtist({ + id: artistId, + name: artistName, + albums: [album1], + }); + + const track1 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album1), + genre: pop, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getTopSongsJson([track1]))) + ); + + const result = await subsonic.topSongs(artistId); + + expect(result).toEqual([ + trackToTrackSummary(track1) + ]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getTopSongs" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + artist: artistName, + count: 50, + }), + headers, + } + ); + }); + }); + + describe("when there are many top songs", () => { + it("should return them", async () => { + const artistId = "bobMarleyId"; + const artistName = "Bob Marley"; + + const album1 = anAlbum({ name: "Burnin", genre: POP }); + const album2 = anAlbum({ name: "Churning", genre: POP }); + + const artist = anArtist({ + id: artistId, + name: artistName, + albums: [album1, album2], + }); + + const track1 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album1), + genre: POP, + }); + + const track2 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album2), + genre: POP, + }); + + const track3 = aTrack({ + artist: artistToArtistSummary(artist), + album: albumToAlbumSummary(album1), + genre: POP, + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getTopSongsJson([track1, track2, track3]))) + ); + + const result = await subsonic.topSongs(artistId); + + expect(result).toEqual([ + trackToTrackSummary(track1), + trackToTrackSummary(track2), + trackToTrackSummary(track3), + ]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getTopSongs" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + artist: artistName, + count: 50, + }), + headers, + } + ); + }); + }); + + describe("when there are no similar songs", () => { + it("should return []", async () => { + const artistId = "bobMarleyId"; + const artistName = "Bob Marley"; + const pop = asGenre("Pop"); + + const album1 = anAlbum({ name: "Burnin", genre: pop }); + const artist = anArtist({ + id: artistId, + name: artistName, + albums: [album1], + }); + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistJson(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getTopSongsJson([]))) + ); + + const result = await subsonic.topSongs(artistId); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getTopSongs" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + artist: artistName, + count: 50, + }), + headers, + } + ); + }); + }); + }); + + describe("radioStations", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("when there some radio stations", () => { + const station1 = aRadioStation(); + const station2 = aRadioStation(); + const station3 = aRadioStation(); + + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve( + ok(getRadioStationsJson([station1, station2, station3])) + ) + ); + }); + + describe("asking for all of them", () => { + it("should return them all", async () => { + const result = await subsonic.radioStations(); + + expect(result).toEqual([station1, station2, station3]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getInternetRadioStations" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + }), + headers, + } + ); + }); + }); + + describe("asking for one of them", () => { + it("should return it", async () => { + const result = await subsonic.radioStation(station2.id); + + expect(result).toEqual(station2); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getInternetRadioStations" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + }), + headers, + } + ); + }); + }); + }); + + describe("when there are no radio stations", () => { + it("should return []", async () => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getRadioStationsJson([]))) + ); + + const result = await subsonic.radioStations(); + + expect(result).toEqual([]); + + expect(mockGET).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getInternetRadioStations" }).href(), + { + params: asURLSearchParams({ + ...authParams, + f: "json", + }), + headers, + } + ); + }); + }); + }); +}); From e22ef0e72de93bb0d58d538762bfe3042b6d7531 Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 23 Jan 2026 18:09:18 +0000 Subject: [PATCH 17/51] Test case for different image sizes when using image replacement patterns on S2 --- tests/server.test.ts | 57 +++++++++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/tests/server.test.ts b/tests/server.test.ts index 85f0c46..b929613 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1258,31 +1258,38 @@ describe("server", () => { describe("fetching a single image", () => { describe("when the images is available and has a valid content type", () => { - it("should return the image with correct content type", async () => { - const coverArtURN = { system: "subsonic", resource: "art:200" }; - - const coverArt = coverArtResponse({}); - - musicService.login.mockResolvedValue(musicLibrary); - - musicLibrary.coverArt.mockResolvedValue(coverArt); - - const res = await request(server) - .get( - `/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}` - ) - .set(BONOB_ACCESS_TOKEN_HEADER, apiToken); - - expect(res.status).toEqual(coverArt.status); - expect(res.header["content-type"]).toEqual( - coverArt.contentType - ); - - expect(musicService.login).toHaveBeenCalledWith(serviceToken); - expect(musicLibrary.coverArt).toHaveBeenCalledWith( - coverArtURN, - 180 - ); + [ + ["180", 180], + ["1500.png", 1500], + ].forEach((spec) => { + describe(`when the requested size is ${spec[0]}`, () => { + it(`should ask for the image of size ${spec[1]} and return the result`, async () => { + const coverArtURN = { system: "subsonic", resource: "art:200" }; + + const coverArt = coverArtResponse({}); + + musicService.login.mockResolvedValue(musicLibrary); + + musicLibrary.coverArt.mockResolvedValue(coverArt); + + const res = await request(server) + .get( + `/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/${spec[0]}?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}` + ) + .set(BONOB_ACCESS_TOKEN_HEADER, apiToken); + + expect(res.status).toEqual(coverArt.status); + expect(res.header["content-type"]).toEqual( + coverArt.contentType + ); + + expect(musicService.login).toHaveBeenCalledWith(serviceToken); + expect(musicLibrary.coverArt).toHaveBeenCalledWith( + coverArtURN, + spec[1] + ); + }); + }); }); }); From 169e2e18080ab6db1a96960f3b357ea2911d504e Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 24 Jan 2026 00:24:38 +0000 Subject: [PATCH 18/51] Add 1000x1000 as an image size --- package.json | 4 ++-- src/smapi.ts | 1 + src/subsonic.ts | 7 +++++-- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/package.json b/package.json index 3ab628a..6d0be26 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "scripts": { "clean": "rm -Rf build node_modules", "build": "tsc", - "dev80": "BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", + "dev80": "BNB_AUTH_TIMEOUT=1m BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", "dev": "BNB_LOGIN_THEME=navidrome-ish BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=false BNB_SONOS_AUTO_REGISTER=false BNB_URL=\"${BNB_DEV_LOCAL_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", "devr": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"${BNB_DEV_LOCAL_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", "register-dev": "ts-node ./src/register.ts ${BNB_DEV_URL}", @@ -74,4 +74,4 @@ "testw": "jest --watch", "gitinfo": "git describe --tags > .gitinfo" } -} +} \ No newline at end of file diff --git a/src/smapi.ts b/src/smapi.ts index ec6eb69..ee0567f 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -55,6 +55,7 @@ export const SONOS_RECOMMENDED_IMAGE_SIZES = [ "600", "640", "750", + "1000", "1242", "1500", ]; diff --git a/src/subsonic.ts b/src/subsonic.ts index e9f407f..d4496ba 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -424,8 +424,11 @@ export const asURLSearchParams = (q: any) => { export type ImageFetcher = (url: string) => Promise; -export const cachingImageFetcher = - (cacheDir: string, delegate: ImageFetcher, makeSharp = sharp) => +export const cachingImageFetcher = ( + cacheDir: string, + delegate: ImageFetcher, + makeSharp = sharp +) => async (url: string): Promise => { const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); return fse From 1bd17eea8d8bb465ae01572a218dbd5a1c8f4cf8 Mon Sep 17 00:00:00 2001 From: Simon J Date: Wed, 28 Jan 2026 18:54:25 +1100 Subject: [PATCH 19/51] Refactor how login tokens are found between soap and request headers to (#238) support auth headers in http request smapi option --- src/smapi.ts | 177 ++++++++++++++++++++++++++-------------- src/sonos.ts | 4 +- tests/builders.ts | 20 +++-- tests/scenarios.test.ts | 13 ++- tests/smapi.test.ts | 85 ++++++++++++++----- 5 files changed, 202 insertions(+), 97 deletions(-) diff --git a/src/smapi.ts b/src/smapi.ts index ee0567f..ef7bf5e 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -36,6 +36,7 @@ import { SMAPI_FAULT_LOGIN_UNAUTHORIZED, ToSmapiFault, } from "./smapi_auth"; +import { IncomingHttpHeaders } from "http"; export const LOGIN_ROUTE = "/login"; export const CREATE_REGISTRATION_ROUTE = "/registration/add"; @@ -65,11 +66,14 @@ const WSDL_FILE = path.resolve( "Sonoswsdl-1.19.6-20231024.wsdl" ); -export type Credentials = { - loginToken: { + +export type LoginToken = { token: string; householdId: string; - }; +} + +export type Credentials = { + loginToken: LoginToken; deviceId: string; deviceProvider: string; }; @@ -96,6 +100,7 @@ export type GetDeviceAuthTokenResult = { export const ratingAsInt = (rating: Rating): number => rating.stars * 10 + (rating.love ? 1 : 0) + 100; + export const ratingFromInt = (value: number): Rating => { const x = value - 100; return { love: x % 10 == 1, stars: Math.floor(x / 10) }; @@ -258,7 +263,7 @@ const genre = (bonobUrl: URLBuilder, genre: Genre) => ({ itemType: "albumList", id: `genre:${genre.id}`, title: genre.name, - albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(), + albumArtURI: albumArtURI(iconArtURI(bonobUrl, iconForGenre(genre.name)).href()), }); const yyyy = (bonobUrl: URLBuilder, year: Year) => ({ @@ -266,7 +271,7 @@ const yyyy = (bonobUrl: URLBuilder, year: Year) => ({ id: `year:${year.year}`, title: year.year, // todo: maybe year.year should be nullable? - albumArtURI: year.year !== "?" ? iconArtURI(bonobUrl, "yyyy", year.year).href() : iconArtURI(bonobUrl, "music").href(), + albumArtURI: albumArtURI(year.year !== "?" ? iconArtURI(bonobUrl, "yyyy", year.year).href() : iconArtURI(bonobUrl, "music").href()), }); export const shouldScrobble = (track: Track, playbackTime: number) => ( @@ -287,7 +292,7 @@ const playlist = (bonobUrl: URLBuilder, playlist: PlaylistSummary) => ({ itemType: "playlist", id: `playlist:${playlist.id}`, title: playlist.name, - albumArtURI: coverArtURI(bonobUrl, playlist).href(), + albumArtURI: albumArtURI(coverArtURI(bonobUrl, playlist).href()), canPlay: true, attributes: { userContent: true, @@ -317,13 +322,25 @@ export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON, text: string | unde export const sonosifyMimeType = (mimeType: string) => mimeType == "audio/x-flac" ? "audio/flac" : mimeType; + +/* This doesnt seem to work on S2, only S1, ChatGPT seems to imply it has been deprecated +even though there is no mention of that in the docs that i can find. +{ + attributes: { + requiresAuthentication: true + }, + $value: value +} +*/ +const albumArtURI = (value: string) => value + export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({ itemType: "album", id: `album:${album.id}`, artist: album.artistName, artistId: `artist:${album.artistId}`, title: album.name, - albumArtURI: coverArtURI(bonobUrl, album).href(), + albumArtURI: albumArtURI(coverArtURI(bonobUrl, album).href()), canPlay: true, // defaults // canScroll: false, @@ -349,7 +366,7 @@ export const track = (bonobUrl: URLBuilder, track: Track) => ({ albumId: `album:${track.album.id}`, albumArtist: track.artist.name, albumArtistId: track.artist.id ? `artist:${track.artist.id}` : undefined, - albumArtURI: coverArtURI(bonobUrl, track).href(), + albumArtURI: albumArtURI(coverArtURI(bonobUrl, track).href()), artist: track.artist.name, artistId: track.artist.id ? `artist:${track.artist.id}` : undefined, duration: track.duration, @@ -367,7 +384,7 @@ export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({ id: `artist:${artist.id}`, artistId: artist.id, title: artist.name, - albumArtURI: coverArtURI(bonobUrl, { coverArt: artist.image }).href(), + albumArtURI: albumArtURI(coverArtURI(bonobUrl, { coverArt: artist.image }).href()), }); export const splitId = (id: string) => { @@ -385,13 +402,21 @@ export function withSplitId(id: string) { }); } -type SoapyHeaders = { - credentials?: Credentials; +export type SoapyHeaders = { + credentials?: { + loginToken?: { + // wsdl seems to imply that token is required, however in practice that doesnt seem to be true + token?: string; + key?: string; + householdId: string; + }, + deviceId?: string; + deviceProvider?: string; + }; }; type Auth = { serviceToken: string; - credentials: Credentials; apiKey: string; }; @@ -399,6 +424,17 @@ function isAuth(thing: any): thing is Auth { return thing.serviceToken; } +export function findLoginToken( + soapHeaders: SoapyHeaders | undefined, + httpRequestHeaders: IncomingHttpHeaders +): string | undefined { + const soapToken = soapHeaders?.credentials?.loginToken?.token + const httpRequestToken = httpRequestHeaders["authorization"] + if(soapToken != undefined) return soapToken + else if(httpRequestToken != undefined) return httpRequestToken.replace(/^Bearer /, "") + else return undefined +} + function bindSmapiSoapServiceToExpress( app: Express, soapPath: string, @@ -419,32 +455,30 @@ function bindSmapiSoapServiceToExpress( }, }); - const auth = (credentials?: Credentials): E.Either => { - const credentialsFrom = E.fromNullable(new MissingLoginTokenError()); + const auth = (loginToken?: string): E.Either => { + const tokenFrom = E.fromNullable(new MissingLoginTokenError()); return pipe( - credentialsFrom(credentials), - E.chain((credentials) => + tokenFrom(loginToken), + E.chain((token) => pipe( smapiAuthTokens.verify({ - token: credentials.loginToken.token + token }), E.map((serviceToken) => ({ - serviceToken, - credentials, + serviceToken })) ) ), - E.map(({ serviceToken, credentials }) => ({ + E.map(({ serviceToken }) => ({ serviceToken, - credentials, apiKey: apiKeys.mint(serviceToken), })) ); }; - const login = async (credentials?: Credentials) => { + const login = async (loginToken?: string) => { const authOrFail = pipe( - auth(credentials), + auth(loginToken), E.getOrElseW((fault) => fault) ); if (isAuth(authOrFail)) { @@ -496,9 +530,14 @@ function bindSmapiSoapServiceToExpress( pollInterval: 60, }, }), - refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders) => { + refreshAuthToken: async ( + _, + _2, + soapyHeaders: SoapyHeaders, + { headers }: Pick + ) => { const serviceToken = pipe( - auth(soapyHeaders?.credentials), + auth(findLoginToken(soapyHeaders, headers)), E.fold( (fault) => isExpiredTokenError(fault) @@ -527,9 +566,10 @@ function bindSmapiSoapServiceToExpress( getMediaURI: async ( { id }: { id: string }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) + login(findLoginToken(soapyHeaders, headers)) .then(withSplitId(id)) .then(({ musicLibrary, apiKey, type, typeId }) => { switch (type) { @@ -566,9 +606,10 @@ function bindSmapiSoapServiceToExpress( getMediaMetadata: async ( { id }: { id: string }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) + login(findLoginToken(soapyHeaders, headers)) .then(withSplitId(id)) .then(async ({ musicLibrary, apiKey, type, typeId }) => { switch (type) { @@ -590,9 +631,10 @@ function bindSmapiSoapServiceToExpress( search: async ( { id, term }: { id: string; term: string }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) + login(findLoginToken(soapyHeaders, headers)) .then(withSplitId(id)) .then(async ({ musicLibrary, apiKey }) => { switch (id) { @@ -634,9 +676,10 @@ function bindSmapiSoapServiceToExpress( getExtendedMetadata: async ( { id }: { id: string }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) + login(findLoginToken(soapyHeaders, headers)) .then(withSplitId(id)) .then(async ({ musicLibrary, apiKey, type, typeId }) => { switch (type) { @@ -701,8 +744,8 @@ function bindSmapiSoapServiceToExpress( _, soapyHeaders: SoapyHeaders, { headers }: Pick - ) => - login(soapyHeaders?.credentials) + ) => { + return login(findLoginToken(soapyHeaders, headers)) .then(withSplitId(id)) .then(({ musicLibrary, apiKey, type, typeId }) => { const paging = { _index: index, _count: count }; @@ -730,37 +773,37 @@ function bindSmapiSoapServiceToExpress( { id: "artists", title: lang("artists"), - albumArtURI: iconArtURI(bonobUrl, "artists").href(), + albumArtURI: albumArtURI(iconArtURI(bonobUrl, "artists").href()), itemType: "container", }, { id: "albums", title: lang("albums"), - albumArtURI: iconArtURI(bonobUrl, "albums").href(), + albumArtURI: albumArtURI(iconArtURI(bonobUrl, "albums").href()), itemType: "albumList", }, { id: "randomAlbums", title: lang("random"), - albumArtURI: iconArtURI(bonobUrl, "random").href(), + albumArtURI: albumArtURI(iconArtURI(bonobUrl, "random").href()), itemType: "albumList", }, { id: "favouriteAlbums", title: lang("favourites"), - albumArtURI: iconArtURI(bonobUrl, "heart").href(), + albumArtURI: albumArtURI(iconArtURI(bonobUrl, "heart").href()), itemType: "albumList", }, { id: "starredAlbums", title: lang("topRated"), - albumArtURI: iconArtURI(bonobUrl, "star").href(), + albumArtURI: albumArtURI(iconArtURI(bonobUrl, "star").href()), itemType: "albumList", }, { id: "playlists", title: lang("playlists"), - albumArtURI: iconArtURI(bonobUrl, "playlists").href(), + albumArtURI: albumArtURI(iconArtURI(bonobUrl, "playlists").href()), itemType: "collection", attributes: { userContent: true, @@ -769,46 +812,46 @@ function bindSmapiSoapServiceToExpress( { id: "genres", title: lang("genres"), - albumArtURI: iconArtURI(bonobUrl, "genres").href(), + albumArtURI: albumArtURI(iconArtURI(bonobUrl, "genres").href()), itemType: "container", }, { id: "years", title: lang("years"), - albumArtURI: iconArtURI(bonobUrl, "music").href(), + albumArtURI: albumArtURI(iconArtURI(bonobUrl, "music").href()), itemType: "container", }, { id: "recentlyAdded", title: lang("recentlyAdded"), - albumArtURI: iconArtURI( + albumArtURI: albumArtURI(iconArtURI( bonobUrl, "recentlyAdded" - ).href(), + ).href()), itemType: "albumList", }, { id: "recentlyPlayed", title: lang("recentlyPlayed"), - albumArtURI: iconArtURI( + albumArtURI: albumArtURI(iconArtURI( bonobUrl, "recentlyPlayed" - ).href(), + ).href()), itemType: "albumList", }, { id: "mostPlayed", title: lang("mostPlayed"), - albumArtURI: iconArtURI( + albumArtURI: albumArtURI(iconArtURI( bonobUrl, "mostPlayed" - ).href(), + ).href()), itemType: "albumList", }, { id: "internetRadio", title: lang("internetRadio"), - albumArtURI: iconArtURI(bonobUrl, "radio").href(), + albumArtURI: albumArtURI(iconArtURI(bonobUrl, "radio").href()), itemType: "stream", }, ], @@ -1004,13 +1047,16 @@ function bindSmapiSoapServiceToExpress( total: 0, }); } - }), + }) + } + , createContainer: async ( { title, seedId }: { title: string; seedId: string | undefined }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) + login(findLoginToken(soapyHeaders, headers)) .then(({ musicLibrary }) => musicLibrary .createPlaylist(title) @@ -1034,17 +1080,19 @@ function bindSmapiSoapServiceToExpress( deleteContainer: async ( { id }: { id: string }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) + login(findLoginToken(soapyHeaders, headers)) .then(({ musicLibrary }) => musicLibrary.deletePlaylist(id)) .then((_) => ({ deleteContainerResult: {} })), addToContainer: async ( { id, parentId }: { id: string; parentId: string }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) + login(findLoginToken(soapyHeaders, headers)) .then(withSplitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId) @@ -1053,9 +1101,10 @@ function bindSmapiSoapServiceToExpress( removeFromContainer: async ( { id, indices }: { id: string; indices: string }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) + login(findLoginToken(soapyHeaders, headers)) .then(withSplitId(id)) .then((it) => ({ ...it, @@ -1077,9 +1126,10 @@ function bindSmapiSoapServiceToExpress( rateItem: async ( { id, rating }: { id: string; rating: number }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) + login(findLoginToken(soapyHeaders, headers)) .then(withSplitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating))) @@ -1089,9 +1139,10 @@ function bindSmapiSoapServiceToExpress( setPlayedSeconds: async ( { id, seconds }: { id: string; seconds: string }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) + login(findLoginToken(soapyHeaders, headers)) .then(withSplitId(id)) .then(({ musicLibrary, type, typeId }) => { switch (type) { diff --git a/src/sonos.ts b/src/sonos.ts index 1dd8a17..191fd45 100644 --- a/src/sonos.ts +++ b/src/sonos.ts @@ -42,10 +42,10 @@ export type Capability = | "manifest"; export const BONOB_CAPABILITIES: Capability[] = [ - "search", - "ucPlaylists", "extendedMD", "logging", + "search", + "ucPlaylists", ]; export type Device = { diff --git a/tests/builders.ts b/tests/builders.ts index ec6dbba..9feb7c3 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -2,7 +2,7 @@ import { SonosDevice } from "@svrooij/sonos/lib"; import { v4 as uuid } from "uuid"; import randomstring from "randomstring"; -import { Credentials } from "../src/smapi"; +import { SoapyHeaders } from "../src/smapi"; import { Service, Device } from "../src/sonos"; import { Album, @@ -89,16 +89,18 @@ export function getAppLinkMessage() { sonosAppName: "", callbackPath: "", }; -} +}; -export function someCredentials({ token } : { token: string }): Credentials { +export function someSoapHeadersForToken(token: string): SoapyHeaders { return { - loginToken: { - token, - householdId: "hh1", - }, - deviceId: "d1", - deviceProvider: "dp1", + credentials: { + loginToken: { + token, + householdId: `householdId-${uuid()}`, + }, + deviceId: `deviceId-${uuid()}`, + deviceProvider: `deviceProvider-${uuid()}`, + } }; } diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index f7cfceb..7240101 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -2,6 +2,7 @@ import { createClientAsync, Client } from "soap"; import { Express } from "express"; import request from "supertest"; +import { v4 as uuid } from "uuid"; import { GetAppLinkResult, @@ -14,7 +15,6 @@ import { BOB_MARLEY, getAppLinkMessage, MADONNA, - someCredentials, } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import { InMemoryLinkCodes } from "../src/link_codes"; @@ -33,9 +33,14 @@ class LoggedInSonosDriver { this.client = client; this.token = token; this.client.addSoapHeader({ - credentials: someCredentials({ - token: this.token.getDeviceAuthTokenResult.authToken, - }), + credentials: { + loginToken: { + token: this.token.getDeviceAuthTokenResult.authToken, + householdId: `householdId-${uuid()}`, + }, + deviceId: `deviceId-${uuid()}`, + deviceProvider: `deviceProvider-${uuid()}` + } }); } diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index b9c49fe..8329279 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -25,6 +25,7 @@ import { ratingAsInt, ratingFromInt, internetRadioStation, + findLoginToken } from "../src/smapi"; import { keys as i8nKeys } from "../src/i8n"; @@ -34,7 +35,6 @@ import { anArtist, anAlbum, aTrack, - someCredentials, POP, ROCK, TRIP_HOP, @@ -43,6 +43,7 @@ import { aRadioStation, anArtistSummary, anAlbumSummary, + someSoapHeadersForToken, } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import supersoap from "./supersoap"; @@ -89,6 +90,54 @@ describe("rating to and from ints", () => { }); }); +describe("findLoginToken", () => { + describe("when there are credentials on the soap header only", () => { + it("should use them", () => { + expect(findLoginToken( + { credentials: { loginToken: { token: "soap-only-token", householdId: "the-household" } } }, + {} + )).toEqual("soap-only-token") + }); + }); + + describe("when the credentials are on the http request header", () => { + it("should use them", () => { + expect(findLoginToken( + { credentials: { loginToken: { householdId: "the-household" } } }, + { "accept": "something", "authorization": `Bearer http-request-token` } + )).toEqual("http-request-token") + }); + }); + + describe("when the credentials are on the http request header, and there are none on the soap header", () => { + it("should use them", () => { + expect(findLoginToken( + { }, + { "accept": "something", "authorization": `Bearer http-request-token` } + )).toEqual("http-request-token") + }); + }); + + describe("when there is no token on the soap header and no http request header", () => { + it("should return undefined", () => { + expect(findLoginToken( + { credentials: { loginToken: { householdId: "the-household" } } }, + { "accept": "something" } + )).toEqual(undefined) + }); + }); + + describe("when there are no credientials at all on the soap header and no http request header", () => { + it("should return undefined", () => { + expect(findLoginToken( + { }, + { "accept": "something" } + )).toEqual(undefined) + }); + }); + +}); + describe("service config", () => { const bonobWithNoContextPath = url("http://localhost:1234"); const bonobWithContextPath = url("http://localhost:5678/some-context-path"); @@ -662,14 +711,21 @@ describe("wsdl api", () => { jest.resetAllMocks(); }); + function randomlySetAuthenticationMethod(ws: Client, token: string) { + if(Math.random() < 0.5) { + // todo: soap will still sell some soap headers, need to add in here.. + ws.addHttpHeader("authorization", `Bearer ${token}`) + } else { + ws.addSoapHeader(someSoapHeadersForToken(token)); + } + return ws; + } + function setupAuthenticatedRequest(ws: Client) { musicService.login.mockResolvedValue(musicLibrary); smapiAuthTokens.verify.mockReturnValue(E.right(serviceToken)); apiTokens.mint.mockReturnValue(apiToken); - ws.addSoapHeader({ - credentials: someCredentials(smapiAuthToken), - }); - return ws; + return randomlySetAuthenticationMethod(ws, serviceToken) } describe("soap api", () => { @@ -829,9 +885,7 @@ describe("wsdl api", () => { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ - credentials: someCredentials(smapiAuthToken), - }); + randomlySetAuthenticationMethod(ws, smapiAuthToken.token) const result = await ws.refreshAuthTokenAsync({}); @@ -855,9 +909,7 @@ describe("wsdl api", () => { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ - credentials: someCredentials(smapiAuthToken), - }); + randomlySetAuthenticationMethod(ws, smapiAuthToken.token) await ws.refreshAuthTokenAsync({}) .then(() => fail("shouldnt get here")) @@ -882,9 +934,7 @@ describe("wsdl api", () => { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ - credentials: someCredentials(smapiAuthToken), - }); + randomlySetAuthenticationMethod(ws, smapiAuthToken.token) const result = await ws.refreshAuthTokenAsync({}); @@ -1062,7 +1112,7 @@ describe("wsdl api", () => { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials({ token: 'tokenThatFails' }) }); + randomlySetAuthenticationMethod(ws, 'tokenThatFails'); await action(ws) .then(() => fail("shouldnt get here")) @@ -1109,9 +1159,7 @@ describe("wsdl api", () => { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ - credentials: someCredentials(smapiAuthToken), - }); + randomlySetAuthenticationMethod(ws, smapiAuthToken.token); await action(ws) .then(() => fail("shouldnt get here")) .catch((e: any) => { @@ -2967,7 +3015,6 @@ describe("wsdl api", () => { describe("when valid credentials are provided", () => { let ws: Client; - beforeEach(async () => { ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, From 5051f36f6e2fa004e260650cf139852d9d0c6a1e Mon Sep 17 00:00:00 2001 From: Simon J Date: Sat, 7 Feb 2026 16:29:55 +1100 Subject: [PATCH 20/51] Updated docs for Sonos S2 support (#233) --- .devcontainer/devcontainer.json | 1 + README.md | 261 +++++++----------- docs/images/about.png | Bin 0 -> 51529 bytes docs/images/s2ImagePatterns.png | Bin 0 -> 70389 bytes docs/sonos-s1-setup.md | 105 +++++++ docs/sonos-s2-setup.adoc | 155 +++++++++++ docs/sonos-s2-setup.md | 130 +++++++++ docs/sonos_service/images/about.png | Bin 0 -> 37496 bytes .../sonos_artwork/navidrome 112x112.png | Bin 0 -> 15704 bytes .../sonos_artwork/navidrome 200x200.png | Bin 0 -> 32676 bytes .../sonos_artwork/navidrome 20x20.png | Bin 0 -> 2149 bytes .../sonos_artwork/navidrome 400x400.png | Bin 0 -> 74360 bytes .../sonos_artwork/navidrome 40x40.png | Bin 0 -> 4229 bytes .../sonos_artwork/navidrome 80x80.png | Bin 0 -> 10359 bytes .../sonos_artwork/service_logo_200x800.svg | 18 ++ .../sonos_artwork/service_logo_20x180.svg | 19 ++ .../sonos_artwork/service_logo_40x40.svg | 20 ++ 17 files changed, 545 insertions(+), 164 deletions(-) create mode 100644 docs/images/about.png create mode 100644 docs/images/s2ImagePatterns.png create mode 100644 docs/sonos-s1-setup.md create mode 100644 docs/sonos-s2-setup.adoc create mode 100644 docs/sonos-s2-setup.md create mode 100644 docs/sonos_service/images/about.png create mode 100644 docs/sonos_service/sonos_artwork/navidrome 112x112.png create mode 100644 docs/sonos_service/sonos_artwork/navidrome 200x200.png create mode 100644 docs/sonos_service/sonos_artwork/navidrome 20x20.png create mode 100644 docs/sonos_service/sonos_artwork/navidrome 400x400.png create mode 100644 docs/sonos_service/sonos_artwork/navidrome 40x40.png create mode 100644 docs/sonos_service/sonos_artwork/navidrome 80x80.png create mode 100644 docs/sonos_service/sonos_artwork/service_logo_200x800.svg create mode 100644 docs/sonos_service/sonos_artwork/service_logo_20x180.svg create mode 100644 docs/sonos_service/sonos_artwork/service_logo_40x40.svg diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 97b6236..f8b81dd 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,6 +23,7 @@ "customizations": { "vscode": { "extensions": [ + "davidanson.vscode-markdownlint", "esbenp.prettier-vscode", "redhat.vscode-xml" ] diff --git a/README.md b/README.md index 7ea21f7..1931e22 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,9 @@ # bonob -A sonos SMAPI implementation to allow registering sources of music with sonos. +A Sonos SMAPI implementation to allow registering sources of music with Sonos. Support for Subsonic API clones (tested against Navidrome and Gonic). -![Build](https://github.com/simojenki/bonob/workflows/Build/badge.svg) ## Features @@ -14,27 +13,29 @@ Support for Subsonic API clones (tested against Navidrome and Gonic). - View Related Artists via Artist -> '...' -> Menu -> Related Arists - Now playing & Track Scrobbling - Search by Album, Artist, Track -- Playlist editing through sonos app. -- Marking of songs as favourites and with ratings through the sonos app. -- Localization (only en-US, da-DK, nl-NL & fr-FR supported currently, require translations for other languages). [Sonos localization and supported languages](https://docs.sonos.com/docs/localization) -- Auto discovery of sonos devices -- Discovery of sonos devices using seed IP address -- Auto registration with sonos on start -- Multiple registrations within a single household. +- Playlist editing through Sonos app. +- Marking of songs as favourites and with ratings through the Sonos app. - Transcoding within subsonic clone - Custom players by mime type, allowing custom transcoding rules for different file types +- Localization (only en-US, da-DK, nl-NL & fr-FR supported currently, require translations for other languages). [Sonos localization and supported languages](https://docs.sonos.com/docs/localization) +- Auto discovery of Sonos devices +- Discovery of Sonos devices using seed IP address +- Multiple registrations within a single household. +- SONOS S1 and S2 support +- Auto registration with Sonos on start for Sonos S1 devices -## Running + +## Running bonob bonob is packaged as an OCI image to both the docker hub registry and github registry. ie. ```bash -docker pull docker.io/simojenki/bonob +docker run docker.io/simojenki/bonob ``` or ```bash -docker pull ghcr.io/simojenki/bonob +docker run ghcr.io/simojenki/bonob ``` tag | description @@ -44,191 +45,74 @@ master | Lastest build from master, probably works, however is currently under t vX.Y.Z | Fixed release versions from tags, for those that want to pin to a specific release -### Full sonos device auto-discovery and auto-registration using docker --network host +## Sonos S1 vs S2 -```bash -docker run \ - -e BNB_SONOS_AUTO_REGISTER=true \ - -e BNB_SONOS_DEVICE_DISCOVERY=true \ - -p 4534:4534 \ - --network host \ - simojenki/bonob -``` +Unfortunately in May 2024 Sonos released an update to the Sonos S2 app that required bonob be exposed to the internet to continue to work on S2. S1 devices continue to work locally within youur network. There is a lengthy thread on the issue [here](https://github.com/simojenki/bonob/issues/205). -Now open http://localhost:4534 in your browser, you should see sonos devices, and service configuration. Bonob will auto-register itself with your sonos system on startup. +The tldr; is: +- If you have devices that can be down graded to Sonos S1 then you can continue to use bonob within your network without exposing anything to the internet, support for this mode of operation will continue until Sonos themselves EOL S1. +- If you have devices that cannot be downgraded to S1 then you must use S2, in which case you need to expose bonob to the internet so that it can be called by Sonos itself. Exposing services to the internet comes with additional risk, tread carefully. -### Full sonos device auto-discovery and auto-registration on custom port by using a sonos seed device, without requiring docker host networking +See below for instructions on how to set up bonob for S1 or S2. -```bash -docker run \ - -e BNB_PORT=3000 \ - -e BNB_SONOS_SEED_HOST=192.168.1.123 \ - -e BNB_SONOS_AUTO_REGISTER=true \ - -e BNB_SONOS_DEVICE_DISCOVERY=true \ - -p 3000:3000 \ - simojenki/bonob -``` - -Bonob will now auto-register itself with sonos on startup, updating the registration if the configuration has changed. Bonob should show up in the "Services" list on http://localhost:3000 - -### Running bonob on a different network to your sonos devices - -Running bonob outside of your lan will require registering your bonob install with your sonos devices from within your LAN. -If you are using bonob over the Internet, you do this at your own risk and should use TLS. - -Start bonob outside the LAN with sonos discovery & registration disabled as they are meaningless in this case, ie. - -```bash -docker run \ - -e BNB_PORT=4534 \ - -e BNB_SONOS_SERVICE_NAME=MyAwesomeMusic \ - -e BNB_SECRET=changeme \ - -e BNB_URL=https://my-server.example.com/bonob \ - -e BNB_SONOS_AUTO_REGISTER=false \ - -e BNB_SONOS_DEVICE_DISCOVERY=false \ - -e BNB_SUBSONIC_URL=https://my-navidrome-service.com:4533 \ - -p 4534:4534 \ - simojenki/bonob -``` +## Sonos S1 setup: -Now within the LAN that contains the sonos devices run bonob the registration process. +See [here](./docs/sonos-s1-setup.md) -#### Using auto-discovery -```bash -docker run \ - --rm \ - --network host \ - simojenki/bonob register https://my-server.example.com/bonob -``` +## Sonos S2 setup: -#### Using a seed host +In order to use Sonos S2 you are going to need to expose your bonob service to the internet so that Sonos can hit it. You may wish to restrict your firewall (TCP/443 only) to the Sonos IP addresses outlined [in here](https://docs.sonos.com/docs/key-requirements). -```bash -docker run \ - --rm \ - -e BNB_SONOS_SEED_HOST=192.168.1.163 \ - simojenki/bonob register https://my-server.example.com/bonob -``` +See [here](./docs/sonos-s2-setup.adoc) -### Running bonob and navidrome using docker-compose - -```yaml -version: "3" -services: - navidrome: - image: deluan/navidrome:latest - user: 1000:1000 # should be owner of volumes - ports: - - "4533:4533" - restart: unless-stopped - environment: - # Optional: put your config options customization here. Examples: - ND_SCANSCHEDULE: 1h - ND_LOGLEVEL: info - ND_SESSIONTIMEOUT: 24h - ND_BASEURL: "" - volumes: - - "/tmp/navidrome/data:/data" - - "/tmp/navidrome/music:/music:ro" - bonob: - image: simojenki/bonob:latest - user: 1000:1000 # should be owner of volumes - ports: - - "4534:4534" - restart: unless-stopped - environment: - BNB_PORT: 4534 - # ip address of your machine running bonob - BNB_URL: http://192.168.1.111:4534 - BNB_SECRET: changeme - BNB_SONOS_AUTO_REGISTER: "true" - BNB_SONOS_DEVICE_DISCOVERY: "true" - BNB_SONOS_SERVICE_ID: 246 - # ip address of one of your sonos devices - BNB_SONOS_SEED_HOST: 192.168.1.121 - BNB_SUBSONIC_URL: http://navidrome:4533 -``` - -### Running bonob on synology - -[See this issue](https://github.com/simojenki/bonob/issues/15) ## Configuration +### General configuration items + item | default value | description ---- | ------------- | ----------- BNB_PORT | 4534 | Default http port for bonob to listen on -BNB_URL | http://$(hostname):4534 | URL (including path) for bonob so that sonos devices can communicate. **This must be either the public IP or DNS entry of the bonob instance so that the sonos devices can communicate with it.** -BNB_SECRET | undefined | secret used for encrypting credentials, must be provided, make it long, make it secure -BNB_AUTH_TIMEOUT | 1h | Timeout for the sonos auth token, described in the format [ms](https://github.com/vercel/ms), ie. '5s' == 5 seconds, '11h' == 11 hours. In the case of using Navidrome this should be less than the value for ND_SESSIONTIMEOUT +BNB_URL | http://$(hostname):4534 | **S1:**

URL (including path) for bonob so that Sonos devices can communicate. This can be an IP address or hostname on your local network, it must however be accessible by your Sonos S1 devices. ie. http://192.168.1.5:4534

**S2:**

This must be the publicly available DNS entry for your bonob instance, ie. https://bonob.example.com +BNB_SECRET | undefined | Secret used for encrypting credentials, must be provided, make it long, make it secure +BNB_AUTH_TIMEOUT | 1h | Timeout for the Sonos auth token, described in the format [ms](https://github.com/vercel/ms), ie. '5s' == 5 seconds, '11h' == 11 hours. In the case of using Navidrome this should be less than the value for ND_SESSIONTIMEOUT BNB_LOG_LEVEL | info | Log level. One of ['debug', 'info', 'warn', 'error'] BNB_SERVER_LOG_REQUESTS | false | Whether or not to log http requests -BNB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register on startup -BNB_SONOS_DEVICE_DISCOVERY | true | Enable/Disable sonos device discovery entirely. Setting this to 'false' will disable sonos device search, regardless of whether a seed host is specified. -BNB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or ommitted for for auto-discovery -BNB_SONOS_SERVICE_NAME | bonob | service name for sonos -BNB_SONOS_SERVICE_ID | 246 | service id for sonos BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone -BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming.

Must specify the source mime type and optionally the transcoded mime type.

For example;

If you want to simply re-encode some flacs, then you could specify just "audio/flac".

However;

if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3"

If you want to specify many something like; "audio/flac>audio/mp3,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.

Disclaimer: Getting this configuration wrong will cause sonos to refuse to play your music, by all means experiment, however know that this may well break your setup. +BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming.

Must specify the source mime type and optionally the transcoded mime type.

For example;

If you want to simply re-encode some flacs, then you could specify just "audio/flac".

However;

if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3"

If you want to specify many something like; "audio/flac>audio/mp3,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.

Disclaimer: Getting this configuration wrong will cause Sonos to refuse to play your music, by all means experiment, however know that this may well break your setup. BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images that are sourced externally. ie. Navidrome provides spotify URLs. Remember to provide a volume-mapping for Docker, when enabling this cache. BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing -BNB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html) -BNB_ICON_BACKGROUND_COLOR | undefined | Icon background color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html) +BNB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in Sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html) +BNB_ICON_BACKGROUND_COLOR | undefined | Icon background color in Sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html) +BNB_LOGIN_THEME | classic | Theme for login page. Options are:

'classic' for the original timeless bonob login page.

'navidrome-ish' for a simplified navidrome login page.

'[@wkulhanek](https://github.com/wkulhanek)' for more 'modernized login page'. TZ | UTC | Your timezone from the [tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) ie. 'Australia/Melbourne' -## Initialising service within sonos app - -- Configure bonob, make sure to set BNB_URL. **bonob must be accessible from your sonos devices on BNB_URL, otherwise it will fail to initialise within the sonos app, so make sure you test this in your browser by putting BNB_URL in the address bar and seeing the bonob information page** -- Start bonob -- Open sonos app on your device -- Settings -> Services & Voice -> + Add a Service -- Select your Music Service, default name is 'bonob', can be overriden with configuration BNB_SONOS_SERVICE_NAME -- Press 'Add to Sonos' -> 'Linking sonos with bonob' -> Authorize -- Your device should open a browser and you should now see a login screen, enter your subsonic clone credentials -- You should get 'Login successful!' -- Go back into the sonos app and complete the process -- You should now be able to play music on your sonos devices from you subsonic clone -- Within the subsonic clone a new player will be created, 'bonob (username)', so you can configure transcoding specifically for sonos - -## Re-registering your bonob service with sonos App - -Generally speaking you will not need to do this very often. However on occassion bonob will change the implementation of the authentication between sonos and bonob, which will require a re-registration. Your sonos app will complain about not being able to browse the service, to re-register execute the following steps (taken from the iOS app); - -- Open the sonos app -- Settings -> Services & Voice -- Your bonob service, will likely have name of either 'bonob' or $BNB_SONOS_SERVICE_NAME -- Reauthorize Account -- Authorize -- Enter credentials, you should see 'Login Successful!' -- Done -Service should now be registered and everything should work as expected. +### Additional configuration for S1 setups. -## Multiple registrations within a single household. - -It's possible to register multiple Subsonic clone users for the bonob service in Sonos. -Basically this consist of repeating the Sonos app ["Add a service"](#initialising-service-within-sonos-app) steps for each additional user. -Afterwards the Sonos app displays a dropdown underneath the service, allowing to switch between users. - -## Implementing a different music source other than a subsonic clone +item | default value | description +---- | ------------- | ----------- +BNB_SONOS_DEVICE_DISCOVERY | true | Enable/Disable Sonos device discovery entirely. Setting this to 'false' will disable Sonos device search, regardless of whether a seed host is specified. +BNB_SONOS_SEED_HOST | undefined | Sonos device seed host for discovery, or ommitted for for auto-discovery +BNB_SONOS_SERVICE_NAME | bonob | S1 service name for Sonos, doesn't seem to apply for S2 setups +BNB_SONOS_SERVICE_ID | 246 | service id for Sonos +BNB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register with S1 devices on startup. **For S2 ensure that this is false.** -- Implement the MusicService/MusicLibrary interface -- Startup bonob with your new implementation. ## Transcoding ### Transcode everything -The simplest transcoding solution is to simply change the player ('bonob') in your subsonic server to transcode all content to something sonos supports (ie. mp3 & flac) +The simplest transcoding solution is to simply change the player ('bonob') in your subsonic server to transcode all content to something Sonos supports (ie. mp3 & flac) ### Audio file type specific transcoding -Disclaimer: The following configuration is more complicated, and if you get the configuration wrong sonos will refuse to play your content. +Disclaimer: The following configuration is more complicated, and if you get the configuration wrong Sonos will refuse to play your content. -In some situations you may wish to have different 'Players' within your Subsonic server so that you can configure different transcoding options depending on the file type. For example if you have flacs with a mixture of frequency formats where not all are supported by sonos [See issue #52](https://github.com/simojenki/bonob/issues/52) & [Sonos supported audio formats](https://docs.sonos.com/docs/supported-audio-formats) +In some situations you may wish to have different 'Players' within your Subsonic server so that you can configure different transcoding options depending on the file type. For example if you have flacs with a mixture of frequency formats where not all are supported by Sonos [See issue #52](https://github.com/simojenki/bonob/issues/52) & [Sonos supported audio formats](https://docs.sonos.com/docs/supported-audio-formats) In this case you could set; @@ -237,7 +121,7 @@ In this case you could set; BNB_SUBSONIC_CUSTOM_CLIENTS="audio/flac" ``` -This would result in 2 players in Navidrome, one called 'bonob', the other called 'bonob+audio/flac'. You could then configure a custom flac transcoder in Navidrome that re-samples the flacs to a sonos supported format, ie [Using something like this](https://stackoverflow.com/questions/41420391/ffmpeg-flac-24-bit-96khz-to-16-bit-48khz) or [this](https://stackoverflow.com/questions/52119489/ffmpeg-limit-audio-sample-rate): +This would result in 2 players in Navidrome, one called 'bonob', the other called 'bonob+audio/flac'. You could then configure a custom flac transcoder in Navidrome that re-samples the flacs to a Sonos supported format, ie [Using something like this](https://stackoverflow.com/questions/41420391/ffmpeg-flac-24-bit-96khz-to-16-bit-48khz) or [this](https://stackoverflow.com/questions/52119489/ffmpeg-limit-audio-sample-rate): ```bash ffmpeg -i %s -af aformat=sample_fmts=s16|s32:sample_rates=8000|11025|16000|22050|24000|32000|44100|48000 -f flac - @@ -249,7 +133,7 @@ ffmpeg -i %s -af aformat=sample_fmts=s16|s32:sample_rates=8000|11025|16000|22050 ffmpeg -i %s -af aformat=sample_fmts=s16:sample_rates=8000|11025|16000|22050|24000|32000|44100|48000 -f flac - ``` -Alternatively perhaps you have some aac (audio/mpeg) files that will not play in sonos (ie. voice recordings from an iPhone), however you do not want to transcode all everything, just those audio/mpeg files. Let's say you want to transcode them to mp3s, you could set the following; +Alternatively perhaps you have some aac (audio/mpeg) files that will not play in Sonos (ie. voice recordings from an iPhone), however you do not want to transcode all everything, just those audio/mpeg files. Let's say you want to transcode them to mp3s, you could set the following; ```bash BNB_SUBSONIC_CUSTOM_CLIENTS="audio/mpeg>audio/mp3" @@ -289,10 +173,59 @@ And then configure the 'bonob+audio/mpeg' player in your subsonic server. ![Spotify-ish](https://github.com/simojenki/bonob/blob/master/docs/images/spotify-ish.png?raw=true) -## Notes on using Cloudflare/cloudflared tunnels. +## Notes on running bonob with various integrations: + +### Running bonob and navidrome using docker-compose + +```yaml +version: "3" +services: + navidrome: + image: deluan/navidrome:latest + user: 1000:1000 # should be owner of volumes + ports: + - "4533:4533" + restart: unless-stopped + environment: + # Optional: put your config options customization here. Examples: + ND_SCANSCHEDULE: 1h + ND_LOGLEVEL: info + ND_SESSIONTIMEOUT: 24h + ND_BASEURL: "" + volumes: + - "/tmp/navidrome/data:/data" + - "/tmp/navidrome/music:/music:ro" + bonob: + image: simojenki/bonob:latest + user: 1000:1000 # should be owner of volumes + ports: + - "4534:4534" + restart: unless-stopped + environment: + BNB_PORT: 4534 + # ip address of your machine running bonob + BNB_URL: http://192.168.1.111:4534 + BNB_SECRET: changeme + BNB_SONOS_AUTO_REGISTER: "true" + BNB_SONOS_DEVICE_DISCOVERY: "true" + BNB_SONOS_SERVICE_ID: 246 + # ip address of one of your sonos devices + BNB_SONOS_SEED_HOST: 192.168.1.121 + BNB_SUBSONIC_URL: http://navidrome:4533 +``` + + +### Running bonob on synology + +[See this issue](https://github.com/simojenki/bonob/issues/15) + + +### Running bonob behind Cloudflare/cloudflared tunnels. As discussed [here](https://github.com/simojenki/bonob/issues/101#issuecomment-1471635855) and [here](https://github.com/simojenki/bonob/issues/205#issuecomment-3461453809), there is an issue playing tracks via cloudflare. Until otherwise resolved the current 'solution' is to "disable CF proxy feature and leave DNS-only for bonob.example.com record". (Note you may need to wait some time for DNS caches to propogate) ## Credits -- Icons courtesy of: [Navidrome](https://www.navidrome.org/), [Vectornator](https://www.vectornator.io/icons), and @jicho +- Icons courtesy of [Navidrome](https://www.navidrome.org/), [Vectornator](https://www.vectornator.io/icons), and [@jicho](https://github.com/jicho) +- Sonos S2 setup documentation and navidrome images courtesy of [@wkulhanek](https://github.com/wkulhanek) +- Sonos S2 support courtest of everyone involved with issue [205](https://github.com/simojenki/bonob/issues/205) diff --git a/docs/images/about.png b/docs/images/about.png new file mode 100644 index 0000000000000000000000000000000000000000..efef817bb592bcc17f781d5876aac1e0059d895a GIT binary patch literal 51529 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 Hf$#qYi$bw$ literal 0 HcmV?d00001 diff --git a/docs/images/s2ImagePatterns.png b/docs/images/s2ImagePatterns.png new file mode 100644 index 0000000000000000000000000000000000000000..49979fa874bae7a46ed9bcd92e18e5f18dc36fb5 GIT binary patch literal 70389 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%0Av4u;(Nbnu3h-_LmJ!OTY=85Vj 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 Z^5t=VMvxEa!t*Xd>XF>T{0Glp{||;@N)P}5 literal 0 HcmV?d00001 diff --git a/docs/sonos-s1-setup.md b/docs/sonos-s1-setup.md new file mode 100644 index 0000000..939cac0 --- /dev/null +++ b/docs/sonos-s1-setup.md @@ -0,0 +1,105 @@ +# Sonos S1 setup: + +## Running bonob itself + +### Full Sonos device auto-discovery and auto-registration using docker --network host + +```bash +docker run \ + -e BNB_SONOS_AUTO_REGISTER=true \ + -e BNB_SONOS_DEVICE_DISCOVERY=true \ + -p 4534:4534 \ + --network host \ + simojenki/bonob +``` + +Now open http://localhost:4534 in your browser, you should see Sonos devices, and service configuration. Bonob will auto-register itself with your Sonos system on startup. + +### Full Sonos device auto-discovery and auto-registration on custom port by using a Sonos seed device, without requiring docker host networking + +```bash +docker run \ + -e BNB_PORT=3000 \ + -e BNB_SONOS_SEED_HOST=192.168.1.123 \ + -e BNB_SONOS_AUTO_REGISTER=true \ + -e BNB_SONOS_DEVICE_DISCOVERY=true \ + -p 3000:3000 \ + simojenki/bonob +``` + +Bonob will now auto-register itself with Sonos on startup, updating the registration if the configuration has changed. Bonob should show up in the "Services" list on http://localhost:3000 + +### Running bonob on a different network to your Sonos devices + +Running bonob outside of your lan will require registering your bonob install with your Sonos devices from within your LAN. + +If you are using bonob over the Internet, you do this at your own risk and should use TLS. + +Start bonob outside the LAN with Sonos discovery & registration disabled as they are meaningless in this case, ie. + +```bash +docker run \ + -e BNB_PORT=4534 \ + -e BNB_SONOS_SERVICE_NAME=MyAwesomeMusic \ + -e BNB_SECRET=changeme \ + -e BNB_URL=https://my-server.example.com/bonob \ + -e BNB_SONOS_AUTO_REGISTER=false \ + -e BNB_SONOS_DEVICE_DISCOVERY=false \ + -e BNB_SUBSONIC_URL=https://my-navidrome-service.com:4533 \ + -p 4534:4534 \ + simojenki/bonob +``` + +Now within the LAN that contains the Sonos devices run bonob the registration process. + +### Using auto-discovery + +```bash +docker run \ + --rm \ + --network host \ + simojenki/bonob register https://my-server.example.com/bonob +``` + +### Using a seed host + +```bash +docker run \ + --rm \ + -e BNB_SONOS_SEED_HOST=192.168.1.163 \ + simojenki/bonob register https://my-server.example.com/bonob +``` + +## Initialising service within Sonos app + +- Configure bonob, make sure to set BNB_URL. **bonob must be accessible from your Sonos devices on BNB_URL, otherwise it will fail to initialise within the Sonos app, so make sure you test this in your browser by putting BNB_URL in the address bar and seeing the bonob information page** +- Start bonob +- Open Sonos app on your device +- Settings -> Services & Voice -> + Add a Service +- Select your Music Service, default name is 'bonob', can be overriden with configuration BNB_SONOS_SERVICE_NAME +- Press 'Add to Sonos' -> 'Linking Sonos with bonob' -> Authorize +- Your device should open a browser and you should now see a login screen, enter your subsonic clone credentials +- You should get 'Login successful!' +- Go back into the Sonos app and complete the process +- You should now be able to play music on your Sonos devices from you subsonic clone +- Within the subsonic clone a new player will be created, 'bonob (username)', so you can configure transcoding specifically for Sonos + +## Re-registering your bonob service with Sonos App + +Generally speaking you will not need to do this very often. However on occassion bonob will change the implementation of the authentication between Sonos and bonob, which will require a re-registration. Your Sonos app will complain about not being able to browse the service, to re-register execute the following steps (taken from the iOS app); + +- Open the Sonos app +- Settings -> Services & Voice +- Your bonob service, will likely have name of either 'bonob' or $BNB_SONOS_SERVICE_NAME +- Reauthorize Account +- Authorize +- Enter credentials, you should see 'Login Successful!' +- Done + +Service should now be registered and everything should work as expected. + +## Multiple registrations within a single household. + +It's possible to register multiple Subsonic clone users for the bonob service in Sonos. +Basically this consist of repeating the Sonos app ["Add a service"](#initialising-service-within-sonos-app) steps for each additional user. +Afterwards the Sonos app displays a dropdown underneath the service, allowing to switch between users. diff --git a/docs/sonos-s2-setup.adoc b/docs/sonos-s2-setup.adoc new file mode 100644 index 0000000..813e2de --- /dev/null +++ b/docs/sonos-s2-setup.adoc @@ -0,0 +1,155 @@ +ifdef::env-github[] +:imagesdir: https://github.com/simojenki/bonob/blob/feature/s80docs/docs/images +endif::[] + += Setting up Sonos Service + +Credit goes to https://github.com/wkulhanek[@wkulhanek] for writing up these instructions and providing the navidrome artwork. + +== Prerequisites +* In your Sonos App get your Sonos ID (About my Sonos System) ++ +image::about.png[] + +* Navidrome running and available from the server that Bonob is running on. This can be a public URL like https://music.mydomain.com or just a local URL like http://192.168.1.100:4533. +* Bonob running and available from the Internet. E.g. via https://bonob.mydomain.com + +You can use any method to make these URLs available. Cloudflare Tunnels, Pangolin, reverse proxy, etc. + +== Sonos Service Integration + +* Log into https://play.sonos.com +* Once logged in go to https://developer.sonos.com/s/integrations + +* Create a *New Content Integration* + +** General Information +*** Service Name: Navidrome +*** Service Availability: Global +*** Checkbox checked +*** Website/Social Media URLs: https://music.mydomain.com (Some URL - e.g. your Navidrome server). This has to be a valid URL. + +** Sonos Music API +*** Integration ID: com.mydomain.music (your domain in reverse) +*** Configuration Label: 1.0 +*** SMAPI Endpoint: https://bonob.mydomain.com/ws/sonos +*** SMAPI Endpoint Version: 1.1 +*** Radio Endpoint: empty +*** Reporting Endpoint: https://bonob.mydomain.com/report +*** Reporting Endpoint Version: 2.1 +*** Authentication Method: OAuth +*** Redirect: https://bonob.mydomain.com/login +*** Auth Token Time To Life: Empty +*** Browse/Search Results Page Size: 100 +*** Polling Interval: 60 + +** Brand Assets + +*** Just upload the various assets from the `docs/sonos_service/sonos_artwork` directory. + +** Localization Resources + +*** Write something about your service in the various fields (except Explicit Filter Description). + +** Integration Capabilities + +*** Check the first two (*Enable Extended Metadata* and *Enable Extended Metadata for Playlists*) and nothing else. + +** Image Replacement Rules + +*** Pattern: \/size\/(?\d+) +*** Name: 60 +*** Replacement Text: /size/60.png +*** Minimum & Maximum Scales: Empty +*** Add Replacement Rule +*** Name: 80 +*** Replacement Text: /size/80.png +*** Minimum & Maximum Scales: Empty + +Should look like this: +image::s2ImagePatterns.png[Example Image Replacement Rules] + +Repeat for the following resolutions; 60,80,120,180,192,200,230,300,600,640,750,1000,1242,1500 + +json in the Service Configuration should look like this. + +[source,json] +---- +"image-replacement-rules" : { + "pattern" : "\\/size\\/(?\\d+)", + "replacements" : [ { + "name" : "60", + "replacement" : "/size/60.png" + }, { + "name" : "80", + "replacement" : "/size/80.png" + }, { + "name" : "120", + "replacement" : "/size/120.png" + }, { + "name" : "180", + "replacement" : "/size/180.png" + }, { + "name" : "192", + "replacement" : "/size/192.png" + }, { + "name" : "200", + "replacement" : "/size/200.png" + }, { + "name" : "230", + "replacement" : "/size/230.png" + }, { + "name" : "300", + "replacement" : "/size/300.png" + }, { + "name" : "600", + "replacement" : "/size/600.png" + }, { + "name" : "640", + "replacement" : "/size/640.png" + }, { + "name" : "750", + "replacement" : "/size/750.png" + }, { + "name" : "1000", + "replacement" : "/size/1000.png" + }, { + "name" : "1242", + "replacement" : "/size/1242.png" + }, { + "name" : "1500", + "replacement" : "/size/1500.png" + } ] +}, +---- + +** Browse Options + +*** No changes + +** Search Capabilities + +*** API Catalog Type: SMAPI Catalog +*** Catalog Title: Music +*** Catalog Type: GLOBAL + +*** Add Three Categories with ID and Mapped ID: ++ +Albums - albums +Artists - artists +Tracks - tracks + +** Content Actions + +*** No changes + +** Service Deployment Settings + +*** Sonos ID: Your Sonos ID (Sonos S2 app -> System Settings -> Manage -> About your system -> "Sonos ID"). This is how only your controller sees the new service. +*** System Name: Whatever you want + +** Service Configuration + +*** Click on *Refresh* and then *Send*. You should get a success message that you can dismiss with *Done*. + +* In your app search for your service name and add Service in your app as usual. diff --git a/docs/sonos-s2-setup.md b/docs/sonos-s2-setup.md new file mode 100644 index 0000000..5f3c281 --- /dev/null +++ b/docs/sonos-s2-setup.md @@ -0,0 +1,130 @@ +# Setting up Sonos Service + +Credit goes to [@wkulhanek](https://github.com/wkulhanek) for writing up these instructions and providing the navidrome artwork. + +## Prerequisites + +* In your Sonos App get your Sonos ID (About my Sonos System) + +![about](images/about.png) + +* Navidrome running and available from the server that Bonob is running on. This can be a public URL like ```https://music.mydomain.com``` or a local IP like ```http://192.168.1.100:4533```. + +* Bonob running and available from the Internet. E.g. via ```https://bonob.mydomain.com``` + +You can use any method to make these URLs available. Cloudflare Tunnels, Pangolin, reverse proxy, etc. + +## Sonos Service Integration + +* Log into [https://play.sonos.com](https://play.sonos.com) +* Once logged in go to [https://developer.sonos.com/s/integrations](https://developer.sonos.com/s/integrations) +* Create a **New Content Integration** + * General Information + * Service Name: Navidrome + * Service Availability: Global + * Checkbox checked + * Website/Social Media URLs: ```https://music.mydomain.com``` (Some URL - e.g. your Navidrome server). This has to be a valid URL. + * Sonos Music API + * Integration ID: com.mydomain.music (your domain in reverse) + * Configuration Label: 1.0 + * SMAPI Endpoint: ```https://bonob.mydomain.com/ws/sonos``` + * SMAPI Endpoint Version: 1.1 + * Radio Endpoint: empty + * Reporting Endpoint: ```https://bonob.mydomain.com/report``` + * Reporting Endpoint Version: 2.1 + * Authentication Method: OAuth + * Redirect: ```https://bonob.mydomain.com/login``` + * Auth Token Time To Life: Empty + * Browse/Search Results Page Size: 100 + * Polling Interval: 60 + * Brand Assets + * Just upload the various assets from the `docs/sonos_service/sonos_artwork` directory. + * Localization Resources + * Write something about your service in the various fields (except Explicit Filter Description). + * Integration Capabilities + * Check the first two (**Enable Extended Metadata** and **Enable Extended Metadata for Playlists**) and nothing else. + * Image Replacement Rules + * Pattern: \/size\/(?<size>\d+) + * Name: 60 + * Replacement Text: /size/60.png + * Minimum & Maximum Scales: Empty + * Add Replacement Rule + * Name: 80 + * Replacement Text: /size/80.png + * Minimum & Maximum Scales: Empty + +Should look like this: +![Example Image Replacement Rules](images/s2ImagePatterns.png) + +Repeat for the following resolutions; 60,80,120,180,192,200,230,300,600,640,750,1000,1242,1500 + +json in the Service Configuration should look like this. + +```json +"image-replacement-rules" : { + "pattern" : "\\/size\\/(?\\d+)", + "replacements" : [ { + "name" : "60", + "replacement" : "/size/60.png" + }, { + "name" : "80", + "replacement" : "/size/80.png" + }, { + "name" : "120", + "replacement" : "/size/120.png" + }, { + "name" : "180", + "replacement" : "/size/180.png" + }, { + "name" : "192", + "replacement" : "/size/192.png" + }, { + "name" : "200", + "replacement" : "/size/200.png" + }, { + "name" : "230", + "replacement" : "/size/230.png" + }, { + "name" : "300", + "replacement" : "/size/300.png" + }, { + "name" : "600", + "replacement" : "/size/600.png" + }, { + "name" : "640", + "replacement" : "/size/640.png" + }, { + "name" : "750", + "replacement" : "/size/750.png" + }, { + "name" : "1000", + "replacement" : "/size/1000.png" + }, { + "name" : "1242", + "replacement" : "/size/1242.png" + }, { + "name" : "1500", + "replacement" : "/size/1500.png" + } ] +}, +``` + +* Browse Options + * No changes +* Search Capabilities + * API Catalog Type: SMAPI Catalog + * Catalog Title: Music + * Catalog Type: GLOBAL + * Add Three Categories with ID and Mapped ID: + + Albums - albums + Artists - artists + Tracks - tracks +* Content Actions + * No changes +* Service Deployment Settings + * Sonos ID: Your Sonos ID (Sonos S2 app -> System Settings -> Manage -> About your system -> "Sonos ID"). This is how only your controller sees the new service. + * System Name: Whatever you want +* Service Configuration + * Click on **Refresh** and then **Send**. You should get a success message that you can dismiss with **Done**. + * In your app search for your service name and add Service in your app as usual. diff --git a/docs/sonos_service/images/about.png b/docs/sonos_service/images/about.png new file mode 100644 index 0000000000000000000000000000000000000000..7063b99aa0a47cc667c66d2f157ac1d16b601d83 GIT binary patch literal 37496 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 JPDHLkV1gyaa)|%{ literal 0 HcmV?d00001 diff --git a/docs/sonos_service/sonos_artwork/navidrome 112x112.png b/docs/sonos_service/sonos_artwork/navidrome 112x112.png new file mode 100644 index 0000000000000000000000000000000000000000..ef6096cfb18166f2e84cceec8a00848a7a4b9599 GIT binary patch literal 15704 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 O!V)5~!j<2C`28PeMKy~6 literal 0 HcmV?d00001 diff --git a/docs/sonos_service/sonos_artwork/navidrome 200x200.png b/docs/sonos_service/sonos_artwork/navidrome 200x200.png new file mode 100644 index 0000000000000000000000000000000000000000..3a9299f8f0c24c37dc2a0add252cb6dd059f2a7c GIT binary patch literal 32676 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

o!<{69UwKN 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ϕMyP5mY9ATitRkzEfUz1_dlv(K4=98uNIjTk6N4%0nfR zz_PWB{VrkpuRM=Z3dj&8loS|<0`f+(Op3~68qZ%0o&f#{X#YtyBumCH5|5>G^}FT$ z&`}@>de1^q0`V>$@8NVEcs+P4SX3I8zvX6mrZBiHp`<{hKv}6vPS_j@C`d#FNm34e zXA5?JP(^YqXb1c(uo`reD1hX*(n_hOPV??n^kSlQQUJvm9^>XN@J3K8<8F{K#uc!P zEZ6F?uDnoQloUu5C>2PeM0wsE3M2*KISH%ccRO-(Ab4bs2Xo#ItOgZ|-y4oXPNh;p z?G#95;8_}`c^Acy?C@4e{2KfvcssZVEJ9N1Djvfst&Jje%IiTXP%4l?NmKr@85Afg zl!4qFDimE@M}bFx2Y~jcRD%L5SvH25(DzCnjRIX7C6q2n=zCp+#oV}b$Zeo5sXM^= zU=gILires17=qU&loY6m0%hf_iCpE&o1j2ZsHM?8#ZP8)V+Z`cpq7dXWiN0ma3B~+ zz{#@-{bsoHa1z{}6Phm}QJ z4@(Mc1_g?OGQF-K9Z0At^i(R&JDCmc4r-;$0e1ywf>XiGfuv;8yGe+r`^5j7I{SY0 zx9L9RA>I3KF+7!Iy7#}0ft}eZ3YEeF@Bz@7-&z^-!N;(Eep$cXSSQM(V}3 zPW-tk#bRi_tI}Xssj!KbqrX%r(?J!AE;0LBy!Pz9@4I}9uCjFYI-$?Bh;$Jt&_!Kw zp0xy4fR1t!y-xQ}bIoQTndjwI=sPRHUT|YzevG?F1C@oeD50c4M^NDZ1JNDvn1!@y Q9RL6T07*qoM6N<$f=fgYX#fBK literal 0 HcmV?d00001 diff --git a/docs/sonos_service/sonos_artwork/navidrome 40x40.png b/docs/sonos_service/sonos_artwork/navidrome 40x40.png new file mode 100644 index 0000000000000000000000000000000000000000..f79ff00a947ced596059bebae9871ae8d0bdad61 GIT binary patch literal 4229 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 L3Sw0v1_A#MztJKL literal 0 HcmV?d00001 diff --git a/docs/sonos_service/sonos_artwork/service_logo_200x800.svg b/docs/sonos_service/sonos_artwork/service_logo_200x800.svg new file mode 100644 index 0000000..630956b --- /dev/null +++ b/docs/sonos_service/sonos_artwork/service_logo_200x800.svg @@ -0,0 +1,18 @@ + + Bonob Subsonic Logo + A blue music-note icon with family dots on the left, followed by stacked text Bonob Navidrome in blue. + + + + + + + + + + + + + Bonob + Navidrome + diff --git a/docs/sonos_service/sonos_artwork/service_logo_20x180.svg b/docs/sonos_service/sonos_artwork/service_logo_20x180.svg new file mode 100644 index 0000000..244e322 --- /dev/null +++ b/docs/sonos_service/sonos_artwork/service_logo_20x180.svg @@ -0,0 +1,19 @@ + + Bonob Subsonic Logo + A blue music-note icon with family dots, followed by the text Bonob Subsonic in blue. + + + + + + + + + + + + + + Bonob Subsonic + + diff --git a/docs/sonos_service/sonos_artwork/service_logo_40x40.svg b/docs/sonos_service/sonos_artwork/service_logo_40x40.svg new file mode 100644 index 0000000..1dc9327 --- /dev/null +++ b/docs/sonos_service/sonos_artwork/service_logo_40x40.svg @@ -0,0 +1,20 @@ + + Bonob Navidrome Music Server + Blue rounded square with a white music note and two small circles representing family. + + + + + + + + + + + + + + + + + From 05e63717ef4802c244d0bd05fa5d31189cfa35ae Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 7 Feb 2026 16:31:16 +1100 Subject: [PATCH 21/51] Fix incorrect documentation --- README.md | 2 +- docs/sonos-s2-setup.adoc | 155 --------------------------------------- 2 files changed, 1 insertion(+), 156 deletions(-) delete mode 100644 docs/sonos-s2-setup.adoc diff --git a/README.md b/README.md index 1931e22..a5fc9ab 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ See [here](./docs/sonos-s1-setup.md) In order to use Sonos S2 you are going to need to expose your bonob service to the internet so that Sonos can hit it. You may wish to restrict your firewall (TCP/443 only) to the Sonos IP addresses outlined [in here](https://docs.sonos.com/docs/key-requirements). -See [here](./docs/sonos-s2-setup.adoc) +See [here](./docs/sonos-s2-setup.md) ## Configuration diff --git a/docs/sonos-s2-setup.adoc b/docs/sonos-s2-setup.adoc deleted file mode 100644 index 813e2de..0000000 --- a/docs/sonos-s2-setup.adoc +++ /dev/null @@ -1,155 +0,0 @@ -ifdef::env-github[] -:imagesdir: https://github.com/simojenki/bonob/blob/feature/s80docs/docs/images -endif::[] - -= Setting up Sonos Service - -Credit goes to https://github.com/wkulhanek[@wkulhanek] for writing up these instructions and providing the navidrome artwork. - -== Prerequisites -* In your Sonos App get your Sonos ID (About my Sonos System) -+ -image::about.png[] - -* Navidrome running and available from the server that Bonob is running on. This can be a public URL like https://music.mydomain.com or just a local URL like http://192.168.1.100:4533. -* Bonob running and available from the Internet. E.g. via https://bonob.mydomain.com - -You can use any method to make these URLs available. Cloudflare Tunnels, Pangolin, reverse proxy, etc. - -== Sonos Service Integration - -* Log into https://play.sonos.com -* Once logged in go to https://developer.sonos.com/s/integrations - -* Create a *New Content Integration* - -** General Information -*** Service Name: Navidrome -*** Service Availability: Global -*** Checkbox checked -*** Website/Social Media URLs: https://music.mydomain.com (Some URL - e.g. your Navidrome server). This has to be a valid URL. - -** Sonos Music API -*** Integration ID: com.mydomain.music (your domain in reverse) -*** Configuration Label: 1.0 -*** SMAPI Endpoint: https://bonob.mydomain.com/ws/sonos -*** SMAPI Endpoint Version: 1.1 -*** Radio Endpoint: empty -*** Reporting Endpoint: https://bonob.mydomain.com/report -*** Reporting Endpoint Version: 2.1 -*** Authentication Method: OAuth -*** Redirect: https://bonob.mydomain.com/login -*** Auth Token Time To Life: Empty -*** Browse/Search Results Page Size: 100 -*** Polling Interval: 60 - -** Brand Assets - -*** Just upload the various assets from the `docs/sonos_service/sonos_artwork` directory. - -** Localization Resources - -*** Write something about your service in the various fields (except Explicit Filter Description). - -** Integration Capabilities - -*** Check the first two (*Enable Extended Metadata* and *Enable Extended Metadata for Playlists*) and nothing else. - -** Image Replacement Rules - -*** Pattern: \/size\/(?\d+) -*** Name: 60 -*** Replacement Text: /size/60.png -*** Minimum & Maximum Scales: Empty -*** Add Replacement Rule -*** Name: 80 -*** Replacement Text: /size/80.png -*** Minimum & Maximum Scales: Empty - -Should look like this: -image::s2ImagePatterns.png[Example Image Replacement Rules] - -Repeat for the following resolutions; 60,80,120,180,192,200,230,300,600,640,750,1000,1242,1500 - -json in the Service Configuration should look like this. - -[source,json] ----- -"image-replacement-rules" : { - "pattern" : "\\/size\\/(?\\d+)", - "replacements" : [ { - "name" : "60", - "replacement" : "/size/60.png" - }, { - "name" : "80", - "replacement" : "/size/80.png" - }, { - "name" : "120", - "replacement" : "/size/120.png" - }, { - "name" : "180", - "replacement" : "/size/180.png" - }, { - "name" : "192", - "replacement" : "/size/192.png" - }, { - "name" : "200", - "replacement" : "/size/200.png" - }, { - "name" : "230", - "replacement" : "/size/230.png" - }, { - "name" : "300", - "replacement" : "/size/300.png" - }, { - "name" : "600", - "replacement" : "/size/600.png" - }, { - "name" : "640", - "replacement" : "/size/640.png" - }, { - "name" : "750", - "replacement" : "/size/750.png" - }, { - "name" : "1000", - "replacement" : "/size/1000.png" - }, { - "name" : "1242", - "replacement" : "/size/1242.png" - }, { - "name" : "1500", - "replacement" : "/size/1500.png" - } ] -}, ----- - -** Browse Options - -*** No changes - -** Search Capabilities - -*** API Catalog Type: SMAPI Catalog -*** Catalog Title: Music -*** Catalog Type: GLOBAL - -*** Add Three Categories with ID and Mapped ID: -+ -Albums - albums -Artists - artists -Tracks - tracks - -** Content Actions - -*** No changes - -** Service Deployment Settings - -*** Sonos ID: Your Sonos ID (Sonos S2 app -> System Settings -> Manage -> About your system -> "Sonos ID"). This is how only your controller sees the new service. -*** System Name: Whatever you want - -** Service Configuration - -*** Click on *Refresh* and then *Send*. You should get a success message that you can dismiss with *Done*. - -* In your app search for your service name and add Service in your app as usual. From a2e3633adc97a568ea1d4c4647e6c687f99a01ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 16:56:11 +1100 Subject: [PATCH 22/51] Bump jws (#242) Bumps [jws](https://github.com/brianloveswords/node-jws) to 3.2.3 and updates ancestor dependency . These dependencies need to be updated together. Updates `jws` from 3.2.2 to 3.2.3 - [Release notes](https://github.com/brianloveswords/node-jws/releases) - [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md) - [Commits](https://github.com/brianloveswords/node-jws/compare/v3.2.2...v3.2.3) Updates `jws` from 4.0.0 to 4.0.1 - [Release notes](https://github.com/brianloveswords/node-jws/releases) - [Changelog](https://github.com/auth0/node-jws/blob/master/CHANGELOG.md) - [Commits](https://github.com/brianloveswords/node-jws/compare/v3.2.2...v3.2.3) --- updated-dependencies: - dependency-name: jws dependency-version: 3.2.3 dependency-type: indirect - dependency-name: jws dependency-version: 4.0.1 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 37 ++++++++++++++++++++----------------- package.json | 2 +- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/package-lock.json b/package-lock.json index e5bb124..7d9eefd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,7 +28,7 @@ "fp-ts": "^2.16.11", "fs-extra": "^11.3.2", "jsonwebtoken": "^9.0.2", - "jws": "^4.0.0", + "jws": "^4.0.1", "morgan": "^1.10.1", "node-html-parser": "^7.0.1", "randomstring": "^1.3.1", @@ -5421,21 +5421,23 @@ } }, "node_modules/jsonwebtoken/node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", + "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", + "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jsonwebtoken/node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", + "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", + "license": "MIT", "dependencies": { - "jwa": "^1.4.1", + "jwa": "^1.4.2", "safe-buffer": "^5.0.1" } }, @@ -5470,22 +5472,23 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/jwa": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", - "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", "dependencies": { - "buffer-equal-constant-time": "1.0.1", + "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", "license": "MIT", "dependencies": { - "jwa": "^2.0.0", + "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, diff --git a/package.json b/package.json index 6d0be26..ab11cba 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "fp-ts": "^2.16.11", "fs-extra": "^11.3.2", "jsonwebtoken": "^9.0.2", - "jws": "^4.0.0", + "jws": "^4.0.1", "morgan": "^1.10.1", "node-html-parser": "^7.0.1", "randomstring": "^1.3.1", From 0fc1c3738fbd266a3bbd58d4e8ddea65816c46df Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:13:55 +1100 Subject: [PATCH 23/51] Bump qs from 6.14.0 to 6.14.1 (#243) Bumps [qs](https://github.com/ljharb/qs) from 6.14.0 to 6.14.1. - [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/qs/compare/v6.14.0...v6.14.1) --- updated-dependencies: - dependency-name: qs dependency-version: 6.14.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d9eefd..f64066a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6681,9 +6681,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", - "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" From f311faedcb93ccff197a51d5a1035c4c30af9d47 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:14:28 +1100 Subject: [PATCH 24/51] Bump lodash from 4.17.21 to 4.17.23 (#244) Bumps [lodash](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23) --- updated-dependencies: - dependency-name: lodash dependency-version: 4.17.23 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f64066a..7d04b9c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5528,9 +5528,10 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "license": "MIT" }, "node_modules/lodash.includes": { "version": "4.3.0", From 1055e4455ebb962d4753a5ff9e1928becc8391e2 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 10:38:16 +1100 Subject: [PATCH 25/51] Bump axios from 1.13.1 to 1.13.5 (#247) --- package-lock.json | 24 ++++++++++++------------ package.json | 2 +- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7d04b9c..665218d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@types/uuid": "^10.0.0", "@types/xmldom": "^0.1.34", "@xmldom/xmldom": "^0.9.7", - "axios": "^1.13.1", + "axios": "^1.13.5", "dayjs": "^1.11.19", "eta": "^2.2.0", "express": "^5.1.0", @@ -2487,13 +2487,13 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", - "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.6", - "form-data": "^4.0.4", + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", "proxy-from-env": "^1.1.0" } }, @@ -3883,9 +3883,9 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -3933,9 +3933,9 @@ } }, "node_modules/form-data": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", - "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", diff --git a/package.json b/package.json index ab11cba..d3d93b6 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@types/uuid": "^10.0.0", "@types/xmldom": "^0.1.34", "@xmldom/xmldom": "^0.9.7", - "axios": "^1.13.1", + "axios": "^1.13.5", "dayjs": "^1.11.19", "eta": "^2.2.0", "express": "^5.1.0", From 4f1072f0195b90939d7800b01cd0cb41a36a045f Mon Sep 17 00:00:00 2001 From: Simon J Date: Thu, 12 Feb 2026 10:41:59 +1100 Subject: [PATCH 26/51] Update README.md Correct documentation around the use of cloudflared --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a5fc9ab..e8ac1eb 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ services: ### Running bonob behind Cloudflare/cloudflared tunnels. -As discussed [here](https://github.com/simojenki/bonob/issues/101#issuecomment-1471635855) and [here](https://github.com/simojenki/bonob/issues/205#issuecomment-3461453809), there is an issue playing tracks via cloudflare. Until otherwise resolved the current 'solution' is to "disable CF proxy feature and leave DNS-only for bonob.example.com record". (Note you may need to wait some time for DNS caches to propogate) +This was an issue, however running bonob behind cloudflared should now work. ## Credits From c7e597557dd0e081ac2633a92fe859187c4918ad Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 15 Feb 2026 08:39:41 +1100 Subject: [PATCH 27/51] Bump qs from 6.14.1 to 6.14.2 (#248) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 665218d..bc0d196 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6682,9 +6682,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" From 55dd461627e3e8899591177c45832d23898e60cb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 2 Mar 2026 13:58:10 +1100 Subject: [PATCH 28/51] Bump minimatch (#250) --- package-lock.json | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index bc0d196..1158ee5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4165,13 +4165,13 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", - "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "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.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -5760,10 +5760,11 @@ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, From 064bd3f29b395e3d4a7f9436ba5a3a874ed4079e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 4 Mar 2026 12:45:14 +1100 Subject: [PATCH 29/51] Bump underscore from 1.13.7 to 1.13.8 (#251) --- package-lock.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1158ee5..7cf86d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,7 +36,7 @@ "soap": "^1.6.0", "ts-md5": "^1.3.1", "typescript": "^5.9.3", - "underscore": "^1.13.7", + "underscore": "^1.13.8", "urn-lib": "^2.0.0", "uuid": "^11.1.0", "winston": "^3.18.3", @@ -7926,9 +7926,9 @@ "dev": true }, "node_modules/underscore": { - "version": "1.13.7", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", - "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", + "version": "1.13.8", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.8.tgz", + "integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==", "license": "MIT" }, "node_modules/undici-types": { diff --git a/package.json b/package.json index d3d93b6..2ca5770 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "soap": "^1.6.0", "ts-md5": "^1.3.1", "typescript": "^5.9.3", - "underscore": "^1.13.7", + "underscore": "^1.13.8", "urn-lib": "^2.0.0", "uuid": "^11.1.0", "winston": "^3.18.3", From 217afeb9044a322e1eb2df42931d43c7571b73b5 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 21 Mar 2026 08:54:33 +1100 Subject: [PATCH 30/51] Remove DEBUG_CF env var flag now that CF tunnels are working --- src/server.ts | 4 ---- src/smapi.ts | 3 --- 2 files changed, 7 deletions(-) diff --git a/src/server.ts b/src/server.ts index f8feb05..17dc92e 100644 --- a/src/server.ts +++ b/src/server.ts @@ -449,10 +449,6 @@ function server( )}, headers=${JSON.stringify({ ...req.headers, "authorization": "*****" })}` ); - if(process.env["BNB_DEBUG_CF"] == "true") { - console.log(`DEBUG_CF /stream auth header == '${req.headers["authorization"]}'`) - } - const serviceToken = pipe( E.fromNullable("Missing authorization header")(req.headers["authorization"] as string), E.chain((authorization) => diff --git a/src/smapi.ts b/src/smapi.ts index ef7bf5e..2596f3a 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -578,9 +578,6 @@ function bindSmapiSoapServiceToExpress( getMediaURIResult: it.url, })); case "track": - if(process.env["BNB_DEBUG_CF"] == "true") { - console.log(`DEBUG_CF getMediaURIResult header 'authorization'== '${apiKey}'`) - } return { getMediaURIResult: bonobUrl .append({ From 75cc0cfef0f432ef457e681f1715bd016bc95be7 Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 21 Mar 2026 08:55:05 +1100 Subject: [PATCH 31/51] Additional tests cases for non standard https port url building --- tests/url_builder.test.ts | 43 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/url_builder.test.ts b/tests/url_builder.test.ts index 3eea2d8..d655f52 100644 --- a/tests/url_builder.test.ts +++ b/tests/url_builder.test.ts @@ -4,6 +4,7 @@ describe("URLBuilder", () => { describe("construction", () => { it("with a string", () => { expect(url("http://example.com/").href()).toEqual("http://example.com/"); + expect(url("https://nonstandardport.example.com:4443/").href()).toEqual("https://nonstandardport.example.com:4443/"); expect(url("http://example.com/foobar?name=bob").href()).toEqual( "http://example.com/foobar?name=bob" ); @@ -13,6 +14,9 @@ describe("URLBuilder", () => { expect(url(new URL("http://example.com/")).href()).toEqual( "http://example.com/" ); + expect(url(new URL("http://nonstandardport.example.com:8080/")).href()).toEqual( + "http://nonstandardport.example.com:8080/" + ); expect(url(new URL("http://example.com/foobar?name=bob")).href()).toEqual( "http://example.com/foobar?name=bob" ); @@ -22,6 +26,7 @@ describe("URLBuilder", () => { describe("toString", () => { it("should print the href", () => { expect(`${url("http://example.com/")}`).toEqual("http://example.com/"); + expect(`${url("http://something.example.com:8443/")}`).toEqual("http://something.example.com:8443/"); expect(`${url("http://example.com/foobar?name=bob")}`).toEqual( "http://example.com/foobar?name=bob" ); @@ -53,6 +58,19 @@ describe("URLBuilder", () => { }); }); + describe("when there is no existing pathname on a non standard https port", ()=>{ + it("should return a new URLBuilder with the new pathname appended to the existing pathname", () => { + const original = url("https://example.com:8443?a=b"); + const updated = original.append({ pathname: "/the-appended-path" }); + + expect(original.href()).toEqual("https://example.com:8443/?a=b"); + expect(original.pathname()).toEqual("/") + + expect(updated.href()).toEqual("https://example.com:8443/the-appended-path?a=b"); + expect(updated.pathname()).toEqual("/the-appended-path") + }); + }); + describe("when the existing pathname is /", ()=>{ it("should return a new URLBuilder with the new pathname appended to the existing pathname", () => { const original = url("https://example.com/"); @@ -104,7 +122,7 @@ describe("URLBuilder", () => { }); }); - describe("replacing", () => { + describe("replacing on a standard port", () => { it("should return a new URLBuilder with the new pathname", () => { const original = url("https://example.com/some-path?a=b"); const updated = original.with({ pathname: "/some-new-path" }); @@ -116,6 +134,19 @@ describe("URLBuilder", () => { expect(updated.pathname()).toEqual("/some-new-path") }); }); + + describe("replacing on a custom port", () => { + it("should return a new URLBuilder with the new pathname", () => { + const original = url("https://example.com:4443/some-path?a=b"); + const updated = original.with({ pathname: "/some-new-path" }); + + expect(original.href()).toEqual("https://example.com:4443/some-path?a=b"); + expect(original.pathname()).toEqual("/some-path") + + expect(updated.href()).toEqual("https://example.com:4443/some-new-path?a=b"); + expect(updated.pathname()).toEqual("/some-new-path") + }); + }); }); describe("updating search params", () => { @@ -221,4 +252,14 @@ describe("URLBuilder", () => { }); }); }); + + describe("an example", () => { + describe("of a non standard port", () => { + expect( + url("https://xyz.example.com:4443/path1?param1=value1").append({ pathname: "/path2", searchParams: { param2: "value2" } }).href() + ).toEqual( + "https://xyz.example.com:4443/path1/path2?param1=value1¶m2=value2" + ); + }); + }); }); From 907987b54e0254e215970258fef73ba5b3b906f9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 26 Mar 2026 11:29:17 +1100 Subject: [PATCH 32/51] Bump picomatch (#256) --- package-lock.json | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7cf86d4..2ba0a02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5214,9 +5214,9 @@ } }, "node_modules/jest-util/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -6580,10 +6580,11 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, From ff9de5a606225c46e14d6ddaf68d9a3da88b3353 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Mar 2026 14:34:17 +1100 Subject: [PATCH 33/51] Bump handlebars from 4.7.8 to 4.7.9 (#257) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2ba0a02..83d7f5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4198,9 +4198,9 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "dev": true, "license": "MIT", "dependencies": { From dc956c83f07594c9b6d3722cac6f0dd30a23d37e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 29 Mar 2026 10:27:51 +1100 Subject: [PATCH 34/51] Bump path-to-regexp from 8.3.0 to 8.4.0 (#258) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 83d7f5c..c7df292 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6563,9 +6563,9 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", - "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.0.tgz", + "integrity": "sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg==", "license": "MIT", "funding": { "type": "opencollective", From 50da3f828e779247c9fd092d70699e22ebb55671 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 11:42:37 +1100 Subject: [PATCH 35/51] Bump @xmldom/xmldom (#259) --- package-lock.json | 14 +++++++------- package.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index c7df292..2d8c03a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,7 +20,7 @@ "@types/underscore": "^1.13.0", "@types/uuid": "^10.0.0", "@types/xmldom": "^0.1.34", - "@xmldom/xmldom": "^0.9.7", + "@xmldom/xmldom": "^0.9.9", "axios": "^1.13.5", "dayjs": "^1.11.19", "eta": "^2.2.0", @@ -2320,9 +2320,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.9.7", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.7.tgz", - "integrity": "sha512-syvR8iIJjpTZ/stv7l89UAViwGFh6lbheeOaqSxkYx9YNmIVvPTRH+CT/fpykFtUx5N+8eSMDRvggF9J8GEPzQ==", + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.9.tgz", + "integrity": "sha512-qycIHAucxy/LXAYIjmLmtQ8q9GPnMbnjG1KXhWm9o5sCr6pOYDATkMPiTNa6/v8eELyqOQ2FsEqeoFYmgv/gJg==", "license": "MIT", "engines": { "node": ">=14.6" @@ -8335,9 +8335,9 @@ } }, "node_modules/xml-crypto/node_modules/@xmldom/xmldom": { - "version": "0.8.11", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", - "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index 2ca5770..e8cbe23 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "@types/underscore": "^1.13.0", "@types/uuid": "^10.0.0", "@types/xmldom": "^0.1.34", - "@xmldom/xmldom": "^0.9.7", + "@xmldom/xmldom": "^0.9.9", "axios": "^1.13.5", "dayjs": "^1.11.19", "eta": "^2.2.0", From 274e8927ee8b4549588b7c3d16f5f8435008dc24 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 13:01:34 +1000 Subject: [PATCH 36/51] Bump axios from 1.13.5 to 1.15.0 (#266) Bumps [axios](https://github.com/axios/axios) from 1.13.5 to 1.15.0. - [Release notes](https://github.com/axios/axios/releases) - [Changelog](https://github.com/axios/axios/blob/v1.x/CHANGELOG.md) - [Commits](https://github.com/axios/axios/compare/v1.13.5...v1.15.0) --- updated-dependencies: - dependency-name: axios dependency-version: 1.15.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 20 ++++++++++++-------- package.json | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2d8c03a..f4f6a64 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@types/uuid": "^10.0.0", "@types/xmldom": "^0.1.34", "@xmldom/xmldom": "^0.9.9", - "axios": "^1.13.5", + "axios": "^1.15.0", "dayjs": "^1.11.19", "eta": "^2.2.0", "express": "^5.1.0", @@ -2487,14 +2487,14 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/axios": { - "version": "1.13.5", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", - "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.11", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "proxy-from-env": "^2.1.0" } }, "node_modules/axios-ntlm": { @@ -6656,9 +6656,13 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pstree.remy": { "version": "1.1.8", diff --git a/package.json b/package.json index e8cbe23..6ed6484 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "@types/uuid": "^10.0.0", "@types/xmldom": "^0.1.34", "@xmldom/xmldom": "^0.9.9", - "axios": "^1.13.5", + "axios": "^1.15.0", "dayjs": "^1.11.19", "eta": "^2.2.0", "express": "^5.1.0", From 977a5bf8eeb401ea621de091977a965a10cc9331 Mon Sep 17 00:00:00 2001 From: AliceGrey Date: Mon, 13 Apr 2026 21:40:47 -0700 Subject: [PATCH 37/51] Fix flaky date tests caused by UTC vs local timezone mismatch (#263) The implementation in src/clock.ts uses .date()/.month() which return local time. The tests passed UTC times (Z suffix), so in any timezone west of UTC the local date became the previous day - e.g. midnight UTC on 25/12 is 24/12 in PDT, breaking isChristmas. Drop the Z suffix so the test times are interpreted as local time, matching the implementation. Also fix a typo in one test description (ragardless -> regardless). --- tests/clock.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/clock.test.ts b/tests/clock.test.ts index b4e0220..2b42b4f 100644 --- a/tests/clock.test.ts +++ b/tests/clock.test.ts @@ -29,14 +29,14 @@ function describeFixedDateMonthEvent( const month = dateMonth.split("/")[1]; describe(name, () => { - it(`should return true for ${randomYear}-${month}-${date}T00:00:00 ragardless of year`, () => { - expect(f({ now: () => dayjs(`${randomYear}-${month}-${date}T00:00:00Z`) })).toEqual(true); + it(`should return true for ${randomYear}-${month}-${date}T00:00:00 regardless of year`, () => { + expect(f({ now: () => dayjs(`${randomYear}-${month}-${date}T00:00:00`) })).toEqual(true); }); - + it(`should return true for ${randomYear}-${month}-${date}T12:00:00 regardless of year`, () => { - expect(f({ now: () => dayjs(`${randomYear}-${month}-${date}T12:00:00Z`) })).toEqual(true); + expect(f({ now: () => dayjs(`${randomYear}-${month}-${date}T12:00:00`) })).toEqual(true); }); - + it(`should return true for ${randomYear}-${month}-${date}T23:59:00 regardless of year`, () => { expect(f({ now: () => dayjs(`${randomYear}-${month}-${date}T23:59:00`) })).toEqual(true); }); From b3b057429679c3effcad120e65e667e1e8b50585 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:17:15 +1000 Subject: [PATCH 38/51] Bump follow-redirects from 1.15.11 to 1.16.0 (#267) Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.15.11 to 1.16.0. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.15.11...v1.16.0) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index f4f6a64..e60553c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3883,9 +3883,9 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "funding": [ { "type": "individual", From 2781ec5cea2ed8f3e1a2ffcc16193dcc50a1a44e Mon Sep 17 00:00:00 2001 From: AliceGrey Date: Tue, 14 Apr 2026 01:52:48 -0700 Subject: [PATCH 39/51] Modernize CI and speed up Docker build (#264) CI workflow: - Bump GitHub Actions to current major versions (checkout v5, setup-node v5, docker/* v3-v6, codeql v3) so workflows run on Node 24 instead of the deprecated Node 20 - Bump Node version in build_and_test from 20 to 22 (matches the Dockerfile base image) Dockerfile: - Drop redundant 'npm test' from the build (already runs in the build_and_test job before the Docker push, repeating it on emulated arm/v7 and arm64 wastes substantial CI minutes) - Reorder so package.json is copied and 'npm ci' runs before the source is copied, letting Docker cache the install layer when only application code changes - Use 'npm prune --omit=dev' to strip devDeps in place rather than doing a second full 'npm install' - Add GHA-backed Docker layer cache (cache-from/cache-to) so subsequent builds reuse layers across runs --- .github/workflows/ci.yml | 22 ++++++----- .github/workflows/codeql-analysis.yml | 8 ++-- Dockerfile | 56 ++++++++++++--------------- 3 files changed, 40 insertions(+), 46 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 56a95ea..47c51fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,11 +17,11 @@ jobs: steps: - name: Check out the repo - uses: actions/checkout@v3 + uses: actions/checkout@v5 - - uses: actions/setup-node@v3 + uses: actions/setup-node@v5 with: - node-version: 20 + node-version: 22 - run: npm install - @@ -35,19 +35,19 @@ jobs: steps: - name: Check out the repo - uses: actions/checkout@v3 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Set up QEMU - uses: docker/setup-qemu-action@v2 + uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v2 + uses: docker/setup-buildx-action@v3 - name: Docker meta id: meta - uses: docker/metadata-action@v4 + uses: docker/metadata-action@v5 with: images: | simojenki/bonob @@ -55,24 +55,26 @@ jobs: - name: Login to DockerHub if: github.event_name != 'pull_request' - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Log in to GitHub Container registry if: github.event_name != 'pull_request' - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Push image - uses: docker/build-push-action@v4 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm/v7,linux/arm64 push: ${{ github.event_name != 'pull_request' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 224b7f5..0730b9d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -39,11 +39,11 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v5 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v1 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -54,7 +54,7 @@ jobs: # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild - uses: github/codeql-action/autobuild@v1 + uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 https://git.io/JvXDl @@ -68,4 +68,4 @@ jobs: # make release - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v1 + uses: github/codeql-action/analyze@v3 diff --git a/Dockerfile b/Dockerfile index 2f67a72..b490405 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,20 +2,6 @@ FROM node:22-bookworm-slim AS build WORKDIR /bonob -COPY .git ./.git -COPY src ./src -COPY docs ./docs -COPY typings ./typings -COPY web ./web -COPY tests ./tests -COPY jest.config.js . -COPY register.js . -COPY .npmrc . -COPY tsconfig.json . -COPY package.json . -COPY package-lock.json . - -ENV JEST_TIMEOUT=60000 ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update && \ @@ -27,13 +13,21 @@ RUN apt-get update && \ git \ g++ && \ apt-get clean && \ - rm -rf /var/lib/apt/lists/* && \ - npm install && \ - npm test && \ - npm run gitinfo && \ + rm -rf /var/lib/apt/lists/* + +# Install dependencies first so this layer caches when only source changes +COPY package.json package-lock.json .npmrc ./ +RUN npm ci + +# Now copy source and build +COPY tsconfig.json jest.config.js register.js ./ +COPY src ./src +COPY typings ./typings +COPY .git ./.git + +RUN npm run gitinfo && \ npm run build && \ - rm -Rf node_modules && \ - NODE_ENV=production npm install --omit=dev + npm prune --omit=dev FROM node:22-bookworm-slim @@ -51,15 +45,6 @@ EXPOSE $BNB_PORT WORKDIR /bonob -COPY package.json . -COPY package-lock.json . - -COPY --from=build /bonob/build/src ./src -COPY --from=build /bonob/node_modules ./node_modules -COPY --from=build /bonob/.gitinfo ./ -COPY web ./web -COPY src/Sonoswsdl-1.19.6-20231024.wsdl ./src/Sonoswsdl-1.19.6-20231024.wsdl - RUN apt-get update && \ apt-get -y upgrade && \ apt-get -y install --no-install-recommends \ @@ -69,9 +54,16 @@ RUN apt-get update && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* -USER nobody +COPY package.json package-lock.json ./ +COPY --from=build /bonob/build/src ./src +COPY --from=build /bonob/node_modules ./node_modules +COPY --from=build /bonob/.gitinfo ./ +COPY web ./web +COPY src/Sonoswsdl-1.19.6-20231024.wsdl ./src/Sonoswsdl-1.19.6-20231024.wsdl + +USER nobody WORKDIR /bonob/src -HEALTHCHECK CMD wget -O- http://localhost:${BNB_PORT}/about || exit 1 +HEALTHCHECK CMD wget -O- http://localhost:${BNB_PORT}/about || exit 1 -CMD ["node", "app.js"] \ No newline at end of file +CMD ["node", "app.js"] From 831e0f55496ab8141499ce435c785d01cea0c770 Mon Sep 17 00:00:00 2001 From: Simon J Date: Fri, 17 Apr 2026 11:53:59 +1000 Subject: [PATCH 40/51] Pr 265 (#270) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update dependencies and replace ts-jest with @swc/jest - Bump direct dependencies to latest minor/patch versions - Replace ts-jest with @swc/jest for ~2x faster test execution (and removes ts-jest's chain to deprecated test-exclude/glob@7/inflight) - Run npm audit fix to resolve 3 vulnerabilities (low/moderate/high) * Override whatwg-url to silence punycode deprecation warning @svrooij/sonos pulls in node-fetch@2 → whatwg-url@5 → tr46@0.0.3 which uses Node's built-in punycode module (DEP0040). Forcing whatwg-url@14 uses the userland punycode package instead. * Replace image-js with sharp in tests to drop unmaintained has-own dep The 2 tests that used image-js only checked PNG width/height, which sharp (already a runtime dependency) handles via metadata(). Removing image-js eliminates the deprecated has-own@1.0.1 transitive dependency. * Replace ts-md5, randomstring, uuid, image-js with built-ins - Replace ts-md5 with Node's built-in crypto.createHash('md5') - Replace randomstring with a small crypto.randomBytes-backed helper in src/random.ts (keeps mockability for tests) - Replace uuid library with Node's built-in crypto.randomUUID() - Remove unused chai, @types/chai, @types/mocha (project uses Jest) Drops 5 runtime deps and 3 devDeps in exchange for ~12 lines of helper code, reducing supply-chain attack surface and removing the deprecated 'has-own' transitive dependency. * Drop fs-extra, tmp, urn-lib, and direct jws dependency - fs-extra: only readFile/writeFile/existsSync/etc. used; replaced with Node's built-in fs and fs/promises modules - tmp: only used in tests via dirSync(); replaced with fs.mkdtempSync - urn-lib: only used to format/parse 'bnb::' URNs; replaced with a 12-line BURN object directly in burn.ts - jws: was listed as a direct dep but jsonwebtoken already pulls it in transitively. Removed direct dep, kept @types/jws as devDep for tsc. Drops 4 runtime deps and 4 devDeps, further reducing the supply-chain attack surface for an internet-exposed app. --------- Co-authored-by: Alice Grey --- jest.config.js | 9 +- package-lock.json | 2856 ++++++++++--------------- package.json | 55 +- src/burn.ts | 28 +- src/link_codes.ts | 2 +- src/random.ts | 10 + src/server.ts | 2 +- src/subsonic.ts | 18 +- tests/api_tokens.test.ts | 2 +- tests/builders.ts | 5 +- tests/in_memory_music_service.test.ts | 2 +- tests/music_library.test.ts | 2 +- tests/scenarios.test.ts | 2 +- tests/server.test.ts | 16 +- tests/smapi.test.ts | 2 +- tests/smapi_auth.test.ts | 2 +- tests/sonos.test.ts | 2 +- tests/subsonic.test.ts | 38 +- tests/subsonic_music_library.test.ts | 8 +- 19 files changed, 1285 insertions(+), 1776 deletions(-) create mode 100644 src/random.ts diff --git a/jest.config.js b/jest.config.js index ae6aab8..70102b4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,10 +1,17 @@ module.exports = { - preset: 'ts-jest', testEnvironment: 'node', setupFilesAfterEnv: ["/tests/setup.js"], modulePathIgnorePatterns: [ '/node_modules', '/build', ], + transform: { + '^.+\\.tsx?$': ['@swc/jest', { + jsc: { + parser: { syntax: 'typescript', tsx: false, decorators: true }, + target: 'es2022', + }, + }], + }, testTimeout: Number.parseInt(process.env["JEST_TIMEOUT"] || "5000") }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index e60553c..5fbdc36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,55 +9,41 @@ "version": "0.0.1", "license": "GPL-3.0-only", "dependencies": { - "@svrooij/sonos": "^2.6.0-beta.12", - "@types/express": "^5.0.5", - "@types/fs-extra": "^11.0.4", + "@svrooij/sonos": "^2.6.0-beta.13", + "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.10", - "@types/jws": "^3.2.11", "@types/morgan": "^1.9.10", - "@types/node": "^24.10.0", - "@types/randomstring": "^1.3.0", + "@types/node": "^24.12.2", "@types/underscore": "^1.13.0", - "@types/uuid": "^10.0.0", "@types/xmldom": "^0.1.34", "@xmldom/xmldom": "^0.9.9", "axios": "^1.15.0", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "eta": "^2.2.0", - "express": "^5.1.0", + "express": "^5.2.1", "fp-ts": "^2.16.11", - "fs-extra": "^11.3.2", - "jsonwebtoken": "^9.0.2", - "jws": "^4.0.1", + "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", - "node-html-parser": "^7.0.1", - "randomstring": "^1.3.1", - "sharp": "^0.34.4", - "soap": "^1.6.0", - "ts-md5": "^1.3.1", + "node-html-parser": "^7.1.0", + "sharp": "^0.34.5", + "soap": "^1.9.0", "typescript": "^5.9.3", "underscore": "^1.13.8", - "urn-lib": "^2.0.0", - "uuid": "^11.1.0", - "winston": "^3.18.3", + "winston": "^3.19.0", "xmldom-ts": "^0.3.1", "xpath": "^0.0.34" }, "devDependencies": { - "@types/chai": "^5.2.3", + "@swc/core": "^1.15.24", + "@swc/jest": "^0.2.39", "@types/jest": "^30.0.0", - "@types/mocha": "^10.0.10", + "@types/jws": "^3.2.11", "@types/supertest": "^6.0.3", - "@types/tmp": "^0.2.6", - "chai": "^6.2.0", - "get-port": "^7.1.0", - "image-js": "^0.37.0", - "jest": "^30.2.0", - "nodemon": "^3.1.10", - "npm-check-updates": "^19.1.2", - "supertest": "^7.1.4", - "tmp": "^0.2.5", - "ts-jest": "^29.4.5", + "get-port": "^7.2.0", + "jest": "^30.3.0", + "nodemon": "^3.1.14", + "npm-check-updates": "^19.6.6", + "supertest": "^7.2.2", "ts-mockito": "^2.6.1", "ts-node": "^10.9.2", "xpath-ts": "^1.3.13" @@ -196,9 +182,9 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", - "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", "dev": true, "license": "MIT", "engines": { @@ -363,13 +349,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", - "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -489,13 +475,13 @@ } }, "node_modules/@babel/plugin-syntax-typescript": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.27.1.tgz", - "integrity": "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ==", + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -504,16 +490,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/runtime": { - "version": "7.28.4", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", - "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/template": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -612,21 +588,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.0.tgz", - "integrity": "sha512-pJdKGq/1iquWYtv1RRSljZklxHCOCAJFJrImO5ZLKPJVJlVUcs8yFwNQlqS0Lo8xT1VAXXTCZocF9n26FWEKsw==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.1.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.0.tgz", - "integrity": "sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==", + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", "license": "MIT", "optional": true, "dependencies": { @@ -634,9 +610,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", - "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -654,9 +630,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.4.tgz", - "integrity": "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -672,13 +648,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.3" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.4.tgz", - "integrity": "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -694,13 +670,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.3" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.3.tgz", - "integrity": "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -714,9 +690,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.3.tgz", - "integrity": "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -730,12 +706,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.3.tgz", - "integrity": "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -746,12 +725,15 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.3.tgz", - "integrity": "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -762,12 +744,34 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.3.tgz", - "integrity": "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "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.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -778,12 +782,15 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.3.tgz", - "integrity": "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -794,12 +801,15 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.3.tgz", - "integrity": "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -810,12 +820,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.3.tgz", - "integrity": "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -826,12 +839,15 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.3.tgz", - "integrity": "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -842,12 +858,15 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.4.tgz", - "integrity": "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -860,16 +879,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.3" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.4.tgz", - "integrity": "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -882,16 +904,44 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.3" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.4.tgz", - "integrity": "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -904,16 +954,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.3" + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.4.tgz", - "integrity": "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -926,16 +979,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.3" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.4.tgz", - "integrity": "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], + "libc": [ + "glibc" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -948,16 +1004,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.3" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.4.tgz", - "integrity": "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -970,16 +1029,19 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.4.tgz", - "integrity": "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "license": "Apache-2.0", "optional": true, "os": [ @@ -992,20 +1054,20 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.3" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.4.tgz", - "integrity": "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.5.0" + "@emnapi/runtime": "^1.7.0" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -1015,9 +1077,9 @@ } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.4.tgz", - "integrity": "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -1034,9 +1096,9 @@ } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.4.tgz", - "integrity": "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -1053,9 +1115,9 @@ } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.4.tgz", - "integrity": "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -1116,17 +1178,17 @@ } }, "node_modules/@jest/console": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.2.0.tgz", - "integrity": "sha512-+O1ifRjkvYIkBqASKWgLxrpEhQAAE7hY77ALLUufSk5717KfOShg6IbqLmdsLMPdUiFvA2kTs0R7YZy+l0IzZQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-30.3.0.tgz", + "integrity": "sha512-PAwCvFJ4696XP2qZj+LAn1BWjZaJ6RjG6c7/lkMaUJnkyMS34ucuIsfqYvfskVNvUI27R/u4P1HMYFnlVXG/Ww==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -1134,39 +1196,38 @@ } }, "node_modules/@jest/core": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.2.0.tgz", - "integrity": "sha512-03W6IhuhjqTlpzh/ojut/pDB2LPRygyWX8ExpgHtQA8H/3K7+1vKmcINx5UzeOX1se6YEsBsOHQ1CRzf3fOwTQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-30.3.0.tgz", + "integrity": "sha512-U5mVPsBxLSO6xYbf+tgkymLx+iAhvZX43/xI1+ej2ZOPnPdkdO1CzDmFKh2mZBn2s4XZixszHeQnzp1gm/DIxw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", + "@jest/console": "30.3.0", "@jest/pattern": "30.0.1", - "@jest/reporters": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/reporters": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "ci-info": "^4.2.0", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", - "jest-changed-files": "30.2.0", - "jest-config": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", + "jest-changed-files": "30.3.0", + "jest-config": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-resolve-dependencies": "30.2.0", - "jest-runner": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "jest-watcher": "30.2.0", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "jest-resolve": "30.3.0", + "jest-resolve-dependencies": "30.3.0", + "jest-runner": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", + "jest-watcher": "30.3.0", + "pretty-format": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -1181,10 +1242,23 @@ } } }, + "node_modules/@jest/create-cache-key-function": { + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/create-cache-key-function/-/create-cache-key-function-30.3.0.tgz", + "integrity": "sha512-hTupmOWylzeyqbMNeSNi7ZDprpjrcroAOOG+qCEW66st3+Z5RnYHVYkUt+zjIcLmrTUi2lPY79hJz8mB3L2oXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "30.3.0" + }, + "engines": { + "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" + } + }, "node_modules/@jest/diff-sequences": { - "version": "30.0.1", - "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.0.1.tgz", - "integrity": "sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/diff-sequences/-/diff-sequences-30.3.0.tgz", + "integrity": "sha512-cG51MVnLq1ecVUaQ3fr6YuuAOitHK1S4WUJHnsPFE/quQr33ADUx1FfrTCpMCRxvy0Yr9BThKpDjSlcTi91tMA==", "dev": true, "license": "MIT", "engines": { @@ -1192,39 +1266,39 @@ } }, "node_modules/@jest/environment": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.2.0.tgz", - "integrity": "sha512-/QPTL7OBJQ5ac09UDRa3EQes4gt1FTEG/8jZ/4v5IVzx+Cv7dLxlVIvfvSVRiiX2drWyXeBjkMSR8hvOWSog5g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-30.3.0.tgz", + "integrity": "sha512-SlLSF4Be735yQXyh2+mctBOzNDx5s5uLv88/j8Qn1wH679PDcwy67+YdADn8NJnGjzlXtN62asGH/T4vWOkfaw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0" + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-V9yxQK5erfzx99Sf+7LbhBwNWEZ9eZay8qQ9+JSC0TrMR1pMDHLMY+BnVPacWU6Jamrh252/IKo4F1Xn/zfiqA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-76Nlh4xJxk2D/9URCn3wFi98d2hb19uWE1idLsTt2ywhvdOldbw3S570hBgn25P4ICUZ/cBjybrBex2g17IDbg==", "dev": true, "license": "MIT", "dependencies": { - "expect": "30.2.0", - "jest-snapshot": "30.2.0" + "expect": "30.3.0", + "jest-snapshot": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/@jest/expect-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.2.0.tgz", - "integrity": "sha512-1JnRfhqpD8HGpOmQp180Fo9Zt69zNtC+9lR+kT7NVL05tNXIi+QC8Csz7lfidMoVLPD3FnOtcmp0CEFnxExGEA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-30.3.0.tgz", + "integrity": "sha512-j0+W5iQQ8hBh7tHZkTQv3q2Fh/M7Je72cIsYqC4OaktgtO7v1So9UTjp6uPBHIaB6beoF/RRsCgMJKvti0wADA==", "dev": true, "license": "MIT", "dependencies": { @@ -1235,18 +1309,18 @@ } }, "node_modules/@jest/fake-timers": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.2.0.tgz", - "integrity": "sha512-HI3tRLjRxAbBy0VO8dqqm7Hb2mIa8d5bg/NJkyQcOk7V118ObQML8RC5luTF/Zsg4474a+gDvhce7eTnP4GhYw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-30.3.0.tgz", + "integrity": "sha512-WUQDs8SOP9URStX1DzhD425CqbN/HxUYCTwVrT8sTVBfMvFqYt/s61EK5T05qnHu0po6RitXIvP9otZxYDzTGQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", - "@sinonjs/fake-timers": "^13.0.0", + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1263,16 +1337,16 @@ } }, "node_modules/@jest/globals": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.2.0.tgz", - "integrity": "sha512-b63wmnKPaK+6ZZfpYhz9K61oybvbI1aMcIs80++JI1O1rR1vaxHUCNqo3ITu6NU0d4V34yZFoHMn/uoKr/Rwfw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-30.3.0.tgz", + "integrity": "sha512-+owLCBBdfpgL3HU+BD5etr1SvbXpSitJK0is1kiYjJxAAJggYMRQz5hSdd5pq1sSggfxPbw2ld71pt4x5wwViA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/types": "30.2.0", - "jest-mock": "30.2.0" + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/types": "30.3.0", + "jest-mock": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -1293,32 +1367,32 @@ } }, "node_modules/@jest/reporters": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.2.0.tgz", - "integrity": "sha512-DRyW6baWPqKMa9CzeiBjHwjd8XeAyco2Vt8XbcLFjiwCOEKOvy82GJ8QQnJE9ofsxCMPjH4MfH8fCWIHHDKpAQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-30.3.0.tgz", + "integrity": "sha512-a09z89S+PkQnL055bVj8+pe2Caed2PBOaczHcXCykW5ngxX9EWx/1uAwncxc/HiU0oZqfwseMjyhxgRjS49qPw==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "@types/node": "*", "chalk": "^4.1.2", "collect-v8-coverage": "^1.0.2", "exit-x": "^0.2.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", "istanbul-lib-coverage": "^3.0.0", "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^5.0.0", "istanbul-reports": "^3.1.3", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", "slash": "^3.0.0", "string-length": "^4.0.2", "v8-to-istanbul": "^9.0.1" @@ -1349,13 +1423,13 @@ } }, "node_modules/@jest/snapshot-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.2.0.tgz", - "integrity": "sha512-0aVxM3RH6DaiLcjj/b0KrIBZhSX1373Xci4l3cW5xiUWPctZ59zQ7jj4rqcJQ/Z8JuN/4wX3FpJSa3RssVvCug==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/snapshot-utils/-/snapshot-utils-30.3.0.tgz", + "integrity": "sha512-ORbRN9sf5PP82v3FXNSwmO1OTDR2vzR2YTaR+E3VkSBZ8zadQE6IqYdYEeFH1NIkeB2HIGdF02dapb6K0Mj05g==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "natural-compare": "^1.4.0" @@ -1380,14 +1454,14 @@ } }, "node_modules/@jest/test-result": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.2.0.tgz", - "integrity": "sha512-RF+Z+0CCHkARz5HT9mcQCBulb1wgCP3FBvl9VFokMX27acKphwyQsNuWH3c+ojd1LeWBLoTYoxF0zm6S/66mjg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-30.3.0.tgz", + "integrity": "sha512-e/52nJGuD74AKTSe0P4y5wFRlaXP0qmrS17rqOMHeSwm278VyNyXE3gFO/4DTGF9w+65ra3lo3VKj0LBrzmgdQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/types": "30.3.0", "@types/istanbul-lib-coverage": "^2.0.6", "collect-v8-coverage": "^1.0.2" }, @@ -1396,15 +1470,15 @@ } }, "node_modules/@jest/test-sequencer": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.2.0.tgz", - "integrity": "sha512-wXKgU/lk8fKXMu/l5Hog1R61bL4q5GCdT6OJvdAFz1P+QrpoFuLU68eoKuVc4RbrTtNnTL5FByhWdLgOPSph+Q==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-30.3.0.tgz", + "integrity": "sha512-dgbWy9b8QDlQeRZcv7LNF+/jFiiYHTKho1xirauZ7kVwY7avjFF6uTT0RqlgudB5OuIPagFdVtfFMosjVbk1eA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", + "@jest/test-result": "30.3.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "slash": "^3.0.0" }, "engines": { @@ -1412,24 +1486,23 @@ } }, "node_modules/@jest/transform": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.2.0.tgz", - "integrity": "sha512-XsauDV82o5qXbhalKxD7p4TZYYdwcaEXC77PPD2HixEFF+6YGppjrAAQurTl2ECWcEomHBMMNS9AH3kcCFx8jA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-30.3.0.tgz", + "integrity": "sha512-TLKY33fSLVd/lKB2YI1pH69ijyUblO/BQvCj566YvnwuzoTNr648iE0j22vRvVNk2HsPwByPxATg3MleS3gf5A==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@jridgewell/trace-mapping": "^0.3.25", "babel-plugin-istanbul": "^7.0.1", "chalk": "^4.1.2", "convert-source-map": "^2.0.0", "fast-json-stable-stringify": "^2.1.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "micromatch": "^4.0.8", + "jest-util": "30.3.0", "pirates": "^4.0.7", "slash": "^3.0.0", "write-file-atomic": "^5.0.1" @@ -1439,9 +1512,9 @@ } }, "node_modules/@jest/types": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.2.0.tgz", - "integrity": "sha512-H9xg1/sfVvyfU7o3zMfBEjQ1gcsdeTMgqHoYdN79tuLqfTtuu7WckRA1R5whDwOzxaZAeMKTYWqP+WCAi0CHsg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-30.3.0.tgz", + "integrity": "sha512-JHm87k7bA33hpBngtU8h6UBub/fqqA9uXfw+21j5Hmk7ooPHlboRNxHq0JcMtC+n8VJGP1mcfnD3Mk+XKe1oSw==", "dev": true, "license": "MIT", "dependencies": { @@ -1591,9 +1664,9 @@ } }, "node_modules/@sinonjs/fake-timers": { - "version": "13.0.5", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", - "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "version": "15.3.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-15.3.2.tgz", + "integrity": "sha512-mrn35Jl2pCpns+mE3HaZa1yPN5EYCRgiMI+135COjr2hr8Cls9DXqIZ57vZe2cz7y2XVSq92tcs6kGQcT1J8Rw==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -1611,9 +1684,9 @@ } }, "node_modules/@svrooij/sonos": { - "version": "2.6.0-beta.12", - "resolved": "https://registry.npmjs.org/@svrooij/sonos/-/sonos-2.6.0-beta.12.tgz", - "integrity": "sha512-cbHeTWjlK3yOiJYrYVDrzgdmdoCz0W6uoUkgZ6Wk/IGj8sZ5XtFl62PIwc//A3TK4SF3E6p4Bs6z28db3mg6xg==", + "version": "2.6.0-beta.13", + "resolved": "https://registry.npmjs.org/@svrooij/sonos/-/sonos-2.6.0-beta.13.tgz", + "integrity": "sha512-8MBlrkFK9HPM5T1eKKd4y0l2Ogb3SiPPQmNm/ctWDD28Fx6+azoiifF56A0GM57ol2RvfLL0UB02OMt2RVTdVA==", "license": "MIT", "dependencies": { "@rgrove/parse-xml": "^4.2.0", @@ -1641,119 +1714,396 @@ } } }, - "node_modules/@swiftcarrot/color-fns": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/@swiftcarrot/color-fns/-/color-fns-3.2.0.tgz", - "integrity": "sha512-6SCpc4LwmGGqWHpBY9WaBzJwPF4nfgvFfejOX7Ub0kTehJysFkLUAvGID8zEx39n0pGlfr9pTiQE/7/buC7X5w==", + "node_modules/@swc/core": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.24.tgz", + "integrity": "sha512-5Hj8aNasue7yusUt8LGCUe/AjM7RMAce8ZoyDyiFwx7Al+GbYKL+yE7g4sJk8vEr1dKIkTRARkNIJENc4CjkBQ==", "dev": true, - "license": "MIT", + "hasInstallScript": true, + "license": "Apache-2.0", "dependencies": { - "@babel/runtime": "^7.10.3" + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.26" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.24", + "@swc/core-darwin-x64": "1.15.24", + "@swc/core-linux-arm-gnueabihf": "1.15.24", + "@swc/core-linux-arm64-gnu": "1.15.24", + "@swc/core-linux-arm64-musl": "1.15.24", + "@swc/core-linux-ppc64-gnu": "1.15.24", + "@swc/core-linux-s390x-gnu": "1.15.24", + "@swc/core-linux-x64-gnu": "1.15.24", + "@swc/core-linux-x64-musl": "1.15.24", + "@swc/core-win32-arm64-msvc": "1.15.24", + "@swc/core-win32-ia32-msvc": "1.15.24", + "@swc/core-win32-x64-msvc": "1.15.24" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } } }, - "node_modules/@tsconfig/node10": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", - "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", - "dev": true - }, - "node_modules/@tsconfig/node12": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", - "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", - "dev": true - }, - "node_modules/@tsconfig/node14": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", - "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", - "dev": true - }, - "node_modules/@tsconfig/node16": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", - "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", - "dev": true - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.24.tgz", + "integrity": "sha512-uM5ZGfFXjtvtJ+fe448PVBEbn/CSxS3UAyLj3O9xOqKIWy3S6hPTXSPbszxkSsGDYKi+YFhzAsR4r/eXLxEQ0g==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", + "license": "Apache-2.0 AND MIT", "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.24.tgz", + "integrity": "sha512-fMIb/Zfn929pw25VMBhV7Ji2Dl+lCWtUPNdYJQYOke+00E5fcQ9ynxtP8+qhUo/HZc+mYQb1gJxwHM9vty+lXg==", + "cpu": [ + "x64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.24.tgz", + "integrity": "sha512-vOkjsyjjxnoYx3hMEWcGxQrMgnNrRm6WAegBXrN8foHtDAR+zpdhpGF5a4lj1bNPgXAvmysjui8cM1ov/Clkaw==", + "cpu": [ + "arm" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.24.tgz", + "integrity": "sha512-h/oNu+upkXJ6Cicnq7YGVj9PkdfarLCdQa8l/FlHYvfv8CEiMaeeTnpLU7gSBH/rGxosM6Qkfa/J9mThGF9CLA==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.24.tgz", + "integrity": "sha512-ZpF/pRe1guk6sKzQI9D1jAORtjTdNlyeXn9GDz8ophof/w2WhojRblvSDJaGe7rJjcPN8AaOkhwdRUh7q8oYIg==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/body-parser": { - "version": "1.19.5", - "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", - "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", - "dependencies": { - "@types/connect": "*", - "@types/node": "*" + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "node_modules/@swc/core-linux-ppc64-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.24.tgz", + "integrity": "sha512-QZEsZfisHTSJlmyChgDFNmKPb3W6Lhbfo/O76HhIngfEdnQNmukS38/VSe1feho+xkV5A5hETyCbx3sALBZKAQ==", + "cpu": [ + "ppc64" + ], "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "node_modules/@swc/core-linux-s390x-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.24.tgz", + "integrity": "sha512-DLdJKVsJgglqQrJBuoUYNmzm3leI7kUZhLbZGHv42onfKsGf6JDS3+bzCUQfte/XOqDjh/tmmn1DR/CF/tCJFw==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.24.tgz", + "integrity": "sha512-IpLYfposPA/XLxYOKpRfeccl1p5dDa3+okZDHHTchBkXEaVCnq5MADPmIWwIYj1tudt7hORsEHccG5no6IUQRw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.24.tgz", + "integrity": "sha512-JHy3fMSc0t/EPWgo74+OK5TGr51aElnzqfUPaiRf2qJ/BfX5CUCfMiWVBuhI7qmVMBnk1jTRnL/xZnOSHDPLYg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.24.tgz", + "integrity": "sha512-Txj+qUH1z2bUd1P3JvwByfjKFti3cptlAxhWgmunBUUxy/IW3CXLZ6l6Gk4liANadKkU71nIU1X30Z5vpMT3BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.24.tgz", + "integrity": "sha512-15D/nl3XwrhFpMv+MADFOiVwv3FvH9j8c6Rf8EXBT3Q5LoMh8YnDnSgPYqw1JzPnksvsBX6QPXLiPqmcR/Z4qQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.24", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.24.tgz", + "integrity": "sha512-PR0PlTlPra2JbaDphrOAzm6s0v9rA0F17YzB+XbWD95B4g2cWcZY9LAeTa4xll70VLw9Jr7xBrlohqlQmelMFQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/jest": { + "version": "0.2.39", + "resolved": "https://registry.npmjs.org/@swc/jest/-/jest-0.2.39.tgz", + "integrity": "sha512-eyokjOwYd0Q8RnMHri+8/FS1HIrIUKK/sRrFp8c1dThUOfNeCWbLmBP1P5VsKdvmkd25JaH+OKYwEYiAYg9YAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/create-cache-key-function": "^30.0.0", + "@swc/counter": "^0.1.3", + "jsonc-parser": "^3.2.0" + }, + "engines": { + "npm": ">= 7.0.0" + }, + "peerDependencies": { + "@swc/core": "*" + } + }, + "node_modules/@swc/types": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.26.tgz", + "integrity": "sha512-lyMwd7WGgG79RS7EERZV3T8wMdmPq3xwyg+1nmAM64kIhx5yl+juO2PYIHb7vTiPgPCj8LYjsNV2T5wiQHUEaw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", + "integrity": "sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/body-parser": { + "version": "1.19.5", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.5.tgz", + "integrity": "sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", "dependencies": { "@types/node": "*" @@ -1765,22 +2115,15 @@ "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", "dev": true }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/express": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.5.tgz", - "integrity": "sha512-LuIQOcb6UmnF7C1PCFmEU1u2hmiHL43fgFQX67sN3H4Z+0Yk0Neo++mFsBjhOAuLzvlQeqAAkeDOZrJs9rzumQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz", + "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "license": "MIT", "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", - "@types/serve-static": "^1" + "@types/serve-static": "^2" } }, "node_modules/@types/express-serve-static-core": { @@ -1795,20 +2138,11 @@ "@types/send": "*" } }, - "node_modules/@types/fs-extra": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@types/fs-extra/-/fs-extra-11.0.4.tgz", - "integrity": "sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==", - "license": "MIT", - "dependencies": { - "@types/jsonfile": "*", - "@types/node": "*" - } - }, "node_modules/@types/http-errors": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.4.tgz", - "integrity": "sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==" + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", @@ -1845,14 +2179,6 @@ "pretty-format": "^30.0.0" } }, - "node_modules/@types/jsonfile": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/@types/jsonfile/-/jsonfile-6.1.4.tgz", - "integrity": "sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/jsonwebtoken": { "version": "9.0.10", "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", @@ -1867,6 +2193,7 @@ "version": "3.2.11", "resolved": "https://registry.npmjs.org/@types/jws/-/jws-3.2.11.tgz", "integrity": "sha512-OOaTrLV6XdF1XvBgMeH1MjNuOaGCrRZWNSIds1AQaRgLdOWlAk2yMsfrJn+ekLgUow3xksWIM231lyFab7mHHw==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1878,18 +2205,6 @@ "integrity": "sha512-ymXWVrDiCxTBE3+RIrrP533E70eA+9qu7zdWoHuOmGujkYtzf4HQF96b8nwHLqhuf4ykX61IGRIB38CC6/sImQ==", "dev": true }, - "node_modules/@types/mime": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", - "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==" - }, - "node_modules/@types/mocha": { - "version": "10.0.10", - "resolved": "https://registry.npmjs.org/@types/mocha/-/mocha-10.0.10.tgz", - "integrity": "sha512-xPyYSz1cMPnJQhl0CLMH68j3gprKZaTjG3s5Vi+fDgx+uhG9NOXwbVt52eFS8ECyXhyKcjDLCBEqBExKuiZb7Q==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/morgan": { "version": "1.9.10", "resolved": "https://registry.npmjs.org/@types/morgan/-/morgan-1.9.10.tgz", @@ -1906,33 +2221,20 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.10.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.0.tgz", - "integrity": "sha512-qzQZRBqkFsYyaSWXuEHc2WR9c0a0CXwiE5FWUvn7ZM+vdy1uZLfCunD38UzhuB7YN/J11ndbDBcTmOdxJo9Q7A==", + "version": "24.12.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" } }, - "node_modules/@types/pako": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", - "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/qs": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", "license": "MIT" }, - "node_modules/@types/randomstring": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/@types/randomstring/-/randomstring-1.3.0.tgz", - "integrity": "sha512-kCP61wludjY7oNUeFiMxfswHB3Wn/aC03Cu82oQsNTO6OCuhVN/rCbBs68Cq6Nkgjmp2Sh3Js6HearJPkk7KQA==", - "license": "MIT" - }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", @@ -1949,12 +2251,12 @@ } }, "node_modules/@types/serve-static": { - "version": "1.15.5", - "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz", - "integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz", + "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==", + "license": "MIT", "dependencies": { "@types/http-errors": "*", - "@types/mime": "*", "@types/node": "*" } }, @@ -1987,12 +2289,6 @@ "@types/superagent": "^8.1.0" } }, - "node_modules/@types/tmp": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz", - "integrity": "sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA==", - "dev": true - }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -2005,12 +2301,6 @@ "integrity": "sha512-L6LBgy1f0EFQZ+7uSA57+n2g/s4Qs5r06Vwrwn0/nuK1de+adz00NWaztRQ30aEqw5qOaWbPI8u2cGQ52lj6VA==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "license": "MIT" - }, "node_modules/@types/xmldom": { "version": "0.1.34", "resolved": "https://registry.npmjs.org/@types/xmldom/-/xmldom-0.1.34.tgz", @@ -2147,6 +2437,9 @@ "arm64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2161,6 +2454,9 @@ "arm64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2175,6 +2471,9 @@ "ppc64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2189,6 +2488,9 @@ "riscv64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2203,6 +2505,9 @@ "riscv64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2217,6 +2522,9 @@ "s390x" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2231,6 +2539,9 @@ "x64" ], "dev": true, + "libc": [ + "glibc" + ], "license": "MIT", "optional": true, "os": [ @@ -2245,6 +2556,9 @@ "x64" ], "dev": true, + "libc": [ + "musl" + ], "license": "MIT", "optional": true, "os": [ @@ -2467,15 +2781,6 @@ "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/async": { "version": "3.2.5", "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", @@ -2509,16 +2814,16 @@ } }, "node_modules/babel-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.2.0.tgz", - "integrity": "sha512-0YiBEOxWqKkSQWL9nNGGEgndoeL0ZpWrbLMNL5u/Kaxrli3Eaxlt3ZtIDktEvXt4L/R9r3ODr2zKwGM/2BjxVw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-30.3.0.tgz", + "integrity": "sha512-gRpauEU2KRrCox5Z296aeVHR4jQ98BCnu0IO332D/xpHNOsIH/bgSRk9k6GbKIbBw8vFeN6ctuu6tV8WOyVfYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "30.2.0", + "@jest/transform": "30.3.0", "@types/babel__core": "^7.20.5", "babel-plugin-istanbul": "^7.0.1", - "babel-preset-jest": "30.2.0", + "babel-preset-jest": "30.3.0", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", "slash": "^3.0.0" @@ -2551,9 +2856,9 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.2.0.tgz", - "integrity": "sha512-ftzhzSGMUnOzcCXd6WHdBGMyuwy15Wnn0iyyWGKgBDLxf9/s5ABuraCSpBX2uG0jUg4rqJnxsLc5+oYBqoxVaA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-30.3.0.tgz", + "integrity": "sha512-+TRkByhsws6sfPjVaitzadk1I0F5sPvOVUH5tyTSzhePpsGIVrdeunHSw/C36QeocS95OOk8lunc4rlu5Anwsg==", "dev": true, "license": "MIT", "dependencies": { @@ -2591,13 +2896,13 @@ } }, "node_modules/babel-preset-jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.2.0.tgz", - "integrity": "sha512-US4Z3NOieAQumwFnYdUWKvUKh8+YSnS/gB3t6YBiz0bskpu7Pine8pPCheNxlPEW4wnUkma2a94YuW2q3guvCQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-30.3.0.tgz", + "integrity": "sha512-6ZcUbWHC+dMz2vfzdNwi87Z1gQsLNK2uLuK1Q89R11xdvejcivlYYwDlEv0FHX3VwEXpbBQ9uufB/MUNpZGfhQ==", "dev": true, "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "30.2.0", + "babel-plugin-jest-hoist": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0" }, "engines": { @@ -2648,13 +2953,6 @@ "node": ">=8" } }, - "node_modules/blob-util": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/blob-util/-/blob-util-2.0.2.tgz", - "integrity": "sha512-T7JQa+zsXXEa6/8ZhHcQEW1UFfVM49Ts65uBkFL6fz2QmrElqmbajIDJvuA0tEhRe5eIjpV9ZF+0RfZR9voJFQ==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/body-parser": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz", @@ -2685,9 +2983,9 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", "dev": true, "license": "MIT", "dependencies": { @@ -2742,18 +3040,6 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, - "node_modules/bs-logger": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", - "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", - "dev": true, - "dependencies": { - "fast-json-stable-stringify": "2.x" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -2854,27 +3140,10 @@ ], "license": "CC-BY-4.0" }, - "node_modules/canny-edge-detector": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/canny-edge-detector/-/canny-edge-detector-1.0.0.tgz", - "integrity": "sha512-SpewmkHDE1PbJ1/AVAcpvZKOufYpUXT0euMvhb5C4Q83Q9XEOmSXC+yR7jl3F4Ae1Ev6OtQKbFgdcPrOdHjzQg==", - "dev": true, - "license": "MIT" - }, - "node_modules/chai": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.0.tgz", - "integrity": "sha512-aUTnJc/JipRzJrNADXVvpVqi6CO0dn3nx4EVPxijri+fj3LUUDyZQOgVeW54Ob3Y1Xh9Iz8f+CgaCl8v0mn9bA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, "dependencies": { "ansi-styles": "^4.1.0", @@ -2897,13 +3166,6 @@ "node": ">=10" } }, - "node_modules/cheminfo-types": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/cheminfo-types/-/cheminfo-types-1.8.1.tgz", - "integrity": "sha512-FRcpVkox+cRovffgqNdDFQ1eUav+i/Vq/CUd1hcfEl2bevntFlzznL+jE8g4twl6ElB7gZjCko6pYpXyMn+6dA==", - "dev": true, - "license": "MIT" - }, "node_modules/chokidar": { "version": "3.5.3", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.5.3.tgz", @@ -2948,9 +3210,9 @@ } }, "node_modules/cjs-module-lexer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.1.0.tgz", - "integrity": "sha512-UX0OwmYRYQQetfrLEZeewIFFI+wSTofC+pMBLNuH3RUuu/xzG1oz84UCEDOSoQlN3fZ4+AzmV50ZYvGqkMh9yA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-2.2.0.tgz", + "integrity": "sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==", "dev": true, "license": "MIT" }, @@ -3250,9 +3512,9 @@ } }, "node_modules/dayjs": { - "version": "1.11.19", - "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", - "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "license": "MIT" }, "node_modules/debug": { @@ -3273,9 +3535,9 @@ } }, "node_modules/dedent": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.0.tgz", - "integrity": "sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, "license": "MIT", "peerDependencies": { @@ -3356,10 +3618,11 @@ } }, "node_modules/diff": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz", - "integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } @@ -3642,6 +3905,13 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/execa/node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, "node_modules/exit-x": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/exit-x/-/exit-x-0.2.2.tgz", @@ -3653,36 +3923,37 @@ } }, "node_modules/expect": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/expect/-/expect-30.2.0.tgz", - "integrity": "sha512-u/feCi0GPsI+988gU2FLcsHyAHTU0MX1Wg68NhAnN7z/+C5wqG+CY8J53N9ioe8RXgaoz0nBR/TYMf3AycUuPw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-30.3.0.tgz", + "integrity": "sha512-1zQrciTiQfRdo7qJM1uG4navm8DayFa2TgCSRlzUyNkhcJ6XUZF3hjnpkyr3VhAqPH7i/9GkG7Tv5abz6fqz0Q==", "dev": true, "license": "MIT", "dependencies": { - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", - "jest-util": "30.2.0" + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/express": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/express/-/express-5.1.0.tgz", - "integrity": "sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { "accepts": "^2.0.0", - "body-parser": "^2.2.0", + "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", + "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", @@ -3733,69 +4004,12 @@ "node": ">= 0.6" } }, - "node_modules/fast-bmp": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-bmp/-/fast-bmp-2.0.1.tgz", - "integrity": "sha512-MOSG2rHYJCjIfL3/Llseuj39yl5U3d3XLtWFLFm5ZSTublGEXyvNcwi4Npyv6nzDPRSbAP53rvVRUswgftWCcQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "iobuffer": "^5.1.0" - } - }, - "node_modules/fast-jpeg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fast-jpeg/-/fast-jpeg-1.0.1.tgz", - "integrity": "sha512-nyoYDzmdxgLOBfEhJGwYRsRLqGKziG/wic0SMct17dTVHkseTPvNwHCfihE47tcpGA1cTJO2MNsYYHezmkuA6w==", - "dev": true, - "license": "MIT", - "dependencies": { - "iobuffer": "^2.1.0", - "tiff": "^2.0.0" - } - }, - "node_modules/fast-jpeg/node_modules/iobuffer": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-2.1.0.tgz", - "integrity": "sha512-0XZfU0STJ6NVHBZdMRPjF7jtkDEC5f4AxM/n5DSZOu11SQ+7tAl1csuEnEPoSPYWdaGZ/HOfn5Q837IEHddL2w==", - "dev": true, - "license": "MIT" - }, - "node_modules/fast-jpeg/node_modules/tiff": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tiff/-/tiff-2.1.0.tgz", - "integrity": "sha512-Q4zLT4+Csn/ZhFVacYCAl+w/1J51NW/m2y2yx7Qxp/bsHYOEsK7+5JOID2kfk+EvsaF0LbA6ccAkqiuXOmAbYw==", - "dev": true, - "license": "MIT", - "dependencies": { - "iobuffer": "^2.1.0" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", "dev": true }, - "node_modules/fast-list": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/fast-list/-/fast-list-1.0.3.tgz", - "integrity": "sha512-Lm56Ci3EqefHNdIneRFuzhpPcpVVBz9fgqVmG3UQIxAefJv1mEYsZ1WQLTWqmdqeGEwbI2t6fbZgp9TqTYARuA==", - "dev": true, - "license": "ISC" - }, - "node_modules/fast-png": { - "version": "6.4.0", - "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", - "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/pako": "^2.0.3", - "iobuffer": "^5.3.2", - "pako": "^2.1.0" - } - }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", @@ -3818,22 +4032,6 @@ "integrity": "sha512-OP2IUU6HeYKJi3i0z4A19kHMQoLVs4Hc+DPqqxI2h/DPZHTm/vjsfC6P0b4jCMy14XizLBqvndQ+UilD7707Jw==", "license": "MIT" }, - "node_modules/fft.js": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/fft.js/-/fft.js-4.0.4.tgz", - "integrity": "sha512-f9c00hphOgeQTlDyavwTtu6RiK8AIFjD6+jvXkNkpeQ7rirK3uFWVpalkoS4LAwbdX7mfZ8aoBfFVQX1Re/8aw==", - "dev": true, - "license": "MIT" - }, - "node_modules/file-type": { - "version": "10.11.0", - "resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz", - "integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3919,19 +4117,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/foreground-child/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/form-data": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", @@ -3988,20 +4173,6 @@ "node": ">= 0.8" } }, - "node_modules/fs-extra": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", - "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -4085,9 +4256,9 @@ } }, "node_modules/get-port": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", - "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.2.0.tgz", + "integrity": "sha512-afP4W205ONCuMoPBqcR6PSXnzX35KTcJygfJfcp+QY+uwm3p20p1YczWXhlICIzGMCxYBQcySEcOgsJcrkyobg==", "dev": true, "license": "MIT", "engines": { @@ -4114,6 +4285,8 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -4125,6 +4298,7 @@ "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": { @@ -4155,9 +4329,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", "dev": true, "license": "MIT", "dependencies": { @@ -4195,29 +4369,8 @@ "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" - }, - "node_modules/handlebars": { - "version": "4.7.9", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", - "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true }, "node_modules/has-flag": { "version": "4.0.0", @@ -4228,14 +4381,6 @@ "node": ">=8" } }, - "node_modules/has-own": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-own/-/has-own-1.0.1.tgz", - "integrity": "sha512-RDKhzgQTQfMaLvIFhjahU+2gGnRBK6dYOd5Gd9BzkmnBneOCRYjRC003RIMrdAbH52+l+CnMS4bBCXGer8tEhg==", - "deprecated": "This project is not maintained. Use Object.hasOwn() instead.", - "dev": true, - "license": "MIT" - }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -4363,55 +4508,6 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, - "node_modules/image-js": { - "version": "0.37.0", - "resolved": "https://registry.npmjs.org/image-js/-/image-js-0.37.0.tgz", - "integrity": "sha512-t/6CE+H0mT9TKFhlm1h84e9Y1W/ZNAgLUvYH+ogWSZOMUWXLdtUPVJH3EppCCR+EgwSqxbcuiVlPqDPEa9dNtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@swiftcarrot/color-fns": "^3.2.0", - "blob-util": "^2.0.2", - "canny-edge-detector": "^1.0.0", - "fast-bmp": "^2.0.1", - "fast-jpeg": "^1.0.1", - "fast-list": "^1.0.3", - "fast-png": "^6.2.0", - "has-own": "^1.0.1", - "image-type": "^4.1.0", - "is-array-type": "^1.0.0", - "is-integer": "^1.0.7", - "jpeg-js": "^0.4.3", - "js-priority-queue": "^0.1.5", - "js-quantities": "^1.7.6", - "median-quickselect": "^1.0.1", - "ml-convolution": "0.2.0", - "ml-disjoint-set": "^1.0.0", - "ml-matrix": "^6.8.0", - "ml-matrix-convolution": "0.4.3", - "ml-regression": "^5.0.0", - "monotone-chain-convex-hull": "^1.0.0", - "new-array": "^1.0.0", - "robust-point-in-polygon": "^1.0.3", - "tiff": "^5.0.2", - "web-worker-manager": "^0.2.0" - }, - "engines": { - "node": ">= 16.0.0" - } - }, - "node_modules/image-type": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/image-type/-/image-type-4.1.0.tgz", - "integrity": "sha512-CFJMJ8QK8lJvRlTCEgarL4ro6hfDQKif2HjSvYCdQZESaIPV4v9imrf7BQHK+sQeTeNeMpWciR9hyC/g8ybXEg==", - "dev": true, - "dependencies": { - "file-type": "^10.10.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -4459,13 +4555,6 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/iobuffer": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", - "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", - "dev": true, - "license": "MIT" - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4474,20 +4563,6 @@ "node": ">= 0.10" } }, - "node_modules/is-any-array": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-any-array/-/is-any-array-2.0.1.tgz", - "integrity": "sha512-UtilS7hLRu++wb/WBAw9bNuP1Eg04Ivn1vERJck8zJthEvXCBEBpGR/33u/xLKWEQf95803oalHrVDptcAvFdQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/is-array-type": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-array-type/-/is-array-type-1.0.0.tgz", - "integrity": "sha512-LLwKQdMAO/XUkq4XTed1VYqwR2OahiwkBg+yUtZT88LXX4MLXP28qGsVfSNVP8X0wc7fzDhcZD3nns/IK8UfKw==", - "dev": true, - "license": "MIT" - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -4516,19 +4591,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-finite": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", - "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4561,16 +4623,6 @@ "node": ">=0.10.0" } }, - "node_modules/is-integer": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/is-integer/-/is-integer-1.0.7.tgz", - "integrity": "sha512-RPQc/s9yBHSvpi+hs9dYiJ2cuFeU6x3TyyIp8O2H6SKEltIvJOzRj9ToyvcStDvPR/pS4rxgr1oBFajQjZ2Szg==", - "dev": true, - "license": "WTFPL OR ISC", - "dependencies": { - "is-finite": "^1.0.0" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -4705,16 +4757,16 @@ } }, "node_modules/jest": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest/-/jest-30.2.0.tgz", - "integrity": "sha512-F26gjC0yWN8uAA5m5Ss8ZQf5nDHWGlN/xWZIh8S5SRbsEKBovwZhxGd6LJlbZYxBgCYOtreSUyb8hpXyGC5O4A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-30.3.0.tgz", + "integrity": "sha512-AkXIIFcaazymvey2i/+F94XRnM6TsVLZDhBMLsd1Sf/W0wzsvvpjeyUrCZD6HGG4SDYPgDJDBKeiJTBb10WzMg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", "import-local": "^3.2.0", - "jest-cli": "30.2.0" + "jest-cli": "30.3.0" }, "bin": { "jest": "bin/jest.js" @@ -4732,14 +4784,14 @@ } }, "node_modules/jest-changed-files": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.2.0.tgz", - "integrity": "sha512-L8lR1ChrRnSdfeOvTrwZMlnWV8G/LLjQ0nG9MBclwWZidA2N5FviRki0Bvh20WRMOX31/JYvzdqTJrk5oBdydQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-30.3.0.tgz", + "integrity": "sha512-B/7Cny6cV5At6M25EWDgf9S617lHivamL8vl6KEpJqkStauzcG4e+WPfDgMMF+H4FVH4A2PLRyvgDJan4441QA==", "dev": true, "license": "MIT", "dependencies": { "execa": "^5.1.1", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "p-limit": "^3.1.0" }, "engines": { @@ -4747,29 +4799,29 @@ } }, "node_modules/jest-circus": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.2.0.tgz", - "integrity": "sha512-Fh0096NC3ZkFx05EP2OXCxJAREVxj1BcW/i6EWqqymcgYKWjyyDpral3fMxVcHXg6oZM7iULer9wGRFvfpl+Tg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-30.3.0.tgz", + "integrity": "sha512-PyXq5szeSfR/4f1lYqCmmQjh0vqDkURUYi9N6whnHjlRz4IUQfMcXkGLeEoiJtxtyPqgUaUUfyQlApXWBSN1RA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/expect": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/expect": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "co": "^4.6.0", "dedent": "^1.6.0", "is-generator-fn": "^2.1.0", - "jest-each": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-runtime": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-each": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-runtime": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", "p-limit": "^3.1.0", - "pretty-format": "30.2.0", + "pretty-format": "30.3.0", "pure-rand": "^7.0.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" @@ -4779,21 +4831,21 @@ } }, "node_modules/jest-cli": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.2.0.tgz", - "integrity": "sha512-Os9ukIvADX/A9sLt6Zse3+nmHtHaE6hqOsjQtNiugFTbKRHYIYtZXNGNK9NChseXy7djFPjndX1tL0sCTlfpAA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-30.3.0.tgz", + "integrity": "sha512-l6Tqx+j1fDXJEW5bqYykDQQ7mQg+9mhWXtnj+tQZrTWYHyHoi6Be8HPumDSA+UiX2/2buEgjA58iJzdj146uCw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/core": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", "exit-x": "^0.2.2", "import-local": "^3.2.0", - "jest-config": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-config": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "yargs": "^17.7.2" }, "bin": { @@ -4812,34 +4864,33 @@ } }, "node_modules/jest-config": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.2.0.tgz", - "integrity": "sha512-g4WkyzFQVWHtu6uqGmQR4CQxz/CH3yDSlhzXMWzNjDx843gYjReZnMRanjRCq5XZFuQrGDxgUaiYWE8BRfVckA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-30.3.0.tgz", + "integrity": "sha512-WPMAkMAtNDY9P/oKObtsRG/6KTrhtgPJoBTmk20uDn4Uy6/3EJnnaZJre/FMT1KVRx8cve1r7/FlMIOfRVWL4w==", "dev": true, "license": "MIT", "dependencies": { "@babel/core": "^7.27.4", "@jest/get-type": "30.1.0", "@jest/pattern": "30.0.1", - "@jest/test-sequencer": "30.2.0", - "@jest/types": "30.2.0", - "babel-jest": "30.2.0", + "@jest/test-sequencer": "30.3.0", + "@jest/types": "30.3.0", + "babel-jest": "30.3.0", "chalk": "^4.1.2", "ci-info": "^4.2.0", "deepmerge": "^4.3.1", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-circus": "30.2.0", + "jest-circus": "30.3.0", "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", + "jest-environment-node": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-runner": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", - "micromatch": "^4.0.8", + "jest-resolve": "30.3.0", + "jest-runner": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "parse-json": "^5.2.0", - "pretty-format": "30.2.0", + "pretty-format": "30.3.0", "slash": "^3.0.0", "strip-json-comments": "^3.1.1" }, @@ -4864,16 +4915,16 @@ } }, "node_modules/jest-diff": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.2.0.tgz", - "integrity": "sha512-dQHFo3Pt4/NLlG5z4PxZ/3yZTZ1C7s9hveiOj+GCN+uT109NC2QgsoVZsVOAvbJ3RgKkvyLGXZV9+piDpWbm6A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/diff-sequences": "30.0.1", + "@jest/diff-sequences": "30.3.0", "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -4893,57 +4944,57 @@ } }, "node_modules/jest-each": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.2.0.tgz", - "integrity": "sha512-lpWlJlM7bCUf1mfmuqTA8+j2lNURW9eNafOy99knBM01i5CQeY5UH1vZjgT9071nDJac1M4XsbyI44oNOdhlDQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-30.3.0.tgz", + "integrity": "sha512-V8eMndg/aZ+3LnCJgSm13IxS5XSBM22QSZc9BtPK8Dek6pm+hfUNfwBdvsB3d342bo1q7wnSkC38zjX259qZNA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "chalk": "^4.1.2", - "jest-util": "30.2.0", - "pretty-format": "30.2.0" + "jest-util": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-environment-node": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.2.0.tgz", - "integrity": "sha512-ElU8v92QJ9UrYsKrxDIKCxu6PfNj4Hdcktcn0JX12zqNdqWHB0N+hwOnnBBXvjLd2vApZtuLUGs1QSY+MsXoNA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-30.3.0.tgz", + "integrity": "sha512-4i6HItw/JSiJVsC5q0hnKIe/hbYfZLVG9YJ/0pU9Hz2n/9qZe3Rhn5s5CUZA5ORZlcdT/vmAXRMyONXJwPrmYQ==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/types": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "30.2.0", - "jest-util": "30.2.0", - "jest-validate": "30.2.0" + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-haste-map": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.2.0.tgz", - "integrity": "sha512-sQA/jCb9kNt+neM0anSj6eZhLZUIhQgwDt7cPGjumgLM4rXsfb9kpnlacmvZz3Q5tb80nS+oG/if+NBKrHC+Xw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-30.3.0.tgz", + "integrity": "sha512-mMi2oqG4KRU0R9QEtscl87JzMXfUhbKaFqOxmjb2CKcbHcUGFrJCBWHmnTiUqi6JcnzoBlO4rWfpdl2k/RfLCA==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "anymatch": "^3.1.3", "fb-watchman": "^2.0.2", "graceful-fs": "^4.2.11", "jest-regex-util": "30.0.1", - "jest-util": "30.2.0", - "jest-worker": "30.2.0", - "micromatch": "^4.0.8", + "jest-util": "30.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", "walker": "^1.0.8" }, "engines": { @@ -4953,50 +5004,63 @@ "fsevents": "^2.3.3" } }, + "node_modules/jest-haste-map/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-leak-detector": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.2.0.tgz", - "integrity": "sha512-M6jKAjyzjHG0SrQgwhgZGy9hFazcudwCNovY/9HPIicmNSBuockPSedAP9vlPK6ONFJ1zfyH/M2/YYJxOz5cdQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-30.3.0.tgz", + "integrity": "sha512-cuKmUUGIjfXZAiGJ7TbEMx0bcqNdPPI6P1V+7aF+m/FUJqFDxkFR4JqkTu8ZOiU5AaX/x0hZ20KaaIPXQzbMGQ==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.2.0.tgz", - "integrity": "sha512-dQ94Nq4dbzmUWkQ0ANAWS9tBRfqCrn0bV9AMYdOi/MHW726xn7eQmMeRTpX2ViC00bpNaWXq+7o4lIQ3AX13Hg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-30.3.0.tgz", + "integrity": "sha512-HEtc9uFQgaUHkC7nLSlQL3Tph4Pjxt/yiPvkIrrDCt9jhoLIgxaubo1G+CFOnmHYMxHwwdaSN7mkIFs6ZK8OhA==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", "chalk": "^4.1.2", - "jest-diff": "30.2.0", - "pretty-format": "30.2.0" + "jest-diff": "30.3.0", + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-message-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.2.0.tgz", - "integrity": "sha512-y4DKFLZ2y6DxTWD4cDe07RglV88ZiNEdlRfGtqahfbIjfsw1nMCPx49Uev4IA/hWn3sDKyAnSPwoYSsAEdcimw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-30.3.0.tgz", + "integrity": "sha512-Z/j4Bo+4ySJ+JPJN3b2Qbl9hDq3VrXmnjjGEWD/x0BCXeOXPTV1iZYYzl2X8c1MaCOL+ewMyNBcm88sboE6YWw==", "dev": true, "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/stack-utils": "^2.0.3", "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "micromatch": "^4.0.8", - "pretty-format": "30.2.0", + "picomatch": "^4.0.3", + "pretty-format": "30.3.0", "slash": "^3.0.0", "stack-utils": "^2.0.6" }, @@ -5004,16 +5068,29 @@ "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, + "node_modules/jest-message-util/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/jest-mock": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.2.0.tgz", - "integrity": "sha512-JNNNl2rj4b5ICpmAcq+WbLH83XswjPbjH4T7yvGzfAGCPh1rw+xVNbtk+FnRslvt9lkCcdn9i1oAoKUuFsOxRw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-30.3.0.tgz", + "integrity": "sha512-OTzICK8CpE+t4ndhKrwlIdbM6Pn8j00lvmSmq5ejiO+KxukbLjgOflKWMn3KE34EZdQm5RqTuKj+5RIEniYhog==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "30.2.0" + "jest-util": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -5048,18 +5125,18 @@ } }, "node_modules/jest-resolve": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.2.0.tgz", - "integrity": "sha512-TCrHSxPlx3tBY3hWNtRQKbtgLhsXa1WmbJEqBlTBrGafd5fiQFByy2GNCEoGR+Tns8d15GaL9cxEzKOO3GEb2A==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-30.3.0.tgz", + "integrity": "sha512-NRtTAHQlpd15F9rUR36jqwelbrDV/dY4vzNte3S2kxCKUJRYNd5/6nTSbYiak1VX5g8IoFF23Uj5TURkUW8O5g==", "dev": true, "license": "MIT", "dependencies": { "chalk": "^4.1.2", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", + "jest-haste-map": "30.3.0", "jest-pnp-resolver": "^1.2.3", - "jest-util": "30.2.0", - "jest-validate": "30.2.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.0", "slash": "^3.0.0", "unrs-resolver": "^1.7.11" }, @@ -5068,46 +5145,46 @@ } }, "node_modules/jest-resolve-dependencies": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.2.0.tgz", - "integrity": "sha512-xTOIGug/0RmIe3mmCqCT95yO0vj6JURrn1TKWlNbhiAefJRWINNPgwVkrVgt/YaerPzY3iItufd80v3lOrFJ2w==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-30.3.0.tgz", + "integrity": "sha512-9ev8s3YN6Hsyz9LV75XUwkCVFlwPbaFn6Wp75qnI0wzAINYWY8Fb3+6y59Rwd3QaS3kKXffHXsZMziMavfz/nw==", "dev": true, "license": "MIT", "dependencies": { "jest-regex-util": "30.0.1", - "jest-snapshot": "30.2.0" + "jest-snapshot": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" } }, "node_modules/jest-runner": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.2.0.tgz", - "integrity": "sha512-PqvZ2B2XEyPEbclp+gV6KO/F1FIFSbIwewRgmROCMBo/aZ6J1w8Qypoj2pEOcg3G2HzLlaP6VUtvwCI8dM3oqQ==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-30.3.0.tgz", + "integrity": "sha512-gDv6C9LGKWDPLia9TSzZwf4h3kMQCqyTpq+95PODnTRDO0g9os48XIYYkS6D236vjpBir2fF63YmJFtqkS5Duw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/console": "30.2.0", - "@jest/environment": "30.2.0", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/console": "30.3.0", + "@jest/environment": "30.3.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "emittery": "^0.13.1", "exit-x": "^0.2.2", "graceful-fs": "^4.2.11", "jest-docblock": "30.2.0", - "jest-environment-node": "30.2.0", - "jest-haste-map": "30.2.0", - "jest-leak-detector": "30.2.0", - "jest-message-util": "30.2.0", - "jest-resolve": "30.2.0", - "jest-runtime": "30.2.0", - "jest-util": "30.2.0", - "jest-watcher": "30.2.0", - "jest-worker": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-haste-map": "30.3.0", + "jest-leak-detector": "30.3.0", + "jest-message-util": "30.3.0", + "jest-resolve": "30.3.0", + "jest-runtime": "30.3.0", + "jest-util": "30.3.0", + "jest-watcher": "30.3.0", + "jest-worker": "30.3.0", "p-limit": "^3.1.0", "source-map-support": "0.5.13" }, @@ -5116,32 +5193,32 @@ } }, "node_modules/jest-runtime": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.2.0.tgz", - "integrity": "sha512-p1+GVX/PJqTucvsmERPMgCPvQJpFt4hFbM+VN3n8TMo47decMUcJbt+rgzwrEme0MQUA/R+1de2axftTHkKckg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-30.3.0.tgz", + "integrity": "sha512-CgC+hIBJbuh78HEffkhNKcbXAytQViplcl8xupqeIWyKQF50kCQA8J7GeJCkjisC6hpnC9Muf8jV5RdtdFbGng==", "dev": true, "license": "MIT", "dependencies": { - "@jest/environment": "30.2.0", - "@jest/fake-timers": "30.2.0", - "@jest/globals": "30.2.0", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", "@jest/source-map": "30.0.1", - "@jest/test-result": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "cjs-module-lexer": "^2.1.0", "collect-v8-coverage": "^1.0.2", - "glob": "^10.3.10", + "glob": "^10.5.0", "graceful-fs": "^4.2.11", - "jest-haste-map": "30.2.0", - "jest-message-util": "30.2.0", - "jest-mock": "30.2.0", + "jest-haste-map": "30.3.0", + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", "jest-regex-util": "30.0.1", - "jest-resolve": "30.2.0", - "jest-snapshot": "30.2.0", - "jest-util": "30.2.0", + "jest-resolve": "30.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.0", "slash": "^3.0.0", "strip-bom": "^4.0.0" }, @@ -5150,9 +5227,9 @@ } }, "node_modules/jest-snapshot": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.2.0.tgz", - "integrity": "sha512-5WEtTy2jXPFypadKNpbNkZ72puZCa6UjSr/7djeecHWOu7iYhSXSnHScT8wBz3Rn8Ena5d5RYRcsyKIeqG1IyA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5161,20 +5238,20 @@ "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/types": "^7.27.3", - "@jest/expect-utils": "30.2.0", + "@jest/expect-utils": "30.3.0", "@jest/get-type": "30.1.0", - "@jest/snapshot-utils": "30.2.0", - "@jest/transform": "30.2.0", - "@jest/types": "30.2.0", + "@jest/snapshot-utils": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.0", "babel-preset-current-node-syntax": "^1.2.0", "chalk": "^4.1.2", - "expect": "30.2.0", + "expect": "30.3.0", "graceful-fs": "^4.2.11", - "jest-diff": "30.2.0", - "jest-matcher-utils": "30.2.0", - "jest-message-util": "30.2.0", - "jest-util": "30.2.0", - "pretty-format": "30.2.0", + "jest-diff": "30.3.0", + "jest-matcher-utils": "30.3.0", + "jest-message-util": "30.3.0", + "jest-util": "30.3.0", + "pretty-format": "30.3.0", "semver": "^7.7.2", "synckit": "^0.11.8" }, @@ -5183,9 +5260,9 @@ } }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -5196,18 +5273,18 @@ } }, "node_modules/jest-util": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.2.0.tgz", - "integrity": "sha512-QKNsM0o3Xe6ISQU869e+DhG+4CK/48aHYdJZGlFQVTjnbvgpcKyxpzk29fGiO7i/J8VENZ+d2iGnSsvmuHywlA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-30.3.0.tgz", + "integrity": "sha512-/jZDa00a3Sz7rdyu55NLrQCIrbyIkbBxareejQI315f/i8HjYN+ZWsDLLpoQSiUIEIyZF/R8fDg3BmB8AtHttg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "@types/node": "*", "chalk": "^4.1.2", "ci-info": "^4.2.0", "graceful-fs": "^4.2.11", - "picomatch": "^4.0.2" + "picomatch": "^4.0.3" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -5227,18 +5304,18 @@ } }, "node_modules/jest-validate": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.2.0.tgz", - "integrity": "sha512-FBGWi7dP2hpdi8nBoWxSsLvBFewKAg0+uSQwBaof4Y4DPgBabXgpSYC5/lR7VmnIlSpASmCi/ntRWPbv7089Pw==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-30.3.0.tgz", + "integrity": "sha512-I/xzC8h5G+SHCb2P2gWkJYrNiTbeL47KvKeW5EzplkyxzBRBw1ssSHlI/jXec0ukH2q7x2zAWQm7015iusg62Q==", "dev": true, "license": "MIT", "dependencies": { "@jest/get-type": "30.1.0", - "@jest/types": "30.2.0", + "@jest/types": "30.3.0", "camelcase": "^6.3.0", "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "30.2.0" + "pretty-format": "30.3.0" }, "engines": { "node": "^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0" @@ -5258,19 +5335,19 @@ } }, "node_modules/jest-watcher": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.2.0.tgz", - "integrity": "sha512-PYxa28dxJ9g777pGm/7PrbnMeA0Jr7osHP9bS7eJy9DuAjMgdGtxgf0uKMyoIsTWAkIbUW5hSDdJ3urmgXBqxg==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-30.3.0.tgz", + "integrity": "sha512-PJ1d9ThtTR8aMiBWUdcownq9mDdLXsQzJayTk4kmaBRHKvwNQn+ANveuhEBUyNI2hR1TVhvQ8D5kHubbzBHR/w==", "dev": true, "license": "MIT", "dependencies": { - "@jest/test-result": "30.2.0", - "@jest/types": "30.2.0", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", "ansi-escapes": "^4.3.2", "chalk": "^4.1.2", "emittery": "^0.13.1", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "string-length": "^4.0.2" }, "engines": { @@ -5278,15 +5355,15 @@ } }, "node_modules/jest-worker": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.2.0.tgz", - "integrity": "sha512-0Q4Uk8WF7BUwqXHuAjc23vmopWJw5WH7w2tqBoUOZpOjW/ZnR44GXXd1r82RvnmI2GZge3ivrYXk/BE2+VtW2g==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-30.3.0.tgz", + "integrity": "sha512-DrCKkaQwHexjRUFTmPzs7sHQe0TSj9nvDALKGdwmK5mW9v7j90BudWirKAJHt3QQ9Dhrg1F7DogPzhChppkJpQ==", "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", "@ungap/structured-clone": "^1.3.0", - "jest-util": "30.2.0", + "jest-util": "30.3.0", "merge-stream": "^2.0.0", "supports-color": "^8.1.1" }, @@ -5310,30 +5387,11 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, - "node_modules/jpeg-js": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/jpeg-js/-/jpeg-js-0.4.4.tgz", - "integrity": "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==", - "dev": true - }, "node_modules/js-md4": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/js-md4/-/js-md4-0.3.2.tgz", "integrity": "sha512-/GDnfQYsltsjRswQhN9fhv3EMw2sCpUdrdxyWDOUK7eyD++r3gRhzgiQgc/x4MAv2i1iuQ4lxO5mvqM3vj4bwA==" }, - "node_modules/js-priority-queue": { - "version": "0.1.5", - "resolved": "https://registry.npmjs.org/js-priority-queue/-/js-priority-queue-0.1.5.tgz", - "integrity": "sha512-2dPmJT4GbXUpob7AZDR1wFMKz3Biy6oW69mwt5PTtdeoOgDin1i0p5gUV9k0LFeUxDpwkfr+JGMZDpcprjiY5w==", - "dev": true - }, - "node_modules/js-quantities": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/js-quantities/-/js-quantities-1.8.0.tgz", - "integrity": "sha512-swDw9RJpXACAWR16vAKoSojAsP6NI7cZjjnjKqhOyZSdybRUdmPr071foD3fejUKSU2JMHz99hflWkRWvfLTpQ==", - "dev": true, - "license": "MIT" - }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -5387,24 +5445,20 @@ "node": ">=6" } }, - "node_modules/jsonfile": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", - "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "dev": true, + "license": "MIT" }, "node_modules/jsonwebtoken": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.2.tgz", - "integrity": "sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", "license": "MIT", "dependencies": { - "jws": "^3.2.2", + "jws": "^4.0.1", "lodash.includes": "^4.3.0", "lodash.isboolean": "^3.0.3", "lodash.isinteger": "^4.0.4", @@ -5420,27 +5474,6 @@ "npm": ">=6" } }, - "node_modules/jsonwebtoken/node_modules/jwa": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.2.tgz", - "integrity": "sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jsonwebtoken/node_modules/jws": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.3.tgz", - "integrity": "sha512-byiJ0FLRdLdSVSReO/U4E7RoEyOCKnEnEPMjq3HxWtvzLsV08/i5RQKsFVNkCldrCaPr2vDNAOMsfs8T/Hze7g==", - "license": "MIT", - "dependencies": { - "jwa": "^1.4.2", - "safe-buffer": "^5.0.1" - } - }, "node_modules/jsonwebtoken/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -5528,9 +5561,10 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, "license": "MIT" }, "node_modules/lodash.includes": { @@ -5563,12 +5597,6 @@ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", - "dev": true - }, "node_modules/lodash.once": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", @@ -5618,9 +5646,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, "license": "ISC", "bin": { @@ -5663,13 +5691,6 @@ "node": ">= 0.8" } }, - "node_modules/median-quickselect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/median-quickselect/-/median-quickselect-1.0.1.tgz", - "integrity": "sha512-/QL9ptNuLsdA68qO+2o10TKCyu621zwwTFdLvtu8rzRNKsn8zvuGoq/vDxECPyELFG8wu+BpyoMR9BnsJqfVZQ==", - "dev": true, - "license": "ISC" - }, "node_modules/merge-descriptors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", @@ -5698,20 +5719,6 @@ "node": ">= 0.6" } }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, "node_modules/mime": { "version": "2.6.0", "resolved": "https://registry.npmjs.org/mime/-/mime-2.6.0.tgz", @@ -5769,314 +5776,18 @@ "brace-expansion": "^1.1.7" }, "engines": { - "node": "*" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/ml-array-max": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/ml-array-max/-/ml-array-max-1.2.4.tgz", - "integrity": "sha512-BlEeg80jI0tW6WaPyGxf5Sa4sqvcyY6lbSn5Vcv44lp1I2GR6AWojfUvLnGTNsIXrZ8uqWmo8VcG1WpkI2ONMQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-any-array": "^2.0.0" - } - }, - "node_modules/ml-array-median": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/ml-array-median/-/ml-array-median-1.1.6.tgz", - "integrity": "sha512-V6bV6bTPFRX8v5CaAx/7fuRXC39LLTHfPSVZZafdNaqNz2PFL5zEA7gesjv8dMXh+gwPeUMtB5QPovlTBaa4sw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-any-array": "^2.0.0", - "median-quickselect": "^1.0.1" - } - }, - "node_modules/ml-array-min": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/ml-array-min/-/ml-array-min-1.2.3.tgz", - "integrity": "sha512-VcZ5f3VZ1iihtrGvgfh/q0XlMobG6GQ8FsNyQXD3T+IlstDv85g8kfV0xUG1QPRO/t21aukaJowDzMTc7j5V6Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-any-array": "^2.0.0" - } - }, - "node_modules/ml-array-rescale": { - "version": "1.3.7", - "resolved": "https://registry.npmjs.org/ml-array-rescale/-/ml-array-rescale-1.3.7.tgz", - "integrity": "sha512-48NGChTouvEo9KBctDfHC3udWnQKNKEWN0ziELvY3KG25GR5cA8K8wNVzracsqSW1QEkAXjTNx+ycgAv06/1mQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-any-array": "^2.0.0", - "ml-array-max": "^1.2.4", - "ml-array-min": "^1.2.3" - } - }, - "node_modules/ml-convolution": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/ml-convolution/-/ml-convolution-0.2.0.tgz", - "integrity": "sha512-km5f81jFVnEWG0eFEKAwt00X3xGUIAcUqZZlUk+w0q2sZOz1vkEYhIKOXAlmaEi9rnrTknxW//Ttm399zPzDPg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fft.js": "^4.0.3", - "next-power-of-two": "^1.0.0" - } - }, - "node_modules/ml-disjoint-set": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/ml-disjoint-set/-/ml-disjoint-set-1.0.0.tgz", - "integrity": "sha512-UcEzgvRzVhsKpT66syfdhaK8R+av6GxDFmU37t+6WClT/kHDIN6OMRfO7OPwQIV8+L8FSc2E6lNKpvdqf6OgLw==", - "dev": true, - "license": "MIT" - }, - "node_modules/ml-distance-euclidean": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ml-distance-euclidean/-/ml-distance-euclidean-2.0.0.tgz", - "integrity": "sha512-yC9/2o8QF0A3m/0IXqCTXCzz2pNEzvmcE/9HFKOZGnTjatvBbsn4lWYJkxENkA4Ug2fnYl7PXQxnPi21sgMy/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/ml-fft": { - "version": "1.3.5", - "resolved": "https://registry.npmjs.org/ml-fft/-/ml-fft-1.3.5.tgz", - "integrity": "sha512-laAATDyUuWPbIlX57thIds41wqFLsB+Zl7i1yrLRo/4CFg+hFaF9Xle8InblQseyiaVtt1KSlDG+6lgUMPOj3g==", - "dev": true, - "license": "MIT" - }, - "node_modules/ml-kernel": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ml-kernel/-/ml-kernel-3.0.0.tgz", - "integrity": "sha512-R+ZR0Kl5xJ7vnxtlDqjZ26xVk7mAw7ctK4NlzRHviBFXxp7keC9+hWirMOdzi2DOQA0t6CaRwjElZ6SdirOmow==", - "dev": true, - "license": "MIT", - "dependencies": { - "ml-distance-euclidean": "^2.0.0", - "ml-kernel-gaussian": "^2.0.2", - "ml-kernel-polynomial": "^2.0.1", - "ml-kernel-sigmoid": "^1.0.1", - "ml-matrix": "^6.1.2" - } - }, - "node_modules/ml-kernel-gaussian": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/ml-kernel-gaussian/-/ml-kernel-gaussian-2.0.2.tgz", - "integrity": "sha512-5MBrH2g9MBO53I6mcyXvMhyOLsmO2w21+26A1ZV/vYoxqpsov2PWkT8bhdFCEe0kgDupmAb6u81iOID/rhnarA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ml-distance-euclidean": "^2.0.0" - } - }, - "node_modules/ml-kernel-polynomial": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ml-kernel-polynomial/-/ml-kernel-polynomial-2.0.1.tgz", - "integrity": "sha512-aGDNRPHDiKeJmBxB0L9wTxKNLfp5JytbdRIo5K+FTcmFjkWDe3YZPo6R6wBB5mxaJ5eqTRawzeV4RoIWHbakyQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/ml-kernel-sigmoid": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ml-kernel-sigmoid/-/ml-kernel-sigmoid-1.0.1.tgz", - "integrity": "sha512-mSbYOSbNQ7GsUAGrHuUHNsLgM3bZGpXkotw/FBdKZD9YMXfVOgQb1LvvvVeSlOR/ZdmX23qqaV0RnKSYWBF8og==", - "dev": true, - "license": "MIT" - }, - "node_modules/ml-matrix": { - "version": "6.12.1", - "resolved": "https://registry.npmjs.org/ml-matrix/-/ml-matrix-6.12.1.tgz", - "integrity": "sha512-TJ+8eOFdp+INvzR4zAuwBQJznDUfktMtOB6g/hUcGh3rcyjxbz4Te57Pgri8Q9bhSQ7Zys4IYOGhFdnlgeB6Lw==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-any-array": "^2.0.1", - "ml-array-rescale": "^1.3.7" - } - }, - "node_modules/ml-matrix-convolution": { - "version": "0.4.3", - "resolved": "https://registry.npmjs.org/ml-matrix-convolution/-/ml-matrix-convolution-0.4.3.tgz", - "integrity": "sha512-B4AATOjxDw4J0oVcoeYHsXrhMr31x9SWhVKZjWucDU+brwXLR0enMdqb1OuRy/REdpL5/iSshA46sS2B1dO2OQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ml-fft": "1.3.5", - "ml-stat": "^1.2.0" - } - }, - "node_modules/ml-regression": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/ml-regression/-/ml-regression-5.0.0.tgz", - "integrity": "sha512-mBn0LpfEWV3Dk0dj+8PRNUqIHvO87rUY0PmCUTYv3MKfECx7TtlKyeacJeOBLZ4YAVixX8U5hn4HwRL6TpTYaw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ml-kernel": "^3.0.0", - "ml-matrix": "^6.1.2", - "ml-regression-base": "^2.0.1", - "ml-regression-exponential": "^2.0.0", - "ml-regression-multivariate-linear": "^2.0.2", - "ml-regression-polynomial": "^2.0.0", - "ml-regression-power": "^2.0.0", - "ml-regression-robust-polynomial": "^2.0.0", - "ml-regression-simple-linear": "^2.0.2", - "ml-regression-theil-sen": "^2.0.0" - } - }, - "node_modules/ml-regression-base": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/ml-regression-base/-/ml-regression-base-2.1.6.tgz", - "integrity": "sha512-yTckvEc8szc6VrUTJSgAClShvCoPZdNt8pmyRe8aGsIWGjg6bYFotp9mDUwAB0snvKAbQWd6A4trL/PDCASLug==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-any-array": "^2.0.0" - } - }, - "node_modules/ml-regression-exponential": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ml-regression-exponential/-/ml-regression-exponential-2.1.3.tgz", - "integrity": "sha512-TE7xIlsHqKdLIgJ5d2rYVrGTd+NkjSBH8bLf1umLFiQI5hR7mUPmMoX+LalVJMd8NhlOwQzufOHqaPewMJ7S6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "ml-regression-base": "^3.0.0", - "ml-regression-simple-linear": "^3.0.0" - } - }, - "node_modules/ml-regression-exponential/node_modules/ml-regression-base": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/ml-regression-base/-/ml-regression-base-3.0.0.tgz", - "integrity": "sha512-qkQWvNk8VU1LIytjid/+YHOSx8GnEU9dCUPsAQ8AzCh4saijrsni/XA6x7r+N1UrHMDHeSEUBtRZTsl2syyu/A==", - "dev": true, - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.7.2", - "is-any-array": "^2.0.1" - } - }, - "node_modules/ml-regression-exponential/node_modules/ml-regression-simple-linear": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ml-regression-simple-linear/-/ml-regression-simple-linear-3.0.1.tgz", - "integrity": "sha512-SF2oxA+034Co9GVQSFuS3vtACaRAFrEwHi9oX6VTaSY/KtXxseL3d4GApj4jWXMoAgrP7VMoIO1PH0RoZaMR1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.7.3", - "ml-regression-base": "^4.0.0" - } - }, - "node_modules/ml-regression-exponential/node_modules/ml-regression-simple-linear/node_modules/ml-regression-base": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/ml-regression-base/-/ml-regression-base-4.0.0.tgz", - "integrity": "sha512-V2VjB+K/BcgXaX450xvYw36TLOB+piD9G1pHU3VE+ggQUApsVGkYco6UMQykFOwBydHnDTbOiybH/lwrkqFT4g==", - "dev": true, - "license": "MIT", - "dependencies": { - "cheminfo-types": "^1.7.3", - "is-any-array": "^2.0.1" - } - }, - "node_modules/ml-regression-multivariate-linear": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/ml-regression-multivariate-linear/-/ml-regression-multivariate-linear-2.0.4.tgz", - "integrity": "sha512-/vShPAlP+mB7P2mC5TuXwObSJNl/UBI71/bszt9ilTg6yLKy6btDLpAYyJNa6t+JnL5a7q+Yy4dCltfpvqXRIw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ml-matrix": "^6.10.1" - } - }, - "node_modules/ml-regression-polynomial": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/ml-regression-polynomial/-/ml-regression-polynomial-2.2.0.tgz", - "integrity": "sha512-WxFsEmi6oLxgq9TeaVoAA+vVUJFp1kGarX6WWClR8OmlanoIW5iLMnaeXfQcYuH8xNq4R1Cax2N9hYYmeWWkLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ml-matrix": "^6.8.0", - "ml-regression-base": "^2.1.3" - } - }, - "node_modules/ml-regression-power": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ml-regression-power/-/ml-regression-power-2.0.0.tgz", - "integrity": "sha512-u8O9Fy45+OeYm/4ZBcNDn5w3w+MHc6kZz/AWSJIwmJcyjz6PRkTZnNfgGYdVKwKKDlAOS7G/AFvMKSTWRNO4RQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ml-regression-base": "^2.0.1", - "ml-regression-simple-linear": "^2.0.2" - } - }, - "node_modules/ml-regression-robust-polynomial": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/ml-regression-robust-polynomial/-/ml-regression-robust-polynomial-2.0.1.tgz", - "integrity": "sha512-WkxA224Cil1G3Ug/T1O8H/2IDADlca21oC5WDplcM+gQRTqtueT/Su4ubH70tG6s79XHM046HfO8xQSpDQxqqg==", - "dev": true, - "license": "MIT", - "dependencies": { - "ml-matrix": "^6.8.0", - "ml-regression-base": "^2.1.3" - } - }, - "node_modules/ml-regression-simple-linear": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/ml-regression-simple-linear/-/ml-regression-simple-linear-2.0.5.tgz", - "integrity": "sha512-7DBYru8GvWLaYo4LUF9vU2DjzHuM6i6WGnVbEP9wq8nUFUZ2DlwN46m8Z/hNhTSR7+3T+RvhaSY+OqdBpaz8zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ml-regression-base": "^2.0.1" - } - }, - "node_modules/ml-regression-theil-sen": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ml-regression-theil-sen/-/ml-regression-theil-sen-2.0.0.tgz", - "integrity": "sha512-RO//tYzo69XbWDO5LIPdGp8ef1MSTPPJY0bXNlmOLMSay7YR9FQqtNgqn29T9DSYTa863VAafRlCeXwDQNXkBw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ml-array-median": "^1.1.1", - "ml-regression-base": "^2.0.1" - } - }, - "node_modules/ml-stat": { - "version": "1.3.3", - "resolved": "https://registry.npmjs.org/ml-stat/-/ml-stat-1.3.3.tgz", - "integrity": "sha512-F6plydFIKFZA+7j/pRsRrfRu4nwsruQvYD9QxHWc4hFUdASVznsKUL2hgAwgMVizY/P0+b1L9bVQexKES5y/uw==", - "dev": true, - "license": "MIT" + "node": "*" + } }, - "node_modules/monotone-chain-convex-hull": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/monotone-chain-convex-hull/-/monotone-chain-convex-hull-1.1.0.tgz", - "integrity": "sha512-iZGaoO2qtqIWaAfscTtsH2LolE06U4JzTw8AgtjT/yzYIA0aoAHDdwBtsesnQXfVRvS375Wu0Y1+FqdI5Y22GA==", + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "license": "MIT" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } }, "node_modules/morgan": { "version": "1.10.1", @@ -6156,27 +5867,6 @@ "node": ">= 0.6" } }, - "node_modules/neo-async": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", - "dev": true, - "license": "MIT" - }, - "node_modules/new-array": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/new-array/-/new-array-1.0.0.tgz", - "integrity": "sha512-K5AyFYbuHZ4e/ti52y7k18q8UHsS78FlRd85w2Fmsd6AkuLipDihPflKC0p3PN5i8II7+uHxo+CtkLiJDfmS5A==", - "dev": true, - "license": "MIT" - }, - "node_modules/next-power-of-two": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/next-power-of-two/-/next-power-of-two-1.0.0.tgz", - "integrity": "sha512-+z6QY1SxkDk6CQJAeaIZKmcNubBCRP7J8DMQUBglz/sSkNsZoJ1kULjqk9skNPPplzs4i9PFhYrvNDdtQleF/A==", - "dev": true, - "license": "MIT" - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -6197,9 +5887,9 @@ } }, "node_modules/node-html-parser": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.0.1.tgz", - "integrity": "sha512-KGtmPY2kS0thCWGK0VuPyOS+pBKhhe8gXztzA2ilAOhbUbxa9homF1bOyKvhGzMLXUoRds9IOmr/v5lr/lqNmA==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-7.1.0.tgz", + "integrity": "sha512-iJo8b2uYGT40Y8BTyy5ufL6IVbN8rbm/1QK2xffXU/1a/v3AAa0d1YAoqBNYqaS4R/HajkWIpIfdE6KcyFh1AQ==", "license": "MIT", "dependencies": { "css-select": "^5.1.0", @@ -6220,16 +5910,16 @@ "license": "MIT" }, "node_modules/nodemon": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz", - "integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==", + "version": "3.1.14", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.14.tgz", + "integrity": "sha512-jakjZi93UtB3jHMWsXL68FXSAosbLfY0In5gtKq3niLSkrWznrVBzXFNOEMJUfc9+Ke7SHWoAZsiMkNP3vq6Jw==", "dev": true, "license": "MIT", "dependencies": { "chokidar": "^3.5.2", "debug": "^4", "ignore-by-default": "^1.0.1", - "minimatch": "^3.1.2", + "minimatch": "^10.2.1", "pstree.remy": "^1.1.8", "semver": "^7.5.3", "simple-update-notifier": "^2.0.0", @@ -6248,6 +5938,29 @@ "url": "https://opencollective.com/nodemon" } }, + "node_modules/nodemon/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/nodemon/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, "node_modules/nodemon/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -6269,6 +5982,22 @@ "node": ">=10" } }, + "node_modules/nodemon/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/nodemon/node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -6312,9 +6041,9 @@ } }, "node_modules/npm-check-updates": { - "version": "19.1.2", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-19.1.2.tgz", - "integrity": "sha512-FNeFCVgPOj0fz89hOpGtxP2rnnRHR7hD2E8qNU8SMWfkyDZXA/xpgjsL3UMLSo3F/K13QvJDnbxPngulNDDo/g==", + "version": "19.6.6", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-19.6.6.tgz", + "integrity": "sha512-AvlRcnlUEyBEJfblUSjYMJwYKvCIWDRuCDa6x3hyUMTMkI3kslmFm0LDqwgzQfshfNh0Z3ouKiA4fLjRN7HejQ==", "dev": true, "license": "Apache-2.0", "bin": { @@ -6474,13 +6203,6 @@ "dev": true, "license": "BlueOak-1.0.0" }, - "node_modules/pako": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", - "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", - "dev": true, - "license": "(MIT AND Zlib)" - }, "node_modules/parse-json": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", @@ -6616,9 +6338,9 @@ } }, "node_modules/pretty-format": { - "version": "30.2.0", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.2.0.tgz", - "integrity": "sha512-9uBdv/B4EefsuAL+pWqueZyZS2Ba+LxfFeQ9DN14HU4bN8bhaxKdkpjpB6fs9+pSjIBu+FXQHImEg8j/Lw0+vA==", + "version": "30.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "dev": true, "license": "MIT", "dependencies": { @@ -6670,6 +6392,15 @@ "integrity": "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==", "dev": true }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -6702,30 +6433,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, - "node_modules/randomstring": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/randomstring/-/randomstring-1.3.1.tgz", - "integrity": "sha512-lgXZa80MUkjWdE7g2+PZ1xDLzc7/RokXVEQOv5NN2UOTChW1I8A9gha5a9xYBOqgaSoI6uJikDmCU8PyRdArRQ==", - "license": "MIT", - "dependencies": { - "randombytes": "2.1.0" - }, - "bin": { - "randomstring": "bin/randomstring" - }, - "engines": { - "node": "*" - } - }, "node_modules/range-parser": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", @@ -6815,49 +6522,6 @@ "node": ">=8" } }, - "node_modules/robust-orientation": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/robust-orientation/-/robust-orientation-1.2.1.tgz", - "integrity": "sha512-FuTptgKwY6iNuU15nrIJDLjXzCChWB+T4AvksRtwPS/WZ3HuP1CElCm1t+OBfgQKfWbtZIawip+61k7+buRKAg==", - "dev": true, - "dependencies": { - "robust-scale": "^1.0.2", - "robust-subtract": "^1.0.0", - "robust-sum": "^1.0.0", - "two-product": "^1.0.2" - } - }, - "node_modules/robust-point-in-polygon": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/robust-point-in-polygon/-/robust-point-in-polygon-1.0.3.tgz", - "integrity": "sha512-pPzz7AevOOcPYnFv4Vs5L0C7BKOq6C/TfAw5EUE58CylbjGiPyMjAnPLzzSuPZ2zftUGwWbmLWPOjPOz61tAcA==", - "dev": true, - "dependencies": { - "robust-orientation": "^1.0.2" - } - }, - "node_modules/robust-scale": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/robust-scale/-/robust-scale-1.0.2.tgz", - "integrity": "sha512-jBR91a/vomMAzazwpsPTPeuTPPmWBacwA+WYGNKcRGSh6xweuQ2ZbjRZ4v792/bZOhRKXRiQH0F48AvuajY0tQ==", - "dev": true, - "dependencies": { - "two-product": "^1.0.2", - "two-sum": "^1.0.0" - } - }, - "node_modules/robust-subtract": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/robust-subtract/-/robust-subtract-1.0.0.tgz", - "integrity": "sha512-xhKUno+Rl+trmxAIVwjQMiVdpF5llxytozXJOdoT4eTIqmqsndQqFb1A0oiW3sZGlhMRhOi6pAD4MF1YYW6o/A==", - "dev": true - }, - "node_modules/robust-sum": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/robust-sum/-/robust-sum-1.0.0.tgz", - "integrity": "sha512-AvLExwpaqUqD1uwLU6MwzzfRdaI6VEZsyvQ3IAQ0ZJ08v1H+DTyqskrf2ZJyh0BDduFVLN7H04Zmc+qTiahhAw==", - "dev": true - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -6919,10 +6583,13 @@ "license": "MIT" }, "node_modules/sax": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz", - "integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==", - "license": "ISC" + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } }, "node_modules/semver": { "version": "6.3.1", @@ -6998,15 +6665,15 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.34.4", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.4.tgz", - "integrity": "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "@img/colour": "^1.0.0", - "detect-libc": "^2.1.0", - "semver": "^7.7.2" + "detect-libc": "^2.1.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -7015,28 +6682,30 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.4", - "@img/sharp-darwin-x64": "0.34.4", - "@img/sharp-libvips-darwin-arm64": "1.2.3", - "@img/sharp-libvips-darwin-x64": "1.2.3", - "@img/sharp-libvips-linux-arm": "1.2.3", - "@img/sharp-libvips-linux-arm64": "1.2.3", - "@img/sharp-libvips-linux-ppc64": "1.2.3", - "@img/sharp-libvips-linux-s390x": "1.2.3", - "@img/sharp-libvips-linux-x64": "1.2.3", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", - "@img/sharp-libvips-linuxmusl-x64": "1.2.3", - "@img/sharp-linux-arm": "0.34.4", - "@img/sharp-linux-arm64": "0.34.4", - "@img/sharp-linux-ppc64": "0.34.4", - "@img/sharp-linux-s390x": "0.34.4", - "@img/sharp-linux-x64": "0.34.4", - "@img/sharp-linuxmusl-arm64": "0.34.4", - "@img/sharp-linuxmusl-x64": "0.34.4", - "@img/sharp-wasm32": "0.34.4", - "@img/sharp-win32-arm64": "0.34.4", - "@img/sharp-win32-ia32": "0.34.4", - "@img/sharp-win32-x64": "0.34.4" + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/sharp/node_modules/semver": { @@ -7147,10 +6816,17 @@ } }, "node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/simple-update-notifier": { "version": "2.0.0", @@ -7207,32 +6883,22 @@ } }, "node_modules/soap": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/soap/-/soap-1.6.0.tgz", - "integrity": "sha512-koOlNMAONSSVP38WakXEWz3WaYFupJJ08eicwrIvQsv9k2Qwz5JLLS6COqJVpIVCwffcqf8InMs+NYPw1bLOjA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/soap/-/soap-1.9.0.tgz", + "integrity": "sha512-rPBwstctI7bM7WElH7exDn48ndK+KLEFNjbm1nz26TocSccO+LysxwVh9VtSlLrxCfbMfPygZmQN5EVTMf9DEQ==", "license": "MIT", "dependencies": { - "axios": "^1.12.2", + "axios": "^1.13.6", "axios-ntlm": "^1.4.6", "debug": "^4.4.3", + "follow-redirects": "^1.15.11", "formidable": "^3.5.4", - "get-stream": "^6.0.1", - "lodash": "^4.17.21", - "sax": "^1.4.1", - "strip-bom": "^3.0.0", - "whatwg-mimetype": "4.0.0", + "sax": "^1.5.0", + "whatwg-mimetype": "5.0.0", "xml-crypto": "^6.1.2" }, "engines": { - "node": ">=14.17.0" - } - }, - "node_modules/soap/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "engines": { - "node": ">=4" + "node": ">=20.19.0" } }, "node_modules/source-map": { @@ -7404,13 +7070,13 @@ } }, "node_modules/strip-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", - "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", "dev": true, "license": "MIT", "dependencies": { - "ansi-regex": "^6.0.1" + "ansi-regex": "^6.2.2" }, "engines": { "node": ">=12" @@ -7477,9 +7143,9 @@ } }, "node_modules/superagent": { - "version": "10.2.3", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.2.3.tgz", - "integrity": "sha512-y/hkYGeXAj7wUMjxRbB21g/l6aAEituGXM9Rwl4o20+SX3e8YOSV6BxFXl+dL3Uk0mjSL3kCbNkwURm8/gEDig==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/superagent/-/superagent-10.3.0.tgz", + "integrity": "sha512-B+4Ik7ROgVKrQsXTV0Jwp2u+PXYLSlqtDAhYnkkD+zn3yg8s/zjA2MeGayPoY/KICrbitwneDHrjSotxKL+0XQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7487,25 +7153,26 @@ "cookiejar": "^2.1.4", "debug": "^4.3.7", "fast-safe-stringify": "^2.1.1", - "form-data": "^4.0.4", + "form-data": "^4.0.5", "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", - "qs": "^6.11.2" + "qs": "^6.14.1" }, "engines": { "node": ">=14.18.0" } }, "node_modules/supertest": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.1.4.tgz", - "integrity": "sha512-tjLPs7dVyqgItVFirHYqe2T+MfWc2VOBQ8QFKKbWTA3PU7liZR8zoSpAi/C1k1ilm9RsXIKYf197oap9wXGVYg==", + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/supertest/-/supertest-7.2.2.tgz", + "integrity": "sha512-oK8WG9diS3DlhdUkcFn4tkNIiIbBx9lI2ClF8K+b2/m8Eyv47LSawxUzZQSNKUrVb2KsqeTDCcjAAVPYaSLVTA==", "dev": true, "license": "MIT", "dependencies": { + "cookie-signature": "^1.2.2", "methods": "^1.1.2", - "superagent": "^10.2.3" + "superagent": "^10.3.0" }, "engines": { "node": ">=14.18.0" @@ -7524,9 +7191,9 @@ } }, "node_modules/synckit": { - "version": "0.11.11", - "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz", - "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==", + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "dev": true, "license": "MIT", "dependencies": { @@ -7582,27 +7249,6 @@ "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", "license": "MIT" }, - "node_modules/tiff": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/tiff/-/tiff-5.0.3.tgz", - "integrity": "sha512-R0WckwRGhawWDNdha8iPQCjHyOiaEEmfFjhmalUVCIEELsON7Y/XO3eeGmBkoCXQp0Gg2nmTozN92Z4hlwbsow==", - "dev": true, - "license": "MIT", - "dependencies": { - "iobuffer": "^5.0.4", - "pako": "^2.0.4" - } - }, - "node_modules/tmp": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", - "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } - }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -7659,105 +7305,24 @@ } }, "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/triple-beam": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", - "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", - "license": "MIT", - "engines": { - "node": ">= 14.0.0" - } - }, - "node_modules/ts-jest": { - "version": "29.4.5", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.4.5.tgz", - "integrity": "sha512-HO3GyiWn2qvTQA4kTgjDcXiMwYQt68a1Y8+JuLRVpdIzm+UOLSHgl/XqR4c6nzJkq5rOkjc02O2I7P7l/Yof0Q==", - "dev": true, + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", "license": "MIT", "dependencies": { - "bs-logger": "^0.2.6", - "fast-json-stable-stringify": "^2.1.0", - "handlebars": "^4.7.8", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.7.3", - "type-fest": "^4.41.0", - "yargs-parser": "^21.1.1" - }, - "bin": { - "ts-jest": "cli.js" - }, - "engines": { - "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" - }, - "peerDependencies": { - "@babel/core": ">=7.0.0-beta.0 <8", - "@jest/transform": "^29.0.0 || ^30.0.0", - "@jest/types": "^29.0.0 || ^30.0.0", - "babel-jest": "^29.0.0 || ^30.0.0", - "jest": "^29.0.0 || ^30.0.0", - "jest-util": "^29.0.0 || ^30.0.0", - "typescript": ">=4.3 <6" - }, - "peerDependenciesMeta": { - "@babel/core": { - "optional": true - }, - "@jest/transform": { - "optional": true - }, - "@jest/types": { - "optional": true - }, - "babel-jest": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jest-util": { - "optional": true - } - } - }, - "node_modules/ts-jest/node_modules/semver": { - "version": "7.7.3", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", - "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" + "punycode": "^2.3.1" }, "engines": { - "node": ">=10" - } - }, - "node_modules/ts-jest/node_modules/type-fest": { - "version": "4.41.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz", - "integrity": "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==", - "dev": true, - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, - "node_modules/ts-md5": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/ts-md5/-/ts-md5-1.3.1.tgz", - "integrity": "sha512-DiwiXfwvcTeZ5wCE0z+2A9EseZsztaiZtGrtSaY5JOD7ekPnR/GoIVD5gXZAlK9Na9Kvpo9Waz5rW64WKAWApg==", + "node_modules/triple-beam": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/triple-beam/-/triple-beam-1.4.1.tgz", + "integrity": "sha512-aZbgViZrg1QNcG+LULa7nhZpJTZSLm/mXnHXnbAbjmN5aSa0y7V+wvv6+4WaBtpISJzThKy+PIPxc1Nq1EJ9mg==", "license": "MIT", "engines": { - "node": ">=12" + "node": ">= 14.0.0" } }, "node_modules/ts-mockito": { @@ -7818,18 +7383,6 @@ "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", "optional": true }, - "node_modules/two-product": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/two-product/-/two-product-1.0.2.tgz", - "integrity": "sha512-vOyrqmeYvzjToVM08iU52OFocWT6eB/I5LUWYnxeAPGXAhAxXYU/Yr/R2uY5/5n4bvJQL9AQulIuxpIsMoT8XQ==", - "dev": true - }, - "node_modules/two-sum": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/two-sum/-/two-sum-1.0.0.tgz", - "integrity": "sha512-phP48e8AawgsNUjEY2WvoIWqdie8PoiDZGxTDv70LDr01uX5wLEQbOgSP7Z/B6+SW5oLtbe8qaYX2fKJs3CGTw==", - "dev": true - }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -7910,20 +7463,6 @@ "node": ">=14.17" } }, - "node_modules/uglify-js": { - "version": "3.19.3", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", - "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -7942,14 +7481,6 @@ "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, - "node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -8025,30 +7556,12 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/urn-lib": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/urn-lib/-/urn-lib-2.0.0.tgz", - "integrity": "sha512-A9w5yIZke2J13v7i01rumaW2w5qWEakQM7sWkDNcdDty/3LhZVMg/AZyM+JNVVmi+tKU4flbJJsUJ+/qGyWdFw==" - }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/v8-compile-cache-lib": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", @@ -8087,34 +7600,35 @@ "makeerror": "1.0.12" } }, - "node_modules/web-worker-manager": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/web-worker-manager/-/web-worker-manager-0.2.0.tgz", - "integrity": "sha512-WmGabA4GLth1ju9VLm/oMDcPMhMngHoBSdY1OMhrEJvNsPl7z2p+7RBOXjEi5zlP0dK+Shd3Wm+BdD5WZrNYBA==", - "dev": true, - "license": "MIT" - }, "node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } }, "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", - "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", "license": "MIT", "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "license": "MIT", "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" } }, "node_modules/which": { @@ -8134,9 +7648,9 @@ } }, "node_modules/winston": { - "version": "3.18.3", - "resolved": "https://registry.npmjs.org/winston/-/winston-3.18.3.tgz", - "integrity": "sha512-NoBZauFNNWENgsnC9YpgyYwOVrl2m58PpQ8lNHjV3kosGs7KJ7Npk9pCUE+WJlawVSe8mykWDKWFSVfs3QO9ww==", + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", + "integrity": "sha512-LZNJgPzfKR+/J3cHkxcpHKpKKvGfDZVPS4hfJCc4cCG0CgYzvlD6yE/S3CIL/Yt91ak327YCpiF/0MyeZHEHKA==", "license": "MIT", "dependencies": { "@colors/colors": "^1.6.0", @@ -8169,13 +7683,6 @@ "node": ">= 12.0.0" } }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true, - "license": "MIT" - }, "node_modules/wrap-ansi": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", @@ -8290,19 +7797,6 @@ "node": "^14.17.0 || ^16.13.0 || >=18.0.0" } }, - "node_modules/write-file-atomic/node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/ws": { "version": "8.18.3", "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", diff --git a/package.json b/package.json index 6ed6484..aee1a54 100644 --- a/package.json +++ b/package.json @@ -6,62 +6,49 @@ "author": "simojenki ", "license": "GPL-3.0-only", "dependencies": { - "@svrooij/sonos": "^2.6.0-beta.12", - "@types/express": "^5.0.5", - "@types/fs-extra": "^11.0.4", + "@svrooij/sonos": "^2.6.0-beta.13", + "@types/express": "^5.0.6", "@types/jsonwebtoken": "^9.0.10", - "@types/jws": "^3.2.11", "@types/morgan": "^1.9.10", - "@types/node": "^24.10.0", - "@types/randomstring": "^1.3.0", + "@types/node": "^24.12.2", "@types/underscore": "^1.13.0", - "@types/uuid": "^10.0.0", "@types/xmldom": "^0.1.34", "@xmldom/xmldom": "^0.9.9", "axios": "^1.15.0", - "dayjs": "^1.11.19", + "dayjs": "^1.11.20", "eta": "^2.2.0", - "express": "^5.1.0", + "express": "^5.2.1", "fp-ts": "^2.16.11", - "fs-extra": "^11.3.2", - "jsonwebtoken": "^9.0.2", - "jws": "^4.0.1", + "jsonwebtoken": "^9.0.3", "morgan": "^1.10.1", - "node-html-parser": "^7.0.1", - "randomstring": "^1.3.1", - "sharp": "^0.34.4", - "soap": "^1.6.0", - "ts-md5": "^1.3.1", + "node-html-parser": "^7.1.0", + "sharp": "^0.34.5", + "soap": "^1.9.0", "typescript": "^5.9.3", "underscore": "^1.13.8", - "urn-lib": "^2.0.0", - "uuid": "^11.1.0", - "winston": "^3.18.3", + "winston": "^3.19.0", "xmldom-ts": "^0.3.1", "xpath": "^0.0.34" }, "devDependencies": { - "@types/chai": "^5.2.3", + "@swc/core": "^1.15.24", + "@swc/jest": "^0.2.39", "@types/jest": "^30.0.0", - "@types/mocha": "^10.0.10", + "@types/jws": "^3.2.11", "@types/supertest": "^6.0.3", - "@types/tmp": "^0.2.6", - "chai": "^6.2.0", - "get-port": "^7.1.0", - "image-js": "^0.37.0", - "jest": "^30.2.0", - "nodemon": "^3.1.10", - "npm-check-updates": "^19.1.2", - "supertest": "^7.1.4", - "tmp": "^0.2.5", - "ts-jest": "^29.4.5", + "get-port": "^7.2.0", + "jest": "^30.3.0", + "nodemon": "^3.1.14", + "npm-check-updates": "^19.6.6", + "supertest": "^7.2.2", "ts-mockito": "^2.6.1", "ts-node": "^10.9.2", "xpath-ts": "^1.3.13" }, "overrides": { "axios-ntlm": "npm:dry-uninstall", - "axios": "$axios" + "axios": "$axios", + "whatwg-url": "^14.2.0" }, "scripts": { "clean": "rm -Rf build node_modules", @@ -74,4 +61,4 @@ "testw": "jest --watch", "gitinfo": "git describe --tags > .gitinfo" } -} \ No newline at end of file +} diff --git a/src/burn.ts b/src/burn.ts index eaad332..7b52468 100644 --- a/src/burn.ts +++ b/src/burn.ts @@ -1,22 +1,32 @@ import _ from "underscore"; -import { createUrnUtil } from "urn-lib"; -import randomstring from "randomstring"; +import { generateRandomString } from "./random"; import { pipe } from "fp-ts/lib/function"; import { either as E } from "fp-ts"; import jwsEncryption from "./encryption"; -const BURN = createUrnUtil("bnb", { - components: ["system", "resource"], - separator: ":", - allowEmpty: false, -}); - export type BUrn = { system: string; resource: string; }; +// Tiny URN serializer/parser for the "bnb::" format +// previously provided by urn-lib. Components are non-empty; resource may +// contain ":" since we only split on the first two. +const BURN = { + format: ({ system, resource }: BUrn): string => + `bnb:${system}:${resource}`, + parse: (s: string): BUrn | undefined => { + const m = s.match(/^bnb:([^:]+):(.+)$/); + return m ? { system: m[1]!, resource: m[2]! } : undefined; + }, + validate: (b: BUrn | undefined): string[] | undefined => { + if (!b) return ["invalid format"]; + if (!b.system || !b.resource) return ["empty component"]; + return undefined; + }, +}; + const DEFAULT_FORMAT_OPTS = { shorthand: false, encrypt: false, @@ -37,7 +47,7 @@ if(SHORTHAND_MAPPINGS.length != REVERSE_SHORTHAND_MAPPINGS.length) { throw `Invalid SHORTHAND_MAPPINGS, must be duplicate!` } -export const BURN_SALT = randomstring.generate(5); +export const BURN_SALT = generateRandomString(5); const encryptor = jwsEncryption(BURN_SALT); export const format = ( diff --git a/src/link_codes.ts b/src/link_codes.ts index 2e3ddf1..a807e4d 100644 --- a/src/link_codes.ts +++ b/src/link_codes.ts @@ -1,4 +1,4 @@ -import { v4 as uuid } from 'uuid'; +import { randomUUID as uuid } from 'crypto'; export type Association = { diff --git a/src/random.ts b/src/random.ts new file mode 100644 index 0000000..5989852 --- /dev/null +++ b/src/random.ts @@ -0,0 +1,10 @@ +import { randomBytes } from "crypto"; + +// Generate a random alphanumeric-ish string of the given length. +// Backed by crypto.randomBytes for cryptographic strength. +// Default length 32 matches the previous randomstring.generate() default. +export const generateRandomString = (length = 32): string => + randomBytes(Math.ceil(length * 0.75)) + .toString("base64url") + .replace(/[-_]/g, "") + .slice(0, length); diff --git a/src/server.ts b/src/server.ts index 17dc92e..7cf3e1c 100644 --- a/src/server.ts +++ b/src/server.ts @@ -3,7 +3,7 @@ import express, { Express, Request } from "express"; import * as Eta from "eta"; import path from "path"; import sharp from "sharp"; -import { v4 as uuid } from "uuid"; +import { randomUUID as uuid } from "crypto"; import dayjs from "dayjs"; import { PassThrough, Transform, TransformCallback } from "stream"; diff --git a/src/subsonic.ts b/src/subsonic.ts index d4496ba..4e6e34b 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -2,7 +2,8 @@ import { option as O, taskEither as TE } from "fp-ts"; import * as A from "fp-ts/Array"; import { ordString } from "fp-ts/lib/Ord"; import { pipe } from "fp-ts/lib/function"; -import { Md5 } from "ts-md5"; +import { createHash } from "crypto"; +import { generateRandomString } from "./random"; import { Credentials, Album, @@ -19,11 +20,10 @@ import { } from "./music_library"; import sharp from "sharp"; import _ from "underscore"; -import fse from "fs-extra"; +import { readFile, writeFile } from "fs/promises"; import path from "path"; import axios, { AxiosRequestConfig } from "axios"; -import randomstring from "randomstring"; import { b64Encode, b64Decode } from "./b64"; import { BUrn } from "./burn"; import { album, artist } from "./smapi"; @@ -40,10 +40,10 @@ export const BROWSER_HEADERS = { }; export const t = (password: string, s: string) => - Md5.hashStr(`${password}${s}`); + createHash("md5").update(`${password}${s}`).digest("hex"); export const t_and_s = (password: string) => { - const s = randomstring.generate(); + const s = generateRandomString(); return { t: t(password, s), s, @@ -430,9 +430,8 @@ export const cachingImageFetcher = ( makeSharp = sharp ) => async (url: string): Promise => { - const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`); - return fse - .readFile(filename) + const filename = path.join(cacheDir, `${createHash("md5").update(url).digest("hex")}.png`); + return readFile(filename) .then((data) => ({ contentType: "image/png", data })) .catch(() => delegate(url).then((image) => { @@ -441,8 +440,7 @@ export const cachingImageFetcher = ( .png() .toBuffer() .then((png) => { - return fse - .writeFile(filename, png) + return writeFile(filename, png) .then(() => ({ contentType: "image/png", data: png })); }); } else { diff --git a/tests/api_tokens.test.ts b/tests/api_tokens.test.ts index 416f8fd..651975f 100644 --- a/tests/api_tokens.test.ts +++ b/tests/api_tokens.test.ts @@ -1,4 +1,4 @@ -import { v4 as uuid } from "uuid"; +import { randomUUID as uuid } from "crypto"; import { FixedClock } from "../src/clock"; import { diff --git a/tests/builders.ts b/tests/builders.ts index 9feb7c3..29eb234 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -1,6 +1,7 @@ import { SonosDevice } from "@svrooij/sonos/lib"; -import { v4 as uuid } from "uuid"; -import randomstring from "randomstring"; +import { randomUUID as uuid } from "crypto"; +import { generateRandomString } from "../src/random"; +const randomstring = { generate: generateRandomString }; import { SoapyHeaders } from "../src/smapi"; import { Service, Device } from "../src/sonos"; diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index 7a53f5a..1b5ae2e 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -6,7 +6,7 @@ import { MusicLibrary, artistToArtistSummary, } from "../src/music_library"; -import { v4 as uuid } from "uuid"; +import { randomUUID as uuid } from "crypto"; import { anArtist, anAlbum, diff --git a/tests/music_library.test.ts b/tests/music_library.test.ts index aefcd83..b05542b 100644 --- a/tests/music_library.test.ts +++ b/tests/music_library.test.ts @@ -1,4 +1,4 @@ -import { v4 as uuid } from "uuid"; +import { randomUUID as uuid } from "crypto"; import { anArtist } from "./builders"; import { artistToArtistSummary } from "../src/music_library"; diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index 7240101..05bc679 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -2,7 +2,7 @@ import { createClientAsync, Client } from "soap"; import { Express } from "express"; import request from "supertest"; -import { v4 as uuid } from "uuid"; +import { randomUUID as uuid } from "crypto"; import { GetAppLinkResult, diff --git a/tests/server.test.ts b/tests/server.test.ts index b929613..38ca89b 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1,7 +1,7 @@ -import { v4 as uuid } from "uuid"; +import { randomUUID as uuid } from "crypto"; import dayjs from "dayjs"; import request from "supertest"; -import Image from "image-js"; +import sharp from "sharp"; import { either as E, taskEither as TE } from "fp-ts"; import { AuthFailure, MusicService } from "../src/music_library"; @@ -1461,9 +1461,9 @@ describe("server", () => { expect(response.status).toEqual(200); expect(response.header["content-type"]).toEqual("image/png"); - const image = await Image.load(response.body); - expect(image.width).toEqual(80); - expect(image.height).toEqual(80); + const metadata = await sharp(response.body).metadata(); + expect(metadata.width).toEqual(80); + expect(metadata.height).toEqual(80); }); }); @@ -1579,9 +1579,9 @@ describe("server", () => { expect(response.status).toEqual(200); expect(response.header["content-type"]).toEqual("image/png"); - const image = await Image.load(response.body); - expect(image.width).toEqual(80); - expect(image.height).toEqual(80); + const metadata = await sharp(response.body).metadata(); + expect(metadata.width).toEqual(80); + expect(metadata.height).toEqual(80); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 8329279..909abad 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -1,7 +1,7 @@ import crypto from "crypto"; import request from "supertest"; import { Client, createClientAsync } from "soap"; -import { v4 as uuid } from "uuid"; +import { randomUUID as uuid } from "crypto"; import { either as E, taskEither as TE } from "fp-ts"; import { DOMParserImpl } from "xmldom-ts"; import * as xpath from "xpath-ts"; diff --git a/tests/smapi_auth.test.ts b/tests/smapi_auth.test.ts index 06c0454..0050fd0 100644 --- a/tests/smapi_auth.test.ts +++ b/tests/smapi_auth.test.ts @@ -1,4 +1,4 @@ -import { v4 as uuid } from "uuid"; +import { randomUUID as uuid } from "crypto"; import jwt from "jsonwebtoken"; import { diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts index 16dbcf3..1e5ea8b 100644 --- a/tests/sonos.test.ts +++ b/tests/sonos.test.ts @@ -9,7 +9,7 @@ jest.mock("@svrooij/sonos"); import axios from "axios"; jest.mock("axios"); -import { v4 as uuid } from 'uuid'; +import { randomUUID as uuid } from "crypto"; import { AMAZON_MUSIC, APPLE_MUSIC, AUDIBLE } from "./music_services"; diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 772bd5d..7d49278 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -1,9 +1,11 @@ import { option as O, either as E } from "fp-ts"; -import { v4 as uuid } from "uuid"; -import { Md5 } from "ts-md5"; -import tmp from "tmp"; -import fse from "fs-extra"; +import { randomUUID as uuid } from "crypto"; +import { createHash } from "crypto"; +import { existsSync, readFileSync, writeFileSync, mkdtempSync } from "fs"; +import os from "os"; import path from "path"; + +const tmpDir = () => ({ name: mkdtempSync(path.join(os.tmpdir(), "bonob-")) }); import { pipe } from "fp-ts/lib/function"; import sharp from "sharp"; @@ -12,8 +14,8 @@ jest.mock("sharp"); import axios from "axios"; jest.mock("axios"); -import randomstring from "randomstring"; -jest.mock("randomstring"); +import * as random from "../src/random"; +jest.mock("../src/random"); import { URLBuilder } from "../src/url_builder"; import { @@ -47,7 +49,7 @@ describe("t", () => { it("should be an md5 of the password and the salt", () => { const p = "password123"; const s = "saltydog"; - expect(t(p, s)).toEqual(Md5.hashStr(`${p}${s}`)); + expect(t(p, s)).toEqual(createHash("md5").update(`${p}${s}`).digest("hex")); }); }); @@ -157,8 +159,8 @@ describe("cachingImageFetcher", () => { describe("when there is no image in the cache", () => { it("should fetch the image from the source and then cache and return it", async () => { - const dir = tmp.dirSync(); - const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); + const dir = tmpDir(); + const cacheFile = path.join(dir.name, `${createHash("md5").update(url).digest("hex")}.png`); const jpgImage = Buffer.from("jpg-image", "utf-8"); const pngImage = Buffer.from("png-image", "utf-8"); @@ -176,18 +178,18 @@ describe("cachingImageFetcher", () => { expect(result!.data).toEqual(pngImage); expect(delegate).toHaveBeenCalledWith(url); - expect(fse.existsSync(cacheFile)).toEqual(true); - expect(fse.readFileSync(cacheFile)).toEqual(pngImage); + expect(existsSync(cacheFile)).toEqual(true); + expect(readFileSync(cacheFile)).toEqual(pngImage); }); }); describe("when the image is already in the cache", () => { it("should fetch the image from the cache and return it", async () => { - const dir = tmp.dirSync(); - const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); + const dir = tmpDir(); + const cacheFile = path.join(dir.name, `${createHash("md5").update(url).digest("hex")}.png`); const data = Buffer.from("foobar2", "utf-8"); - fse.writeFileSync(cacheFile, data); + writeFileSync(cacheFile, data); const result = await cachingImageFetcher(dir.name, delegate)(url); @@ -200,8 +202,8 @@ describe("cachingImageFetcher", () => { describe("when the delegate returns undefined", () => { it("should return undefined", async () => { - const dir = tmp.dirSync(); - const cacheFile = path.join(dir.name, `${Md5.hashStr(url)}.png`); + const dir = tmpDir(); + const cacheFile = path.join(dir.name, `${createHash("md5").update(url).digest("hex")}.png`); delegate.mockResolvedValue(undefined); @@ -210,7 +212,7 @@ describe("cachingImageFetcher", () => { expect(result).toBeUndefined(); expect(delegate).toHaveBeenCalledWith(url); - expect(fse.existsSync(cacheFile)).toEqual(false); + expect(existsSync(cacheFile)).toEqual(false); }); }); }); @@ -704,7 +706,7 @@ describe("Subsonic", () => { jest.clearAllMocks(); jest.resetAllMocks(); - randomstring.generate = mockRandomstring; + (random.generateRandomString as jest.Mock) = mockRandomstring; axios.get = mockGET; axios.post = mockPOST; diff --git a/tests/subsonic_music_library.test.ts b/tests/subsonic_music_library.test.ts index ee85e87..5e46992 100644 --- a/tests/subsonic_music_library.test.ts +++ b/tests/subsonic_music_library.test.ts @@ -1,12 +1,12 @@ -import { v4 as uuid } from "uuid"; +import { randomUUID as uuid } from "crypto"; import { pipe } from "fp-ts/lib/function"; import { option as O, taskEither as TE, either as E } from "fp-ts"; import axios from "axios"; jest.mock("axios"); -import randomstring from "randomstring"; -jest.mock("randomstring"); +import * as random from "../src/random"; +jest.mock("../src/random"); import { Subsonic, @@ -703,7 +703,7 @@ describe("SubsonicMusicLibrary", () => { jest.clearAllMocks(); jest.resetAllMocks(); - randomstring.generate = mockRandomstring; + (random.generateRandomString as jest.Mock) = mockRandomstring; axios.get = mockGET; axios.post = mockPOST; From 8b4fab5ac17a0efddab6610ef80217e1fe696456 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 23 Apr 2026 11:27:08 +1000 Subject: [PATCH 41/51] Bump @xmldom/xmldom (#271) --- package-lock.json | 104 ++++------------------------------------------ package.json | 2 +- 2 files changed, 8 insertions(+), 98 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5fbdc36..aee03a1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,7 +16,7 @@ "@types/node": "^24.12.2", "@types/underscore": "^1.13.0", "@types/xmldom": "^0.1.34", - "@xmldom/xmldom": "^0.9.9", + "@xmldom/xmldom": "^0.9.10", "axios": "^1.15.0", "dayjs": "^1.11.20", "eta": "^2.2.0", @@ -712,9 +712,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -731,9 +728,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -750,9 +744,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -769,9 +760,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -788,9 +776,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -807,9 +792,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -826,9 +808,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -845,9 +824,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -864,9 +840,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -889,9 +862,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -914,9 +884,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -939,9 +906,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -964,9 +928,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -989,9 +950,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1014,9 +972,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1039,9 +994,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1814,9 +1766,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1834,9 +1783,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1854,9 +1800,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1874,9 +1817,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1894,9 +1834,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -1914,9 +1851,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "Apache-2.0 AND MIT", "optional": true, "os": [ @@ -2437,9 +2371,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2454,9 +2385,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2471,9 +2399,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2488,9 +2413,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2505,9 +2427,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2522,9 +2441,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2539,9 +2455,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2556,9 +2469,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2634,9 +2544,9 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.9.9", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.9.tgz", - "integrity": "sha512-qycIHAucxy/LXAYIjmLmtQ8q9GPnMbnjG1KXhWm9o5sCr6pOYDATkMPiTNa6/v8eELyqOQ2FsEqeoFYmgv/gJg==", + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", "license": "MIT", "engines": { "node": ">=14.6" @@ -7833,9 +7743,9 @@ } }, "node_modules/xml-crypto/node_modules/@xmldom/xmldom": { - "version": "0.8.12", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", - "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "version": "0.8.13", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz", + "integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==", "license": "MIT", "engines": { "node": ">=10.0.0" diff --git a/package.json b/package.json index aee1a54..982d878 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "@types/node": "^24.12.2", "@types/underscore": "^1.13.0", "@types/xmldom": "^0.1.34", - "@xmldom/xmldom": "^0.9.9", + "@xmldom/xmldom": "^0.9.10", "axios": "^1.15.0", "dayjs": "^1.11.20", "eta": "^2.2.0", From 88b482fa6bfbde7687366055ffb8e799b8003754 Mon Sep 17 00:00:00 2001 From: Simon J Date: Wed, 6 May 2026 14:57:51 +1000 Subject: [PATCH 42/51] claude (#268) --- .devcontainer/Dockerfile | 8 +- .devcontainer/claude-install.sh | 158 ++++++++++++++++++++++++++++++++ .devcontainer/devcontainer.json | 4 + .devcontainer/setup.sh | 8 ++ CLAUDE.md | 64 +++++++++++++ package.json | 8 +- src/server.ts | 6 +- 7 files changed, 249 insertions(+), 7 deletions(-) create mode 100755 .devcontainer/claude-install.sh create mode 100755 .devcontainer/setup.sh create mode 100644 CLAUDE.md diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 9ef951b..1a43cfe 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -8,9 +8,15 @@ EXPOSE 4534 RUN apt-get update && \ apt-get -y upgrade && \ apt-get -y install --no-install-recommends \ + jq \ libvips-dev \ python3 \ make \ git \ g++ \ - vim + tzdata \ + vim && \ + ln -fs /usr/share/zoneinfo/Australia/Melbourne /etc/localtime && \ + dpkg-reconfigure --frontend noninteractive tzdata && \ + apt-get clean + diff --git a/.devcontainer/claude-install.sh b/.devcontainer/claude-install.sh new file mode 100755 index 0000000..5cc7bab --- /dev/null +++ b/.devcontainer/claude-install.sh @@ -0,0 +1,158 @@ +#!/bin/bash + +set -e + +# Parse command line arguments +TARGET="$1" # Optional target parameter + +# Validate target if provided +if [[ -n "$TARGET" ]] && [[ ! "$TARGET" =~ ^(stable|latest|[0-9]+\.[0-9]+\.[0-9]+(-[^[:space:]]+)?)$ ]]; then + echo "Usage: $0 [stable|latest|VERSION]" >&2 + exit 1 +fi + +GCS_BUCKET="https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases" +DOWNLOAD_DIR="$HOME/.claude/downloads" + +# Check for required dependencies +DOWNLOADER="" +if command -v curl >/dev/null 2>&1; then + DOWNLOADER="curl" +elif command -v wget >/dev/null 2>&1; then + DOWNLOADER="wget" +else + echo "Either curl or wget is required but neither is installed" >&2 + exit 1 +fi + +# Check if jq is available (optional) +HAS_JQ=false +if command -v jq >/dev/null 2>&1; then + HAS_JQ=true +fi + +# Download function that works with both curl and wget +download_file() { + local url="$1" + local output="$2" + + if [ "$DOWNLOADER" = "curl" ]; then + if [ -n "$output" ]; then + curl -fsSL -o "$output" "$url" + else + curl -fsSL "$url" + fi + elif [ "$DOWNLOADER" = "wget" ]; then + if [ -n "$output" ]; then + wget -q -O "$output" "$url" + else + wget -q -O - "$url" + fi + else + return 1 + fi +} + +# Simple JSON parser for extracting checksum when jq is not available +get_checksum_from_manifest() { + local json="$1" + local platform="$2" + + # Normalize JSON to single line and extract checksum + json=$(echo "$json" | tr -d '\n\r\t' | sed 's/ \+/ /g') + + # Extract checksum for platform using bash regex + if [[ $json =~ \"$platform\"[^}]*\"checksum\"[[:space:]]*:[[:space:]]*\"([a-f0-9]{64})\" ]]; then + echo "${BASH_REMATCH[1]}" + return 0 + fi + + return 1 +} + +# Detect platform +case "$(uname -s)" in + Darwin) os="darwin" ;; + Linux) os="linux" ;; + MINGW*|MSYS*|CYGWIN*) echo "Windows is not supported by this script. See https://code.claude.com/docs for installation options." >&2; exit 1 ;; + *) echo "Unsupported operating system: $(uname -s). See https://code.claude.com/docs for supported platforms." >&2; exit 1 ;; +esac + +case "$(uname -m)" in + x86_64|amd64) arch="x64" ;; + arm64|aarch64) arch="arm64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac + +# Detect Rosetta 2 on macOS: if the shell is running as x64 under Rosetta on an ARM Mac, +# download the native arm64 binary instead of the x64 one +if [ "$os" = "darwin" ] && [ "$arch" = "x64" ]; then + if [ "$(sysctl -n sysctl.proc_translated 2>/dev/null)" = "1" ]; then + arch="arm64" + fi +fi + +# Check for musl on Linux and adjust platform accordingly +if [ "$os" = "linux" ]; then + if [ -f /lib/libc.musl-x86_64.so.1 ] || [ -f /lib/libc.musl-aarch64.so.1 ] || ldd /bin/ls 2>&1 | grep -q musl; then + platform="linux-${arch}-musl" + else + platform="linux-${arch}" + fi +else + platform="${os}-${arch}" +fi +mkdir -p "$DOWNLOAD_DIR" + +# Always download latest version (which has the most up-to-date installer) +version=$(download_file "$GCS_BUCKET/latest") + +# Download manifest and extract checksum +manifest_json=$(download_file "$GCS_BUCKET/$version/manifest.json") + +# Use jq if available, otherwise fall back to pure bash parsing +if [ "$HAS_JQ" = true ]; then + checksum=$(echo "$manifest_json" | jq -r ".platforms[\"$platform\"].checksum // empty") +else + checksum=$(get_checksum_from_manifest "$manifest_json" "$platform") +fi + +# Validate checksum format (SHA256 = 64 hex characters) +if [ -z "$checksum" ] || [[ ! "$checksum" =~ ^[a-f0-9]{64}$ ]]; then + echo "Platform $platform not found in manifest" >&2 + exit 1 +fi + +# Download and verify +binary_path="$DOWNLOAD_DIR/claude-$version-$platform" +if ! download_file "$GCS_BUCKET/$version/$platform/claude" "$binary_path"; then + echo "Download failed" >&2 + rm -f "$binary_path" + exit 1 +fi + +# Pick the right checksum tool +if [ "$os" = "darwin" ]; then + actual=$(shasum -a 256 "$binary_path" | cut -d' ' -f1) +else + actual=$(sha256sum "$binary_path" | cut -d' ' -f1) +fi + +if [ "$actual" != "$checksum" ]; then + echo "Checksum verification failed" >&2 + rm -f "$binary_path" + exit 1 +fi + +chmod +x "$binary_path" + +# Run claude install to set up launcher and shell integration +echo "Setting up Claude Code..." +"$binary_path" install ${TARGET:+"$TARGET"} + +# Clean up downloaded file +rm -f "$binary_path" + +echo "" +echo "✅ Installation complete!" +echo "" diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index f8b81dd..4a52502 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,7 +11,11 @@ "BNB_DEV_SUBSONIC_URL": "${localEnv:BNB_DEV_SUBSONIC_URL}", "BNB_SECRET": "${localEnv:BNB_SECRET}" }, + "postCreateCommand": "bash .devcontainer/setup.sh", "remoteUser": "node", + "remoteEnv": { + "PATH": "/home/node/.local/bin:${containerEnv:PATH}" + }, "runArgs": ["-p", "0.0.0.0:4534:4534"], "forwardPorts": [4534], "features": { diff --git a/.devcontainer/setup.sh b/.devcontainer/setup.sh new file mode 100755 index 0000000..2fa6d51 --- /dev/null +++ b/.devcontainer/setup.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -e + +if [ ! $(which claude) ] && [ "${BNB_DEV_USE_CLAUDE}" == "true" ]; then + # hardcoding version for the minute due to bug https://github.com/anthropics/claude-code/issues/47669 + /workspaces/bonob/.devcontainer/claude-install.sh 2.1.89 +fi \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..622b5c9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,64 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What is bonob + +bonob is a Sonos SMAPI (Sonos Music API) implementation that bridges Sonos devices with Subsonic API-compatible music servers (Navidrome, Gonic, etc.). It acts as a middleware that translates SMAPI SOAP calls from Sonos into Subsonic API calls, enabling Sonos devices to browse and stream music from a self-hosted music server. + +## Commands + +```bash +# Install dependencies +npm install + +# Build TypeScript to ./build/ +npm run build + +# Run all tests +npm test + +# Run tests in watch mode +npm run testw + +# Run a single test file +npx jest tests/smapi.test.ts + +# Run tests matching a name pattern +npx jest --testNamePattern="some test description" + +# Development server (with nodemon, requires env vars) +npm run dev +``` + +## Architecture + +The request flow through bonob: + +1. **Sonos device** sends a SOAP request to `/ws/sonos` +2. **[smapi.ts](src/smapi.ts)** — SMAPI SOAP service layer. Handles Sonos SMAPI protocol: browsing content, auth token validation, ratings, search, playlist management. Uses the Sonos WSDL (`Sonoswsdl-1.19.6-20231024.wsdl`) to expose a SOAP service via the `soap` library. +3. **[music_library.ts](src/music_library.ts)** — Core domain types (`MusicService`, `MusicLibrary`, `Artist`, `Album`, `Track`, `AlbumQuery`, etc.) and the `MusicService` interface that the SMAPI layer calls into. +4. **[subsonic_music_library.ts](src/subsonic_music_library.ts)** — `SubsonicMusicService` implements `MusicService`, translating music library operations into Subsonic API calls. +5. **[subsonic.ts](src/subsonic.ts)** — Low-level Subsonic API client. Handles HTTP requests to the Subsonic server, image fetching, transcoding/custom player logic. + +### Other key files + +- **[server.ts](src/server.ts)** — Express HTTP server setup. Mounts the SMAPI SOAP service, login page, image proxy, audio streaming, registration endpoints, and icon serving. Also handles byte-range requests for audio streaming. +- **[app.ts](src/app.ts)** — Entry point. Wires together config, Sonos discovery, Subsonic client, and feature flags (scrobbling, now-playing) before starting the server. +- **[sonos.ts](src/sonos.ts)** — Sonos device discovery and auto-registration logic using `@svrooij/sonos`. +- **[smapi_auth.ts](src/smapi_auth.ts)** — JWT-based SMAPI login token management (`JWTSmapiLoginTokens`). Handles AppLink auth flow. +- **[api_tokens.ts](src/api_tokens.ts)** — In-memory store for API tokens that map Sonos device sessions to Subsonic credentials. +- **[link_codes.ts](src/link_codes.ts)** — Short-lived codes used in the AppLink auth flow (user enters a code in the Sonos app to link their account). +- **[burn.ts](src/burn.ts)** — BUrn (bonob URN) scheme: `bnb:system:resource`. Used to identify resources (images, tracks) across system boundaries. External URLs get encrypted; internal IDs use shorthand mappings. +- **[config.ts](src/config.ts)** — Reads all `BNB_*` environment variables (with `BONOB_*` as deprecated legacy names). +- **[i8n.ts](src/i8n.ts)** — Localization strings for Sonos presentation (en-US, da-DK, nl-NL, fr-FR). +- **[icon.ts](src/icon.ts)** — SVG icon generation for genres and the bonob service icon. +- **[registrar.ts](src/registrar.ts)** / **[register.ts](src/register.ts)** — Manual registration of bonob as a Sonos music service (S1 auto-registration). + +## Key patterns + +- **fp-ts** is used extensively: `TaskEither` for async operations that can fail, `Option` for nullable values, `pipe` for composition. Understand these before modifying data-flow code. +- **BUrn** IDs are used everywhere to reference resources. External URLs (artist images from Spotify/etc.) are encrypted when embedded in URNs to avoid exposing them in URLs. +- **Tests** live in `tests/` and mirror the `src/` file names (e.g. `tests/smapi.test.ts` tests `src/smapi.ts`). Tests use Jest with `ts-jest`, `ts-mockito` for mocking, and `supertest` for HTTP endpoint testing. +- **TypeScript** is compiled to `./build/` with strict mode enabled (`noImplicitAny`, `noUnusedLocals`, `noUnusedParameters`, `noUncheckedIndexedAccess`). +- The SMAPI SOAP service is bound to Express via the `soap` library using the Sonos WSDL file. Changes to SOAP operations must align with the WSDL. diff --git a/package.json b/package.json index 982d878..462523c 100644 --- a/package.json +++ b/package.json @@ -53,10 +53,10 @@ "scripts": { "clean": "rm -Rf build node_modules", "build": "tsc", - "dev80": "BNB_AUTH_TIMEOUT=1m BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", - "dev": "BNB_LOGIN_THEME=navidrome-ish BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=false BNB_SONOS_AUTO_REGISTER=false BNB_URL=\"${BNB_DEV_LOCAL_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", - "devr": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"${BNB_DEV_LOCAL_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", - "register-dev": "ts-node ./src/register.ts ${BNB_DEV_URL}", + "dev-s2": "BNB_AUTH_TIMEOUT=1m BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_S2_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", + "dev-s1": "BNB_LOGIN_THEME=navidrome-ish BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=false BNB_SONOS_AUTO_REGISTER=false BNB_URL=\"${BNB_DEV_S1_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", + "devr": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"${BNB_DEV_S1_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", + "register-dev-s1": "BNB_SECRET=\"${BNB_DEV_SECRET}\" BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_DEVICE_DISCOVERY=false ts-node ./src/register.ts ${BNB_DEV_S1_URL}", "test": "jest", "testw": "jest --watch", "gitinfo": "git describe --tags > .gitinfo" diff --git a/src/server.ts b/src/server.ts index 7cf3e1c..4b7c288 100644 --- a/src/server.ts +++ b/src/server.ts @@ -633,6 +633,8 @@ function server( const urn = parse(req.params["burn"]!); const size = Number.parseInt(req.params["size"]!); + logger.debug(`Getting art '${JSON.stringify(urn)}' in size ${size}`) + if (!serviceToken) { return res.status(401).send(); } else if (!(size > 0)) { @@ -656,12 +658,12 @@ function server( res.setHeader("content-type", coverArt.contentType); return res.send(coverArt.data); } else { - logger.warn(`Invalid content type of ${coverArt.contentType}, detected for ${urn}`); + logger.warn(`Invalid content type of ${coverArt.contentType}, detected for ${JSON.stringify(urn)}`); return res.status(502).send(); } }) .catch((e: Error) => { - logger.error(`Failed fetching image ${urn}/size/${size}`, { + logger.error(`Failed fetching image ${JSON.stringify(urn)} (size=${size})`, { cause: e, }); return res.status(500).send(); From 15451834f7db3e592c7c3d13594746f3d95c1fcd Mon Sep 17 00:00:00 2001 From: Simon J Date: Fri, 8 May 2026 16:11:26 +1000 Subject: [PATCH 43/51] Feature/pr 262 (#273) * Add OpenSubsonic transcoding extension support for Sonos Implements getTranscodeDecision and getTranscodeStream from the OpenSubsonic transcoding extension (v1). This enables Navidrome 0.61+ to automatically downsample high sample rate FLAC files (e.g. 96kHz) to Sonos-compatible rates (48kHz) while keeping lossless quality. The Sonos client capability profile declares supported sample rates, bit depths, and channels. When streaming, bonob first asks Navidrome for a transcode decision. If transcoding is needed, it uses the new getTranscodeStream endpoint. Otherwise it falls back to the legacy /rest/stream endpoint, maintaining backward compatibility with older Navidrome versions. * Check if subsonic implementation supports transcode decision via looking at extensions endpoint, set supported codecs/containers for sending to subsonic --------- Co-authored-by: Alice Grey --- .claude/settings.local.json | 10 + README.md | 10 + package.json | 2 +- src/subsonic.ts | 356 ++++++++++++++++++++++++++- src/subsonic_music_library.ts | 36 ++- tests/builders.ts | 16 ++ tests/registrar.test.ts | 6 +- tests/sonos.test.ts | 6 +- tests/subsonic.test.ts | 224 ++++++++++++++++- tests/subsonic_music_library.test.ts | 113 ++++++++- 10 files changed, 751 insertions(+), 28 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..1b47d81 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(awk *", + "Bash(npx jest *)", + "Bash(node *)", + "Bash(python3 -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d.get\\('jest', {}\\), indent=2\\)\\)\")" + ] + } +} diff --git a/README.md b/README.md index e8ac1eb..e5dd769 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,16 @@ BNB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register with S ## Transcoding +### Automatic (OpenSubsonic Transcoding extension) + +If your Subsonic server supports the [OpenSubsonic Transcoding extension](https://opensubsonic.netlify.app/docs/extensions/transcoding/) (Navidrome 0.61.0+), bonob will automatically negotiate the right transcoding decisions with the server using a Sonos-specific capability profile. + +This is the recommended approach for handling unsupported audio formats (e.g. high sample rate FLAC). The Sonos profile bonob sends declares the supported sample rates (≤48kHz), bit depths, and channels — the server then decides whether to direct play or transcode each track based on these capabilities. No manual `BNB_SUBSONIC_CUSTOM_CLIENTS` configuration is required. + +**Important:** When using this approach, ensure the `bonob` player in your Subsonic server has **no Transcoding profile assigned and no Max Bit Rate cap**. A server-side player override would replace bonob's capability profile and prevent the extension from working correctly. + +If the server does not support the extension (e.g. older Navidrome versions), bonob automatically falls back to the legacy `/rest/stream` flow described below. + ### Transcode everything The simplest transcoding solution is to simply change the player ('bonob') in your subsonic server to transcode all content to something Sonos supports (ie. mp3 & flac) diff --git a/package.json b/package.json index 462523c..809e440 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "clean": "rm -Rf build node_modules", "build": "tsc", "dev-s2": "BNB_AUTH_TIMEOUT=1m BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_S2_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", - "dev-s1": "BNB_LOGIN_THEME=navidrome-ish BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=false BNB_SONOS_AUTO_REGISTER=false BNB_URL=\"${BNB_DEV_S1_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", + "dev-s1": "BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=false BNB_SONOS_AUTO_REGISTER=false BNB_URL=\"${BNB_DEV_S1_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", "devr": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"${BNB_DEV_S1_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", "register-dev-s1": "BNB_SECRET=\"${BNB_DEV_SECRET}\" BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_DEVICE_DISCOVERY=false ts-node ./src/register.ts ${BNB_DEV_S1_URL}", "test": "jest", diff --git a/src/subsonic.ts b/src/subsonic.ts index 4e6e34b..f1b42af 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -30,13 +30,11 @@ import { album, artist } from "./smapi"; import { URLBuilder } from "./url_builder"; export const BROWSER_HEADERS = { - accept: - "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "accept-encoding": "gzip, deflate, br", - "accept-language": "en-GB,en;q=0.5", - "upgrade-insecure-requests": "1", - "user-agent": - "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:86.0) Gecko/20100101 Firefox/86.0", + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.9", + "Accept-Encoding": "gzip, deflate, br, zstd", + "Upgrade-Insecure-Requests": "1", + "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:150.0) Gecko/20100101 Firefox/150.0", }; export const t = (password: string, s: string) => @@ -246,6 +244,15 @@ export type Search3Response = SubsonicResponse & { }; }; +export type OpenSubsonicExtension = { + name: string; + versions: number[]; +}; + +type GetOpenSubsonicExtensionsResponse = SubsonicResponse & { + openSubsonicExtensions: OpenSubsonicExtension[]; +}; + export function isError( subsonicResponse: SubsonicResponse ): subsonicResponse is SubsonicError { @@ -422,6 +429,221 @@ export const asURLSearchParams = (q: any) => { return urlSearchParams; }; +// OpenSubsonic Transcoding Extension types +export type DirectPlayProfile = { + containers: string[]; + audioCodecs: string[]; + protocols: string[]; + maxAudioChannels: number; +}; + +export type TranscodingProfile = { + container: string; + audioCodec: string; + protocol: string; + maxAudioChannels: number; +}; + +export type CodecLimitation = { + name: string; + comparison: string; + values: string[]; + required: boolean; +}; + +export type CodecProfile = { + type: string; + name: string; + limitations: CodecLimitation[]; +}; + +export type ClientInfo = { + name: string; + platform: string; + maxAudioBitrate: number; + maxTranscodingAudioBitrate: number; + directPlayProfiles: DirectPlayProfile[]; + transcodingProfiles: TranscodingProfile[]; + codecProfiles: CodecProfile[]; +}; + +export type TranscodeStreamInfo = { + protocol: string; + container: string; + codec: string; + audioChannels: number; + audioBitrate: number; + audioProfile: string; + audioSamplerate: number; + audioBitdepth: number; +}; + +export type TranscodeDecision = { + canDirectPlay: boolean; + canTranscode: boolean; + transcodeReason?: string[]; + errorReason?: string; + transcodeParams?: string; + sourceStream?: TranscodeStreamInfo; + transcodeStream?: TranscodeStreamInfo; +}; + +type GetTranscodeDecisionResponse = { + transcodeDecision: TranscodeDecision; + status: string; +}; + +export const SONOS_CLIENT_INFO: ClientInfo = { + name: "bonob-sonos", + platform: "Sonos", + maxAudioBitrate: 0, + maxTranscodingAudioBitrate: 0, + directPlayProfiles: [ + { + containers: ["mp3"], + audioCodecs: ["mp3"], + protocols: ["http"], + maxAudioChannels: 2, + }, + { + containers: ["ogg"], + audioCodecs: ["vorbis"], + protocols: ["http"], + maxAudioChannels: 2, + }, + { + containers: ["flac"], + audioCodecs: ["flac"], + protocols: ["http"], + maxAudioChannels: 2, + }, + { + containers: ["mp4"], + audioCodecs: ["aac", "alac"], + protocols: ["http"], + maxAudioChannels: 2, + }, + ], + transcodingProfiles: [ + { + container: "flac", + audioCodec: "flac", + protocol: "http", + maxAudioChannels: 2, + }, + { + container: "mp3", + audioCodec: "mp3", + protocol: "http", + maxAudioChannels: 2, + }, + ], + codecProfiles: [ + { + type: "AudioCodec", + name: "mp3", + limitations: [ + { + name: "audioSamplerate", + comparison: "LessThanEqual", + values: ["48000"], + required: true, + }, + { + name: "audioChannels", + comparison: "Equals", + values: ["1", "2"], + required: true, + }, + ], + }, + { + type: "AudioCodec", + name: "vorbis", + limitations: [ + { + name: "audioSamplerate", + comparison: "LessThanEqual", + values: ["48000"], + required: true, + }, + { + name: "audioChannels", + comparison: "Equals", + values: ["1", "2"], + required: true, + }, + ], + }, + { + type: "AudioCodec", + name: "aac", + limitations: [ + { + name: "audioSamplerate", + comparison: "LessThanEqual", + values: ["48000"], + required: true, + }, + { + name: "audioChannels", + comparison: "Equals", + values: ["1", "2"], + required: true, + }, + ], + }, + { + type: "AudioCodec", + name: "flac", + limitations: [ + { + name: "audioSamplerate", + comparison: "LessThanEqual", + values: ["48000"], + required: true, + }, + { + name: "audioBitdepth", + comparison: "LessThanEqual", + values: ["24"], + required: true, + }, + { + name: "audioChannels", + comparison: "Equals", + values: ["1", "2"], + required: true, + }, + ], + }, + { + type: "AudioCodec", + name: "alac", + limitations: [ + { + name: "audioSamplerate", + comparison: "LessThanEqual", + values: ["48000"], + required: true, + }, + { + name: "audioBitdepth", + comparison: "LessThanEqual", + values: ["24"], + required: true, + }, + { + name: "audioChannels", + comparison: "Equals", + values: ["1", "2"], + required: true, + }, + ], + }, + ], +}; + export type ImageFetcher = (url: string) => Promise; export const cachingImageFetcher = ( @@ -519,17 +741,41 @@ export class Subsonic { }, ...config, }) - .catch((e) => { - throw `Subsonic failed with: ${e}`; - }) .then((response) => { if (response.status != 200 && response.status != 206) { throw `Subsonic failed with a ${response.status || "no!"} status`; } else return response; }); - // todo: should I put a catch in here and force a subsonic fail status? - // or there is a catch above, that then throws, perhaps can go in there? + private post = async ( + { username, password }: Credentials, + path: string, + q: {} = {}, + headers: {} = {}, + body: any = {}, + config: AxiosRequestConfig | undefined = {} + ) => + axios + .post(this.url.append({ pathname: path }).href(), body, { + params: asURLSearchParams({ + u: username, + v: "1.16.1", + c: DEFAULT_CLIENT_APPLICATION, + ...t_and_s(password), + ...q, + }), + headers: { + "User-Agent": USER_AGENT, + ...headers + }, + ...config, + }) + .then((response) => { + if (response.status != 200) { + throw `Subsonic POST failed with a ${response.status || "no!"} status`; + } else return response; + }); + private getJSON = async ( { username, password }: Credentials, path: string, @@ -543,6 +789,26 @@ export class Subsonic { else return json as unknown as T; }); + private postJSON = async ( + credentials: Credentials, + path: string, + q: {} = {}, + body: any = {} + ): Promise => + this.post( + credentials, + path, + { f: "json", ...q }, + { "Content-Type": "application/json" }, + body + ) + .then((response) => response.data as SubsonicEnvelope) + .then((json) => json["subsonic-response"]) + .then((json) => { + if (isError(json)) throw `Subsonic error:${json.error.message}`; + else return json as unknown as T; + }); + ping = (credentials: Credentials): TE.TaskEither => pipe( TE.tryCatch( @@ -787,6 +1053,59 @@ export class Subsonic { stream: stream.data, })); + getTranscodeDecision = async ( + credentials: Credentials, + mediaId: string, + clientInfo: ClientInfo + ): Promise => + this.postJSON( + credentials, + `/rest/getTranscodeDecision`, + { mediaId, mediaType: "song" }, + clientInfo + ) + .then((json) => json.transcodeDecision); + + getTranscodeStream = ( + credentials: Credentials, + mediaId: string, + transcodeParams: string, + range: string | undefined + ) => + this.get( + credentials, + `/rest/getTranscodeStream`, + { + mediaId, + mediaType: "song", + transcodeParams, + }, + { + headers: pipe( + range, + O.fromNullable, + O.map((range) => ({ + "User-Agent": USER_AGENT, + Range: range, + })), + O.getOrElse(() => ({ + "User-Agent": USER_AGENT, + })) + ), + responseType: "stream", + } + ) + .then((stream) => ({ + status: stream.status, + headers: { + "content-type": stream.headers["content-type"], + "content-length": stream.headers["content-length"], + "content-range": stream.headers["content-range"], + "accept-ranges": stream.headers["accept-ranges"], + }, + stream: stream.data, + })); + playlists = (credentials: Credentials) => this.getJSON(credentials, "/rest/getPlaylists") .then(({ playlists }) => (playlists.playlist || []).map( it => ({ @@ -887,5 +1206,16 @@ export class Subsonic { url: it.streamUrl, homePage: it.homePageUrl, })) - ); + ); + + getOpenSubsonicExtensions = (credentials: Credentials): Promise => + this.getJSON( + credentials, + "/rest/getOpenSubsonicExtensions.view" + ) + .then((it) => it.openSubsonicExtensions || []) + .catch((e: unknown) => { + if (axios.isAxiosError(e) && e.response?.status === 404) return []; + throw e + }); }; diff --git a/src/subsonic_music_library.ts b/src/subsonic_music_library.ts index 7f47206..63bd382 100644 --- a/src/subsonic_music_library.ts +++ b/src/subsonic_music_library.ts @@ -24,7 +24,8 @@ import { parseToken, artistImageURN, asYear, - isValidImage + isValidImage, + SONOS_CLIENT_INFO, } from "./subsonic"; import _ from "underscore"; @@ -165,13 +166,36 @@ export class SubsonicMusicLibrary implements MusicLibrary { }: { trackId: string; range: string | undefined; - }) => - this.subsonic - .getTrack(this.credentials, trackId) - .then((track) => - this.subsonic.stream(this.credentials, trackId, track.encoding.player, range) + }) => { + const extensions = await this.subsonic.getOpenSubsonicExtensions(this.credentials); + const hasTranscoding = extensions.some((ext) => ext.name === "transcoding"); + logger.debug(`extensions are: ${JSON.stringify(extensions)}`) + + if (hasTranscoding) { + const decision = await this.subsonic.getTranscodeDecision( + this.credentials, + trackId, + SONOS_CLIENT_INFO ); + logger.debug(`decision is: ${JSON.stringify(extensions)}`) + if (decision && !decision.canDirectPlay && decision.canTranscode && decision.transcodeParams) { + logger.info( + `Transcoding track ${trackId} via OpenSubsonic extension: ${JSON.stringify(decision.transcodeReason)}` + ); + return this.subsonic.getTranscodeStream( + this.credentials, + trackId, + decision.transcodeParams, + range + ); + } + } + + const track = await this.subsonic.getTrack(this.credentials, trackId); + return this.subsonic.stream(this.credentials, trackId, track.encoding.player, range); + }; + coverArt = async (coverArtURN: BUrn, size?: number) => Promise.resolve(coverArtURN) .then((it) => assertSystem(it, "subsonic")) diff --git a/tests/builders.ts b/tests/builders.ts index 29eb234..869391b 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -1,3 +1,4 @@ +import { AxiosError } from "axios"; import { SonosDevice } from "@svrooij/sonos/lib"; import { randomUUID as uuid } from "crypto"; import { generateRandomString } from "../src/random"; @@ -24,6 +25,21 @@ import { artistImageURN } from "../src/subsonic"; const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max)); const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`; +export const a404 = (): AxiosError => new AxiosError( + 'Not Found', + 'ERR_BAD_REQUEST', + undefined, + undefined, + { + status: 404, + statusText: 'Not Found', + headers: {}, + config: {} as any, + data: {}, + } + ); + + export const aService = (fields: Partial = {}): Service => ({ sid: randomInt(500), name: `Test Music Service ${uuid()}`, diff --git a/tests/registrar.test.ts b/tests/registrar.test.ts index 876d0d1..29c5045 100644 --- a/tests/registrar.test.ts +++ b/tests/registrar.test.ts @@ -1,5 +1,9 @@ import axios from "axios"; -jest.mock("axios"); +jest.mock("axios", () => ({ + ...jest.requireActual("axios"), + get: jest.fn(), + post: jest.fn(), +})); const fakeSonos = { register: jest.fn(), diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts index 1e5ea8b..939fd70 100644 --- a/tests/sonos.test.ts +++ b/tests/sonos.test.ts @@ -7,7 +7,11 @@ import { jest.mock("@svrooij/sonos"); import axios from "axios"; -jest.mock("axios"); +jest.mock("axios", () => ({ + ...jest.requireActual("axios"), + get: jest.fn(), + post: jest.fn(), +})); import { randomUUID as uuid } from "crypto"; diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 7d49278..4291037 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -11,8 +11,13 @@ import { pipe } from "fp-ts/lib/function"; import sharp from "sharp"; jest.mock("sharp"); + import axios from "axios"; -jest.mock("axios"); +jest.mock("axios", () => ({ + ...jest.requireActual("axios"), + get: jest.fn(), + post: jest.fn(), +})); import * as random from "../src/random"; jest.mock("../src/random"); @@ -32,7 +37,10 @@ import { NO_CUSTOM_PLAYERS, Subsonic, asGenre, - PingResponse + PingResponse, + OpenSubsonicExtension, + SONOS_CLIENT_INFO, + TranscodeDecision, } from "../src/subsonic"; import { getArtistJson, getArtistInfoJson, asArtistsJson } from "./subsonic_music_library.test"; @@ -40,7 +48,7 @@ import { getArtistJson, getArtistInfoJson, asArtistsJson } from "./subsonic_musi import { b64Encode } from "../src/b64"; import { Album, Artist, Track, AlbumSummary, AuthFailure } from "../src/music_library"; -import { anAlbum, aTrack, anAlbumSummary, anArtistSummary, anArtist, aSimilarArtist, POP } from "./builders"; +import { anAlbum, aTrack, anAlbumSummary, anArtistSummary, anArtist, aSimilarArtist, POP, a404 } from "./builders"; import { BUrn } from "../src/burn"; @@ -257,6 +265,12 @@ export type ArtistWithAlbum = { album: Album; }; +const anOpenSubsonicExtension = (fields: Partial = {}): OpenSubsonicExtension => ({ + name: `extension-${uuid()}`, + versions: [1], + ...fields, +}); + const pingJson = (pingResponse: Partial = {}) => ({ "subsonic-response": { status: "ok", @@ -267,6 +281,7 @@ const pingJson = (pingResponse: Partial = {}) => ({ }, }); + describe("artistImageURN", () => { describe("when artist URL is", () => { describe("a valid external URL", () => { @@ -669,6 +684,18 @@ export const getAlbumJson = (album: Album) => })), } }); +const getOpenSubsonicExtensionsJson = (extensions: OpenSubsonicExtension[]) => + subsonicOK({ openSubsonicExtensions: extensions }); + +const aTranscodeDecision = (fields: Partial = {}): TranscodeDecision => ({ + canDirectPlay: false, + canTranscode: false, + ...fields, +}); + +const getTranscodeDecisionJson = (decision: TranscodeDecision) => + subsonicOK({ transcodeDecision: decision }); + describe("Subsonic", () => { const url = new URLBuilder("http://127.0.0.22:4567/some-context-path"); const customPlayers = { @@ -1665,5 +1692,194 @@ describe("Subsonic", () => { expect(result).toEqual(false); }); }); - }); + }); + + describe("getOpenSubsonicExtensions", () => { + describe("when there are no extensions", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([]))) + ); + }); + + it("should return an empty array and call subsonic with correct params", async () => { + const result = await subsonic.getOpenSubsonicExtensions(credentials); + + expect(result).toEqual([]); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getOpenSubsonicExtensions.view" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers } + ); + }); + }); + + describe("when there are extensions", () => { + const extension1 = anOpenSubsonicExtension({ name: "transcoding", versions: [1] }); + const extension2 = anOpenSubsonicExtension({ name: "formPost", versions: [1, 2] }); + + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([extension1, extension2]))) + ); + }); + + it("should return the extensions and call subsonic with correct params", async () => { + const result = await subsonic.getOpenSubsonicExtensions(credentials); + + expect(result).toEqual([extension1, extension2]); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getOpenSubsonicExtensions.view" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers } + ); + }); + }); + + describe("when the server returns 404", () => { + beforeEach(() => { + mockGET.mockRejectedValue(a404()) + }); + + it("should return an empty array", async () => { + const result = await subsonic.getOpenSubsonicExtensions(credentials); + + expect(result).toEqual([]); + }); + }); + }); + + describe("getTranscodeDecision", () => { + const mediaId = `media-${uuid()}`; + + describe("when the server can transcode", () => { + const decision = aTranscodeDecision({ + canDirectPlay: false, + canTranscode: true, + transcodeParams: "some-transcode-params", + transcodeReason: ["AudioCodecNotSupported"], + }); + + beforeEach(() => { + mockPOST.mockImplementationOnce(() => + Promise.resolve(ok(getTranscodeDecisionJson(decision))) + ); + }); + + it("should return the decision and call subsonic with correct params", async () => { + const result = await subsonic.getTranscodeDecision(credentials, mediaId, SONOS_CLIENT_INFO); + + expect(result).toEqual(decision); + expect(axios.post).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getTranscodeDecision" }).href(), + SONOS_CLIENT_INFO, + { + params: asURLSearchParams({ + u: authParams.u, + v: authParams.v, + c: authParams.c, + t: authParams.t, + s: authParams.s, + f: "json", + mediaId, + mediaType: "song", + }), + headers: { + "User-Agent": "bonob", + "Content-Type": "application/json", + }, + } + ); + }); + }); + + describe("when the server requires direct play", () => { + const decision = aTranscodeDecision({ canDirectPlay: true, canTranscode: false }); + + beforeEach(() => { + mockPOST.mockImplementationOnce(() => + Promise.resolve(ok(getTranscodeDecisionJson(decision))) + ); + }); + + it("should return the decision", async () => { + const result = await subsonic.getTranscodeDecision(credentials, mediaId, SONOS_CLIENT_INFO); + + expect(result).toEqual(decision); + }); + }); + }); + + describe("getTranscodeStream", () => { + const mediaId = `media-${uuid()}`; + const transcodeParams = "some-transcode-params"; + const streamData = { pipe: jest.fn() }; + + const streamResponse = { + status: 200, + headers: { + "content-type": "audio/mpeg", + "content-length": "12345", + "content-range": "0-12344", + "accept-ranges": "bytes", + "some-other-header": "ignored", + }, + data: streamData, + }; + + describe("without range", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => Promise.resolve(streamResponse)); + }); + + it("should return the stream response and call subsonic with correct params", async () => { + const result = await subsonic.getTranscodeStream(credentials, mediaId, transcodeParams, undefined); + + expect(result.stream).toEqual(streamData); + expect(result.headers).toEqual({ + "content-type": "audio/mpeg", + "content-length": "12345", + "content-range": "0-12344", + "accept-ranges": "bytes", + }); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getTranscodeStream" }).href(), + { + params: asURLSearchParams({ + ...authParams, + mediaId, + mediaType: "song", + transcodeParams, + }), + headers: { "User-Agent": "bonob" }, + responseType: "stream", + } + ); + }); + }); + + describe("with range", () => { + const range = "1000-2000"; + + beforeEach(() => { + mockGET.mockImplementationOnce(() => Promise.resolve(streamResponse)); + }); + + it("should include the Range header", async () => { + await subsonic.getTranscodeStream(credentials, mediaId, transcodeParams, range); + + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getTranscodeStream" }).href(), + { + params: asURLSearchParams({ + ...authParams, + mediaId, + mediaType: "song", + transcodeParams, + }), + headers: { "User-Agent": "bonob", Range: range }, + responseType: "stream", + } + ); + }); + }); + }); }); diff --git a/tests/subsonic_music_library.test.ts b/tests/subsonic_music_library.test.ts index 5e46992..6b790ec 100644 --- a/tests/subsonic_music_library.test.ts +++ b/tests/subsonic_music_library.test.ts @@ -3,7 +3,11 @@ import { pipe } from "fp-ts/lib/function"; import { option as O, taskEither as TE, either as E } from "fp-ts"; import axios from "axios"; -jest.mock("axios"); +jest.mock("axios", () => ({ + ...jest.requireActual("axios"), + get: jest.fn(), + post: jest.fn(), +})); import * as random from "../src/random"; jest.mock("../src/random"); @@ -170,6 +174,16 @@ const getSimilarSongsJson = (tracks: Track[]) => const getTopSongsJson = (tracks: Track[]) => subsonicOK({ topSongs: { song: tracks.map(asSongJson) } }); +const getOpenSubsonicExtensionsJson = (extensions: { name: string; versions: number[] }[]) => + subsonicOK({ openSubsonicExtensions: extensions }); + +const getTranscodeDecisionJson = (decision: { + canDirectPlay: boolean; + canTranscode: boolean; + transcodeParams?: string; + transcodeReason?: string[]; +}) => subsonicOK({ transcodeDecision: decision }); + const asPlaylistJson = (playlist: PlaylistSummary) => ({ id: playlist.id, name: playlist.name, @@ -1784,6 +1798,9 @@ describe("SubsonicMusicLibrary", () => { }; mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([]))) + ) .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -1821,6 +1838,9 @@ describe("SubsonicMusicLibrary", () => { }; mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([]))) + ) .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -1860,6 +1880,9 @@ describe("SubsonicMusicLibrary", () => { }; mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([]))) + ) .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -1910,6 +1933,9 @@ describe("SubsonicMusicLibrary", () => { }; mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([]))) + ) .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -1929,6 +1955,9 @@ describe("SubsonicMusicLibrary", () => { const trackId = "track123"; mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([]))) + ) .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -1941,7 +1970,7 @@ describe("SubsonicMusicLibrary", () => { return expect( subsonic.stream({ trackId, range: undefined }) - ).rejects.toEqual(`Subsonic failed with: IO error occured`); + ).rejects.toEqual(`IO error occured`); }); }); }); @@ -1966,6 +1995,9 @@ describe("SubsonicMusicLibrary", () => { }; mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([]))) + ) .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track))) ) @@ -2028,6 +2060,9 @@ describe("SubsonicMusicLibrary", () => { }; mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([]))) + ) .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) ) @@ -2070,6 +2105,9 @@ describe("SubsonicMusicLibrary", () => { }; mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([]))) + ) .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(trackWithCustomPlayer))) ) @@ -2100,6 +2138,77 @@ describe("SubsonicMusicLibrary", () => { }); }); }); + + describe("when the transcoding extension is present", () => { + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + describe("when the server can transcode", () => { + it("should use getTranscodeStream", async () => { + const transcodeParams = "some-params"; + const streamData = { pipe: jest.fn() }; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([{ name: "transcoding", versions: [1] }]))) + ) + .mockImplementationOnce(() => + Promise.resolve({ + status: 200, + headers: { "content-type": "audio/mpeg" }, + data: streamData, + }) + ); + + mockPOST.mockImplementationOnce(() => + Promise.resolve({ + status: 200, + data: getTranscodeDecisionJson({ + canDirectPlay: false, + canTranscode: true, + transcodeParams, + }), + }) + ); + + const result = await subsonic.stream({ trackId, range: undefined }); + + expect(result.stream).toEqual(streamData); + }); + }); + + describe("when the server requires direct play", () => { + it("should fall back to the legacy stream", async () => { + const streamData = { pipe: jest.fn() }; + + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([{ name: "transcoding", versions: [1] }]))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getSongJson(track))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ) + .mockImplementationOnce(() => + Promise.resolve({ status: 200, headers: { "content-type": "audio/mpeg" }, data: streamData }) + ); + + mockPOST.mockImplementationOnce(() => + Promise.resolve({ + status: 200, + data: getTranscodeDecisionJson({ canDirectPlay: true, canTranscode: false }), + }) + ); + + const result = await subsonic.stream({ trackId, range: undefined }); + + expect(result.stream).toEqual(streamData); + }); + }); + }); }); describe("fetching cover art", () => { From 14c35bf17cb1a1ac21e79320ae1ac24ba5ea1ec4 Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 8 May 2026 16:52:17 +1000 Subject: [PATCH 44/51] debug log on transcode --- src/subsonic_music_library.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/subsonic_music_library.ts b/src/subsonic_music_library.ts index 63bd382..a02ffbf 100644 --- a/src/subsonic_music_library.ts +++ b/src/subsonic_music_library.ts @@ -169,7 +169,6 @@ export class SubsonicMusicLibrary implements MusicLibrary { }) => { const extensions = await this.subsonic.getOpenSubsonicExtensions(this.credentials); const hasTranscoding = extensions.some((ext) => ext.name === "transcoding"); - logger.debug(`extensions are: ${JSON.stringify(extensions)}`) if (hasTranscoding) { const decision = await this.subsonic.getTranscodeDecision( @@ -177,12 +176,8 @@ export class SubsonicMusicLibrary implements MusicLibrary { trackId, SONOS_CLIENT_INFO ); - - logger.debug(`decision is: ${JSON.stringify(extensions)}`) + logger.debug(`Transcoding decision is: ${JSON.stringify(decision)}`) if (decision && !decision.canDirectPlay && decision.canTranscode && decision.transcodeParams) { - logger.info( - `Transcoding track ${trackId} via OpenSubsonic extension: ${JSON.stringify(decision.transcodeReason)}` - ); return this.subsonic.getTranscodeStream( this.credentials, trackId, From 5f200193b07279f0e575d7ded2696178af94293d Mon Sep 17 00:00:00 2001 From: simojenki Date: Fri, 8 May 2026 21:58:46 +1000 Subject: [PATCH 45/51] Add BNB_SUBSONIC_TRANSCODE config to gate OpenSubsonic transcoding Adds a boolean config item (default true) that flows through SubsonicMusicService and SubsonicMusicLibrary as useTranscode, allowing users to disable automatic OpenSubsonic transcoding negotiation and always use the legacy stream path. --- README.md | 1 + src/app.ts | 3 +- src/config.ts | 1 + src/subsonic_music_library.ts | 43 +++++++++++++++++----------- tests/config.test.ts | 7 +++++ tests/subsonic_music_library.test.ts | 29 +++++++++++++++++++ 6 files changed, 66 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index e5dd769..ad732e7 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,7 @@ BNB_SERVER_LOG_REQUESTS | false | Whether or not to log http requests BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming.

Must specify the source mime type and optionally the transcoded mime type.

For example;

If you want to simply re-encode some flacs, then you could specify just "audio/flac".

However;

if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3"

If you want to specify many something like; "audio/flac>audio/mp3,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.

Disclaimer: Getting this configuration wrong will cause Sonos to refuse to play your music, by all means experiment, however know that this may well break your setup. BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images that are sourced externally. ie. Navidrome provides spotify URLs. Remember to provide a volume-mapping for Docker, when enabling this cache. +BNB_SUBSONIC_TRANSCODE | true | Whether to use the [OpenSubsonic Transcoding extension](https://opensubsonic.netlify.app/docs/extensions/transcoding/) when the server supports it. Set to 'false' to disable automatic transcoding negotiation and always use the legacy stream path. BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing BNB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in Sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html) diff --git a/src/app.ts b/src/app.ts index d936749..b775b09 100644 --- a/src/app.ts +++ b/src/app.ts @@ -47,7 +47,8 @@ const subsonic = new SubsonicMusicService( customPlayers, artistImageFetcher ), - customPlayers + customPlayers, + config.subsonic.transcode ); const featureFlagAwareMusicService: MusicService = { diff --git a/src/config.ts b/src/config.ts index 76ca497..ccebe5b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -124,6 +124,7 @@ export default function (die: (code?: number) => never = process.exit) { url: url(bnbEnvVar("SUBSONIC_URL", { legacy: ["BONOB_NAVIDROME_URL"], default: `http://${hostname()}:4533` })!), customClientsFor: bnbEnvVar("SUBSONIC_CUSTOM_CLIENTS", { legacy: ["BONOB_NAVIDROME_CUSTOM_CLIENTS"] }), artistImageCache: bnbEnvVar("SUBSONIC_ARTIST_IMAGE_CACHE"), + transcode: bnbEnvVar("SUBSONIC_TRANSCODE", { default: true, parser: asBoolean }), }, scrobbleTracks: bnbEnvVar("SCROBBLE_TRACKS", { default: true, parser: asBoolean }), reportNowPlaying: diff --git a/src/subsonic_music_library.ts b/src/subsonic_music_library.ts index a02ffbf..a5f2aea 100644 --- a/src/subsonic_music_library.ts +++ b/src/subsonic_music_library.ts @@ -35,13 +35,16 @@ import { assertSystem, BUrn } from "./burn"; export class SubsonicMusicService implements MusicService { subsonic: Subsonic; customPlayers: CustomPlayers; + useTranscode: boolean; constructor( subsonic: Subsonic, - customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS + customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS, + useTranscode: boolean = true ) { this.subsonic = subsonic; this.customPlayers = customPlayers; + this.useTranscode = useTranscode; } generateToken = ( @@ -67,7 +70,8 @@ export class SubsonicMusicService implements MusicService { return Promise.resolve(new SubsonicMusicLibrary( this.subsonic, credentials, - this.customPlayers + this.customPlayers, + this.useTranscode )); }; } @@ -76,15 +80,18 @@ export class SubsonicMusicLibrary implements MusicLibrary { subsonic: Subsonic; credentials: Credentials; customPlayers: CustomPlayers; + useTranscode: boolean; constructor( subsonic: Subsonic, credentials: Credentials, - customPlayers: CustomPlayers + customPlayers: CustomPlayers, + useTranscode: boolean = true ) { this.subsonic = subsonic; this.credentials = credentials; this.customPlayers = customPlayers; + this.useTranscode = useTranscode; } // todo: q needs to support greater than the max page size supported by subsonic @@ -167,23 +174,25 @@ export class SubsonicMusicLibrary implements MusicLibrary { trackId: string; range: string | undefined; }) => { - const extensions = await this.subsonic.getOpenSubsonicExtensions(this.credentials); - const hasTranscoding = extensions.some((ext) => ext.name === "transcoding"); - - if (hasTranscoding) { - const decision = await this.subsonic.getTranscodeDecision( - this.credentials, - trackId, - SONOS_CLIENT_INFO - ); - logger.debug(`Transcoding decision is: ${JSON.stringify(decision)}`) - if (decision && !decision.canDirectPlay && decision.canTranscode && decision.transcodeParams) { - return this.subsonic.getTranscodeStream( + if (this.useTranscode) { + const extensions = await this.subsonic.getOpenSubsonicExtensions(this.credentials); + const hasTranscoding = extensions.some((ext) => ext.name === "transcoding"); + + if (hasTranscoding) { + const decision = await this.subsonic.getTranscodeDecision( this.credentials, trackId, - decision.transcodeParams, - range + SONOS_CLIENT_INFO ); + logger.debug(`Transcoding decision is: ${JSON.stringify(decision)}`) + if (decision && !decision.canDirectPlay && decision.canTranscode && decision.transcodeParams) { + return this.subsonic.getTranscodeStream( + this.credentials, + trackId, + decision.transcodeParams, + range + ); + } } } diff --git a/tests/config.test.ts b/tests/config.test.ts index 9493b94..5b936e9 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -488,6 +488,13 @@ describe("config", () => { expect(config().subsonic.artistImageCache).toEqual("/some/path"); }); }); + + describeBooleanConfigValue( + "transcode", + "BNB_SUBSONIC_TRANSCODE", + true, + (config) => config.subsonic.transcode + ); }); describe("scrobbling and reporting", () => { diff --git a/tests/subsonic_music_library.test.ts b/tests/subsonic_music_library.test.ts index 6b790ec..9ade775 100644 --- a/tests/subsonic_music_library.test.ts +++ b/tests/subsonic_music_library.test.ts @@ -2209,6 +2209,35 @@ describe("SubsonicMusicLibrary", () => { }); }); }); + + describe("when useTranscode is false", () => { + const noTranscodeLibrary = new SubsonicMusicLibrary( + new Subsonic(url, customPlayers), + { username, password }, + customPlayers as unknown as CustomPlayers, + false + ); + + beforeEach(() => { + customPlayers.encodingFor.mockReturnValue(O.none); + }); + + it("should skip extensions check and use the legacy stream directly", async () => { + const streamData = { pipe: jest.fn() }; + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track)))) + .mockImplementationOnce(() => Promise.resolve(ok(getAlbumJson(album)))) + .mockImplementationOnce(() => + Promise.resolve({ status: 200, headers: { "content-type": "audio/mpeg" }, data: streamData }) + ); + + const result = await noTranscodeLibrary.stream({ trackId, range: undefined }); + + expect(result.stream).toEqual(streamData); + expect(mockGET).toHaveBeenCalledTimes(3); + }); + }); }); describe("fetching cover art", () => { From 39faa3688455d85d24a25d803d3a279131e09efc Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 9 May 2026 09:55:23 +1000 Subject: [PATCH 46/51] Remove BONOB_ legacy config key support bnbEnvVar no longer auto-adds BONOB_{KEY} as a fallback, and the extra legacy arrays for BNB_URL, BNB_SUBSONIC_URL, and BNB_SUBSONIC_CUSTOM_CLIENTS are removed. The legacy mechanism in envVar remains intact for future use. Tests updated accordingly. --- src/config.ts | 10 +- tests/config.test.ts | 233 +++++++++++++------------------------------ 2 files changed, 71 insertions(+), 172 deletions(-) diff --git a/src/config.ts b/src/config.ts index ccebe5b..e46c72c 100644 --- a/src/config.ts +++ b/src/config.ts @@ -50,10 +50,7 @@ export function envVar( } export const bnbEnvVar = (key: string, opts: Partial> = {}) => - envVar(`BNB_${key}`, { - ...opts, - legacy: [`BONOB_${key}`, ...(opts.legacy || [])], - }); + envVar(`BNB_${key}`, opts); const asBoolean = (value: string) => value == "true"; @@ -76,7 +73,6 @@ const cleanLoginTheme = (value: string) => { export default function (die: (code?: number) => never = process.exit) { const port = bnbEnvVar("PORT", { default: 4534, parser: asInt })!; const bonobUrl = bnbEnvVar("URL", { - legacy: ["BONOB_WEB_ADDRESS"], default: `http://${hostname()}:${port}`, })!; @@ -121,8 +117,8 @@ export default function (die: (code?: number) => never = process.exit) { sid: bnbEnvVar("SONOS_SERVICE_ID", { default: 246, parser: asInt }), }, subsonic: { - url: url(bnbEnvVar("SUBSONIC_URL", { legacy: ["BONOB_NAVIDROME_URL"], default: `http://${hostname()}:4533` })!), - customClientsFor: bnbEnvVar("SUBSONIC_CUSTOM_CLIENTS", { legacy: ["BONOB_NAVIDROME_CUSTOM_CLIENTS"] }), + url: url(bnbEnvVar("SUBSONIC_URL", { default: `http://${hostname()}:4533` })!), + customClientsFor: bnbEnvVar("SUBSONIC_CUSTOM_CLIENTS"), artistImageCache: bnbEnvVar("SUBSONIC_ARTIST_IMAGE_CACHE"), transcode: bnbEnvVar("SUBSONIC_TRANSCODE", { default: true, parser: asBoolean }), }, diff --git a/tests/config.test.ts b/tests/config.test.ts index 5b936e9..52f2455 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -110,22 +110,13 @@ describe("config", () => { } describe("bonobUrl", () => { - describe.each([ - "BNB_URL", - "BONOB_URL", - "BONOB_WEB_ADDRESS" - ])("when %s is specified", (k) => { - it("should be used", () => { - const url = "http://bonob1.example.com:8877/"; - - process.env["BNB_SECRET"] = "bonob"; - process.env["BNB_URL"] = ""; - process.env["BONOB_URL"] = ""; - process.env["BONOB_WEB_ADDRESS"] = ""; - process.env[k] = url; - - expect(config().bonobUrl.href()).toEqual(url); - }); + it("should be used when BNB_URL is specified", () => { + const url = "http://bonob1.example.com:8877/"; + + process.env["BNB_SECRET"] = "bonob"; + process.env["BNB_URL"] = url; + + expect(config().bonobUrl.href()).toEqual(url); }); describe(`when BNB_URL is 'http://localhost'`, () => { @@ -137,17 +128,15 @@ describe("config", () => { }); }); - describe("when none of BNB_URL, BONOB_URL, BONOB_WEB_ADDRESS are specified", () => { + describe("when BNB_URL is not specified", () => { beforeEach(() => { process.env["BNB_SECRET"] = "bonob"; }); - describe("when BONOB_PORT is not specified", () => { - it(`should default to http://${hostname()}:4534`, () => { - expect(config().bonobUrl.href()).toEqual( - `http://${hostname()}:4534/` - ); - }); + it(`should default to http://${hostname()}:4534`, () => { + expect(config().bonobUrl.href()).toEqual( + `http://${hostname()}:4534/` + ); }); describe("when BNB_PORT is specified as 3322", () => { @@ -158,15 +147,6 @@ describe("config", () => { ); }); }); - - describe("when BONOB_PORT is specified as 3322", () => { - it(`should default to http://${hostname()}:3322`, () => { - process.env["BONOB_PORT"] = "3322"; - expect(config().bonobUrl.href()).toEqual( - `http://${hostname()}:3322/` - ); - }); - }); }); }); @@ -178,7 +158,6 @@ describe("config", () => { describe("foregroundColor", () => { describe.each([ "BNB_ICON_FOREGROUND_COLOR", - "BONOB_ICON_FOREGROUND_COLOR", ])("%s", (k) => { describe(`when ${k} is not specified`, () => { it(`should default to undefined`, () => { @@ -221,7 +200,6 @@ describe("config", () => { describe("backgroundColor", () => { describe.each([ "BNB_ICON_BACKGROUND_COLOR", - "BONOB_ICON_BACKGROUND_COLOR", ])("%s", (k) => { describe(`when ${k} is not specified`, () => { it(`should default to undefined`, () => { @@ -289,15 +267,10 @@ describe("config", () => { expect(mockDeath).toHaveBeenCalledWith(1); }); - describe.each([ - "BNB_SECRET", - "BONOB_SECRET" - ])("%s", (k) => { - it(`should be overridable using ${k}`, () => { - const secret = "new-secret-that-is-really-really-really-long-isnt-it" - process.env[k] = secret; - expect(config().secret).toEqual(secret); - }); + it(`should be overridable using BNB_SECRET`, () => { + const secret = "new-secret-that-is-really-really-really-long-isnt-it" + process.env["BNB_SECRET"] = secret; + expect(config().secret).toEqual(secret); }); }); @@ -339,64 +312,35 @@ describe("config", () => { expect(config().sonos.serviceName).toEqual("bonob"); }); - describe.each([ - "BNB_SONOS_SERVICE_NAME", - "BONOB_SONOS_SERVICE_NAME" - ])( - "%s", - (k) => { - it("should be overridable", () => { - process.env[k] = "foobar1000"; - expect(config().sonos.serviceName).toEqual("foobar1000"); - }); - } - ); + it("should be overridable using BNB_SONOS_SERVICE_NAME", () => { + process.env["BNB_SONOS_SERVICE_NAME"] = "foobar1000"; + expect(config().sonos.serviceName).toEqual("foobar1000"); + }); }); - describe.each([ + describeBooleanConfigValue( + "deviceDiscovery", "BNB_SONOS_DEVICE_DISCOVERY", - "BONOB_SONOS_DEVICE_DISCOVERY", - ])("%s", (k) => { - describeBooleanConfigValue( - "deviceDiscovery", - k, - true, - (config) => config.sonos.discovery.enabled - ); - }); + true, + (config) => config.sonos.discovery.enabled + ); describe("seedHost", () => { it("should default to undefined", () => { expect(config().sonos.discovery.seedHost).toBeUndefined(); }); - describe.each([ - "BNB_SONOS_SEED_HOST", - "BONOB_SONOS_SEED_HOST" - ])( - "%s", - (k) => { - it("should be overridable", () => { - process.env[k] = "123.456.789.0"; - expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0"); - }); - } - ); + it("should be overridable using BNB_SONOS_SEED_HOST", () => { + process.env["BNB_SONOS_SEED_HOST"] = "123.456.789.0"; + expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0"); + }); }); - describe.each([ - "BNB_SONOS_AUTO_REGISTER", - "BONOB_SONOS_AUTO_REGISTER" - ])( - "%s", - (k) => { - describeBooleanConfigValue( - "autoRegister", - k, - false, - (config) => config.sonos.autoRegister - ); - } + describeBooleanConfigValue( + "autoRegister", + "BNB_SONOS_AUTO_REGISTER", + false, + (config) => config.sonos.autoRegister ); describe("sid", () => { @@ -404,18 +348,10 @@ describe("config", () => { expect(config().sonos.sid).toEqual(246); }); - describe.each([ - "BNB_SONOS_SERVICE_ID", - "BONOB_SONOS_SERVICE_ID" - ])( - "%s", - (k) => { - it("should be overridable", () => { - process.env[k] = "786"; - expect(config().sonos.sid).toEqual(786); - }); - } - ); + it("should be overridable using BNB_SONOS_SERVICE_ID", () => { + process.env["BNB_SONOS_SERVICE_ID"] = "786"; + expect(config().sonos.sid).toEqual(786); + }); }); }); @@ -425,39 +361,25 @@ describe("config", () => { }); describe("url", () => { - describe.each([ - "BNB_SUBSONIC_URL", - "BONOB_SUBSONIC_URL", - "BONOB_NAVIDROME_URL", - ])("%s", (k) => { - describe(`when ${k} is not specified`, () => { - it(`should default to http://${hostname()}:4533/`, () => { - expect(config().subsonic.url.href()).toEqual(`http://${hostname()}:4533/`); - }); - }); + it(`should default to http://${hostname()}:4533/`, () => { + expect(config().subsonic.url.href()).toEqual(`http://${hostname()}:4533/`); + }); - describe(`when ${k} is ''`, () => { - it(`should default to http://${hostname()}:4533/`, () => { - process.env[k] = ""; - expect(config().subsonic.url.href()).toEqual(`http://${hostname()}:4533/`); - }); - }); + it(`should default to http://${hostname()}:4533/ when BNB_SUBSONIC_URL is ''`, () => { + process.env["BNB_SUBSONIC_URL"] = ""; + expect(config().subsonic.url.href()).toEqual(`http://${hostname()}:4533/`); + }); - describe(`when ${k} is specified`, () => { - it(`should use it for ${k}`, () => { - const url = "http://navidrome.example.com:1234/some-context-path"; - process.env[k] = url; - expect(config().subsonic.url.href()).toEqual(url); - }); - }); + it(`should use BNB_SUBSONIC_URL when specified`, () => { + const url = "http://navidrome.example.com:1234/some-context-path"; + process.env["BNB_SUBSONIC_URL"] = url; + expect(config().subsonic.url.href()).toEqual(url); + }); - describe(`when ${k} is specified with trailing slash`, () => { - it(`should maintain the trailing slash as URLBuilder will remove it when required ${k}`, () => { - const url = "http://navidrome.example.com:1234/"; - process.env[k] = url; - expect(config().subsonic.url.href()).toEqual(url); - }); - }); + it(`should maintain trailing slash`, () => { + const url = "http://navidrome.example.com:1234/"; + process.env["BNB_SUBSONIC_URL"] = url; + expect(config().subsonic.url.href()).toEqual(url); }); }); @@ -466,15 +388,9 @@ describe("config", () => { expect(config().subsonic.customClientsFor).toBeUndefined(); }); - describe.each([ - "BNB_SUBSONIC_CUSTOM_CLIENTS", - "BONOB_SUBSONIC_CUSTOM_CLIENTS", - "BONOB_NAVIDROME_CUSTOM_CLIENTS", - ])("%s", (k) => { - it(`should be overridable for ${k}`, () => { - process.env[k] = "whoop/whoop"; - expect(config().subsonic.customClientsFor).toEqual("whoop/whoop"); - }); + it(`should be overridable using BNB_SUBSONIC_CUSTOM_CLIENTS`, () => { + process.env["BNB_SUBSONIC_CUSTOM_CLIENTS"] = "whoop/whoop"; + expect(config().subsonic.customClientsFor).toEqual("whoop/whoop"); }); }); @@ -502,31 +418,18 @@ describe("config", () => { process.env["BNB_SECRET"] = "bonob"; }); - describe.each([ - "BNB_SCROBBLE_TRACKS", - "BONOB_SCROBBLE_TRACKS" - ])("%s", (k) => { - describeBooleanConfigValue( - "scrobbleTracks", - k, - true, - (config) => config.scrobbleTracks - ); - }); + describeBooleanConfigValue( + "scrobbleTracks", + "BNB_SCROBBLE_TRACKS", + true, + (config) => config.scrobbleTracks + ); - describe.each([ - "BNB_REPORT_NOW_PLAYING", - "BONOB_REPORT_NOW_PLAYING" - ])( - "%s", - (k) => { - describeBooleanConfigValue( - "reportNowPlaying", - k, - true, - (config) => config.reportNowPlaying - ); - } + describeBooleanConfigValue( + "reportNowPlaying", + "BNB_REPORT_NOW_PLAYING", + true, + (config) => config.reportNowPlaying ); }); }); From 3448ab6859c42f82f60481f16ce6eb296a38fe40 Mon Sep 17 00:00:00 2001 From: Simon J Date: Wed, 13 May 2026 15:45:51 +1000 Subject: [PATCH 47/51] use podman rather than docker in devcontainer (#274) --- .devcontainer/Dockerfile | 24 +++++++++++++++++++++--- .devcontainer/devcontainer.json | 21 +++++++++++++-------- Makefile | 4 ++++ 3 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 Makefile diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 1a43cfe..2d93957 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,5 +1,7 @@ FROM node:22-bookworm +ENV DEBIAN_FRONTEND=noninteractive + LABEL maintainer=simojenki ENV JEST_TIMEOUT=60000 @@ -8,15 +10,31 @@ EXPOSE 4534 RUN apt-get update && \ apt-get -y upgrade && \ apt-get -y install --no-install-recommends \ + containernetworking-plugins \ jq \ + g++ \ + git \ libvips-dev \ - python3 \ make \ - git \ - g++ \ + podman \ + python3 \ + sudo \ tzdata \ vim && \ ln -fs /usr/share/zoneinfo/Australia/Melbourne /etc/localtime && \ dpkg-reconfigure --frontend noninteractive tzdata && \ apt-get clean +RUN echo "node ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/node + +RUN mkdir -p /home/node/.config/containers + +RUN cat > /home/node/.config/containers/storage.conf < Date: Mon, 25 May 2026 08:41:30 +1000 Subject: [PATCH 48/51] Bump qs from 6.14.2 to 6.15.2 (#276) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index aee03a1..57bf7f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6329,9 +6329,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.2", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", - "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "version": "6.15.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", + "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" From 35753b74e2f569a3c9eb1b9d44a5a3096866de9a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 25 May 2026 09:00:57 +1000 Subject: [PATCH 49/51] Bump ws from 8.18.3 to 8.21.0 (#277) --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 57bf7f0..ee18865 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7708,9 +7708,9 @@ } }, "node_modules/ws": { - "version": "8.18.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", - "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", "license": "MIT", "engines": { "node": ">=10.0.0" From 2179241994a92b83841585a04fdf92b67dab5d63 Mon Sep 17 00:00:00 2001 From: Simon J Date: Tue, 16 Jun 2026 09:16:53 +1000 Subject: [PATCH 50/51] Load sonos config conditionally on BNB_SONOS_ENABLE_S1 (#278) When S1 is disabled (the default), skip reading all other SONOS_* env vars and return hardcoded safe defaults (discovery disabled, sid -1). Only when BNB_SONOS_ENABLE_S1=true are the env vars consulted. --- .devcontainer/devcontainer-lock.json | 9 ++ .devcontainer/devcontainer.json | 16 +-- README.md | 71 +++++-------- docs/sonos-s1-setup.md | 17 ++-- docs/sonos-s2-setup.md | 2 + package.json | 2 +- src/app.ts | 3 +- src/config.ts | 37 +++++-- src/routes/s1.ts | 77 ++++++++++++++ src/server.ts | 69 ++----------- src/smapi.ts | 2 - tests/config.test.ts | 31 ++++++ tests/scenarios.test.ts | 9 +- tests/server.test.ts | 146 ++++++++++++++++++++++----- web/views/index.eta | 43 +------- web/views/s1.eta | 45 +++++++++ 16 files changed, 377 insertions(+), 202 deletions(-) create mode 100644 .devcontainer/devcontainer-lock.json create mode 100644 src/routes/s1.ts create mode 100644 web/views/s1.eta diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..41f7e1d --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": { + "version": "1.0.5", + "resolved": "ghcr.io/anthropics/devcontainer-features/claude-code@sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a", + "integrity": "sha256:cfc2e7d3e9fd3b9b01f8d5cb158508a884c8c0ede2e23ed10f32dea5d4ffe69a" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8fa27a4..77b60f8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -11,7 +11,7 @@ "BNB_DEV_SUBSONIC_URL": "${localEnv:BNB_DEV_SUBSONIC_URL}", "BNB_SECRET": "${localEnv:BNB_SECRET}" }, - "postCreateCommand": "bash .devcontainer/setup.sh", + // "postCreateCommand": "bash .devcontainer/setup.sh", "remoteUser": "node", "remoteEnv": { "PATH": "/home/node/.local/bin:${containerEnv:PATH}" @@ -22,12 +22,14 @@ "--security-opt=label=disable" ], "forwardPorts": [4534], - // "features": { - // "ghcr.io/devcontainers/features/docker-in-docker:2": { - // "version": "latest", - // "moby": true - // } - // }, + "features": { + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {} + // , + // "ghcr.io/devcontainers/features/docker-in-docker:2": { + // "version": "latest", + // "moby": true + // } + }, "customizations": { "vscode": { "extensions": [ diff --git a/README.md b/README.md index ad732e7..6b05e60 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ A Sonos SMAPI implementation to allow registering sources of music with Sonos. Support for Subsonic API clones (tested against Navidrome and Gonic). - ## Features +- SONOS S1 and S2 support - Integrates with Subsonic API clones (Navidrome, Gonic) - Browse by Artist, Albums, Random, Favourites, Top Rated, Playlist, Genres, Years, Recently Added Albums, Recently Played Albums, Most Played Albums - Artist & Album Art @@ -18,23 +18,21 @@ Support for Subsonic API clones (tested against Navidrome and Gonic). - Transcoding within subsonic clone - Custom players by mime type, allowing custom transcoding rules for different file types - Localization (only en-US, da-DK, nl-NL & fr-FR supported currently, require translations for other languages). [Sonos localization and supported languages](https://docs.sonos.com/docs/localization) -- Auto discovery of Sonos devices -- Discovery of Sonos devices using seed IP address - Multiple registrations within a single household. -- SONOS S1 and S2 support -- Auto registration with Sonos on start for Sonos S1 devices +### S1 specific features + +- Auto registration with Sonos on startup +- Auto discovery of Sonos devices +- Discovery of Sonos devices using seed IP address ## Running bonob bonob is packaged as an OCI image to both the docker hub registry and github registry. -ie. ```bash docker run docker.io/simojenki/bonob -``` -or -```bash +#or docker run ghcr.io/simojenki/bonob ``` @@ -44,46 +42,33 @@ latest | Latest release, intended to be stable master | Lastest build from master, probably works, however is currently under test vX.Y.Z | Fixed release versions from tags, for those that want to pin to a specific release - ## Sonos S1 vs S2 -Unfortunately in May 2024 Sonos released an update to the Sonos S2 app that required bonob be exposed to the internet to continue to work on S2. S1 devices continue to work locally within youur network. There is a lengthy thread on the issue [here](https://github.com/simojenki/bonob/issues/205). +In May 2024 Sonos released an update to the Sonos S2 app that required bonob be exposed to the internet to continue to work on S2. S1 devices continue to work locally within youur network. There is [a lengthy thread on the issue](https://github.com/simojenki/bonob/issues/205). The tldr; is: -- If you have devices that can be down graded to Sonos S1 then you can continue to use bonob within your network without exposing anything to the internet, support for this mode of operation will continue until Sonos themselves EOL S1. -- If you have devices that cannot be downgraded to S1 then you must use S2, in which case you need to expose bonob to the internet so that it can be called by Sonos itself. Exposing services to the internet comes with additional risk, tread carefully. - -See below for instructions on how to set up bonob for S1 or S2. - - -## Sonos S1 setup: -See [here](./docs/sonos-s1-setup.md) - - -## Sonos S2 setup: - -In order to use Sonos S2 you are going to need to expose your bonob service to the internet so that Sonos can hit it. You may wish to restrict your firewall (TCP/443 only) to the Sonos IP addresses outlined [in here](https://docs.sonos.com/docs/key-requirements). +- If you have devices that can be down graded to Sonos S1 then you can use bonob within your network without exposing anything to the internet, support for this mode of operation will continue until Sonos themselves EOL S1. This mode is no longer the default, you will need to set `SONOS_ENABLE_S1=true` +- If you have devices that cannot be downgraded to S1 then you must use S2, in which case you need to expose bonob to the internet so that it can be called by Sonos itself. Exposing services to the internet comes with additional risk, tread carefully. -See [here](./docs/sonos-s2-setup.md) +[Sonos S2 setup](./docs/sonos-s2-setup.md) +[Sonos S1 setup](./docs/sonos-s1-setup.md) ## Configuration -### General configuration items - item | default value | description ---- | ------------- | ----------- BNB_PORT | 4534 | Default http port for bonob to listen on -BNB_URL | http://$(hostname):4534 | **S1:**

URL (including path) for bonob so that Sonos devices can communicate. This can be an IP address or hostname on your local network, it must however be accessible by your Sonos S1 devices. ie. http://192.168.1.5:4534

**S2:**

This must be the publicly available DNS entry for your bonob instance, ie. https://bonob.example.com +BNB_URL | http://$(hostname):4534 | **S1:** URL (including path) for bonob so that Sonos devices can communicate. This can be an IP address or hostname on your local network, it must however be accessible by your Sonos S1 devices. ie. `http://192.168.1.5:4534`**S2:** This must be the publicly available DNS entry for your bonob instance, ie. `https://bonob.example.com` BNB_SECRET | undefined | Secret used for encrypting credentials, must be provided, make it long, make it secure BNB_AUTH_TIMEOUT | 1h | Timeout for the Sonos auth token, described in the format [ms](https://github.com/vercel/ms), ie. '5s' == 5 seconds, '11h' == 11 hours. In the case of using Navidrome this should be less than the value for ND_SESSIONTIMEOUT BNB_LOG_LEVEL | info | Log level. One of ['debug', 'info', 'warn', 'error'] BNB_SERVER_LOG_REQUESTS | false | Whether or not to log http requests BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone -BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming.

Must specify the source mime type and optionally the transcoded mime type.

For example;

If you want to simply re-encode some flacs, then you could specify just "audio/flac".

However;

if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3"

If you want to specify many something like; "audio/flac>audio/mp3,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.

Disclaimer: Getting this configuration wrong will cause Sonos to refuse to play your music, by all means experiment, however know that this may well break your setup. -BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images that are sourced externally. ie. Navidrome provides spotify URLs. Remember to provide a volume-mapping for Docker, when enabling this cache. BNB_SUBSONIC_TRANSCODE | true | Whether to use the [OpenSubsonic Transcoding extension](https://opensubsonic.netlify.app/docs/extensions/transcoding/) when the server supports it. Set to 'false' to disable automatic transcoding negotiation and always use the legacy stream path. +BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | This probably should not be used any more, it would be better to use a subsonic server that supports the transcoding extensions, see BNB_SUBSONIC_TRANSCODE. Comma delimeted mime types for custom subsonic clients when streaming.

Must specify the source mime type and optionally the transcoded mime type.

For example;

If you want to simply re-encode some flacs, then you could specify just "audio/flac".

However;

if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3"

If you want to specify many something like; "audio/flac>audio/mp3,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.

Disclaimer: Getting this configuration wrong will cause Sonos to refuse to play your music, by all means experiment, however know that this may well break your setup. +BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images that are sourced externally. ie. Navidrome provides spotify URLs. Remember to provide a volume-mapping for Docker, when enabling this cache. BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing BNB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in Sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html) @@ -91,18 +76,19 @@ BNB_ICON_BACKGROUND_COLOR | undefined | Icon background color in Sonos app, must BNB_LOGIN_THEME | classic | Theme for login page. Options are:

'classic' for the original timeless bonob login page.

'navidrome-ish' for a simplified navidrome login page.

'[@wkulhanek](https://github.com/wkulhanek)' for more 'modernized login page'. TZ | UTC | Your timezone from the [tz database](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) ie. 'Australia/Melbourne' +### Additional S1 configuration options -### Additional configuration for S1 setups. +These will have no effect if you do not set BNB_SONOS_ENABLE_S1=true item | default value | description ---- | ------------- | ----------- +BNB_SONOS_ENABLE_S1 | false | Enables S1 support, disabled by default as new installations are predominantely S2, and having S1 config options is confusing people and causing support overhead. BNB_SONOS_DEVICE_DISCOVERY | true | Enable/Disable Sonos device discovery entirely. Setting this to 'false' will disable Sonos device search, regardless of whether a seed host is specified. BNB_SONOS_SEED_HOST | undefined | Sonos device seed host for discovery, or ommitted for for auto-discovery BNB_SONOS_SERVICE_NAME | bonob | S1 service name for Sonos, doesn't seem to apply for S2 setups BNB_SONOS_SERVICE_ID | 246 | service id for Sonos BNB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register with S1 devices on startup. **For S2 ensure that this is false.** - ## Transcoding ### Automatic (OpenSubsonic Transcoding extension) @@ -115,11 +101,13 @@ This is the recommended approach for handling unsupported audio formats (e.g. hi If the server does not support the extension (e.g. older Navidrome versions), bonob automatically falls back to the legacy `/rest/stream` flow described below. -### Transcode everything +### Legacy transcoding options + +#### Transcode everything The simplest transcoding solution is to simply change the player ('bonob') in your subsonic server to transcode all content to something Sonos supports (ie. mp3 & flac) -### Audio file type specific transcoding +#### Audio file type specific transcoding Disclaimer: The following configuration is more complicated, and if you get the configuration wrong Sonos will refuse to play your content. @@ -152,7 +140,6 @@ BNB_SUBSONIC_CUSTOM_CLIENTS="audio/mpeg>audio/mp3" And then configure the 'bonob+audio/mpeg' player in your subsonic server. - ## Changing Icon colors ```bash @@ -183,8 +170,7 @@ And then configure the 'bonob+audio/mpeg' player in your subsonic server. ![Spotify-ish](https://github.com/simojenki/bonob/blob/master/docs/images/spotify-ish.png?raw=true) - -## Notes on running bonob with various integrations: +## Notes on running bonob with various integrations ### Running bonob and navidrome using docker-compose @@ -217,24 +203,13 @@ services: # ip address of your machine running bonob BNB_URL: http://192.168.1.111:4534 BNB_SECRET: changeme - BNB_SONOS_AUTO_REGISTER: "true" - BNB_SONOS_DEVICE_DISCOVERY: "true" - BNB_SONOS_SERVICE_ID: 246 - # ip address of one of your sonos devices - BNB_SONOS_SEED_HOST: 192.168.1.121 BNB_SUBSONIC_URL: http://navidrome:4533 ``` - ### Running bonob on synology [See this issue](https://github.com/simojenki/bonob/issues/15) - -### Running bonob behind Cloudflare/cloudflared tunnels. -This was an issue, however running bonob behind cloudflared should now work. - - ## Credits - Icons courtesy of [Navidrome](https://www.navidrome.org/), [Vectornator](https://www.vectornator.io/icons), and [@jicho](https://github.com/jicho) diff --git a/docs/sonos-s1-setup.md b/docs/sonos-s1-setup.md index 939cac0..234c40b 100644 --- a/docs/sonos-s1-setup.md +++ b/docs/sonos-s1-setup.md @@ -1,4 +1,4 @@ -# Sonos S1 setup: +# Sonos S1 setup ## Running bonob itself @@ -6,6 +6,8 @@ ```bash docker run \ + -e BNB_SECRET=changeme \ + -e BNB_SONOS_ENABLE_S1=true \ -e BNB_SONOS_AUTO_REGISTER=true \ -e BNB_SONOS_DEVICE_DISCOVERY=true \ -p 4534:4534 \ @@ -13,13 +15,15 @@ docker run \ simojenki/bonob ``` -Now open http://localhost:4534 in your browser, you should see Sonos devices, and service configuration. Bonob will auto-register itself with your Sonos system on startup. +Now open `http://localhost:4534` in your browser, you should see Sonos devices, and service configuration. Bonob will auto-register itself with your Sonos system on startup. ### Full Sonos device auto-discovery and auto-registration on custom port by using a Sonos seed device, without requiring docker host networking ```bash docker run \ + -e BNB_SECRET=changeme \ -e BNB_PORT=3000 \ + -e BNB_SONOS_ENABLE_S1=true \ -e BNB_SONOS_SEED_HOST=192.168.1.123 \ -e BNB_SONOS_AUTO_REGISTER=true \ -e BNB_SONOS_DEVICE_DISCOVERY=true \ @@ -27,7 +31,7 @@ docker run \ simojenki/bonob ``` -Bonob will now auto-register itself with Sonos on startup, updating the registration if the configuration has changed. Bonob should show up in the "Services" list on http://localhost:3000 +Bonob will now auto-register itself with Sonos on startup, updating the registration if the configuration has changed. Bonob should show up in the "Services" list on `http://localhost:3000/` ### Running bonob on a different network to your Sonos devices @@ -39,10 +43,11 @@ Start bonob outside the LAN with Sonos discovery & registration disabled as they ```bash docker run \ - -e BNB_PORT=4534 \ - -e BNB_SONOS_SERVICE_NAME=MyAwesomeMusic \ -e BNB_SECRET=changeme \ + -e BNB_PORT=4534 \ -e BNB_URL=https://my-server.example.com/bonob \ + -e BNB_SONOS_ENABLE_S1=true \ + -e BNB_SONOS_SERVICE_NAME=MyAwesomeMusic \ -e BNB_SONOS_AUTO_REGISTER=false \ -e BNB_SONOS_DEVICE_DISCOVERY=false \ -e BNB_SUBSONIC_URL=https://my-navidrome-service.com:4533 \ @@ -98,7 +103,7 @@ Generally speaking you will not need to do this very often. However on occassio Service should now be registered and everything should work as expected. -## Multiple registrations within a single household. +## Multiple registrations within a single household It's possible to register multiple Subsonic clone users for the bonob service in Sonos. Basically this consist of repeating the Sonos app ["Add a service"](#initialising-service-within-sonos-app) steps for each additional user. diff --git a/docs/sonos-s2-setup.md b/docs/sonos-s2-setup.md index 5f3c281..546b9eb 100644 --- a/docs/sonos-s2-setup.md +++ b/docs/sonos-s2-setup.md @@ -4,6 +4,8 @@ Credit goes to [@wkulhanek](https://github.com/wkulhanek) for writing up these i ## Prerequisites +* In order to use bonob with Sonos S2 you are going to need to expose your bonob service to the internet so that Sonos can hit it. You may wish to restrict your firewall (TCP/443 only) to the Sonos IP addresses outlined [over here in the sonos docs](https://docs.sonos.com/docs/key-requirements). + * In your Sonos App get your Sonos ID (About my Sonos System) ![about](images/about.png) diff --git a/package.json b/package.json index 809e440..c81c3cc 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "clean": "rm -Rf build node_modules", "build": "tsc", "dev-s2": "BNB_AUTH_TIMEOUT=1m BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_SONOS_AUTO_REGISTER=false BNB_SONOS_DEVICE_DISCOVERY=false BNB_URL=\"${BNB_DEV_S2_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", - "dev-s1": "BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=false BNB_SONOS_AUTO_REGISTER=false BNB_URL=\"${BNB_DEV_S1_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", + "dev-s1": "BNB_SONOS_ENABLE_S1=true BNB_LOGIN_THEME=navidrome-ish BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=false BNB_SONOS_AUTO_REGISTER=false BNB_URL=\"${BNB_DEV_S1_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", "devr": "BNB_SUBSONIC_CUSTOM_CLIENTS=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_SERVER_LOG_REQUESTS=true BNB_DEBUG=true BNB_ICON_FOREGROUND_COLOR=deeppink BNB_ICON_BACKGROUND_COLOR=darkslategray BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"${BNB_DEV_S1_URL}\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" BNB_SECRET=\"${BNB_DEV_SECRET}\" nodemon -V ./src/app.ts", "register-dev-s1": "BNB_SECRET=\"${BNB_DEV_SECRET}\" BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_DEVICE_DISCOVERY=false ts-node ./src/register.ts ${BNB_DEV_S1_URL}", "test": "jest", diff --git a/src/app.ts b/src/app.ts index b775b09..154585a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -97,7 +97,8 @@ const app = server( version, smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout), externalImageResolver: artistImageFetcher, - loginTheme: config.loginTheme + loginTheme: config.loginTheme, + enableS1: config.sonos.enableS1, } ); diff --git a/src/config.ts b/src/config.ts index e46c72c..a14ab49 100644 --- a/src/config.ts +++ b/src/config.ts @@ -70,6 +70,31 @@ const cleanLoginTheme = (value: string) => { } } + +function sonosConfig() { + const enableS1 = bnbEnvVar("SONOS_ENABLE_S1", { default: false, parser: asBoolean }); + if (!enableS1) { + return { + serviceName: "bonob", + discovery: { enabled: false, seedHost: undefined as string | undefined }, + autoRegister: false, + sid: -1, + enableS1: false, + }; + } else { + return { + serviceName: bnbEnvVar("SONOS_SERVICE_NAME", { default: "bonob" })!, + discovery: { + enabled: bnbEnvVar("SONOS_DEVICE_DISCOVERY", { default: true, parser: asBoolean }), + seedHost: bnbEnvVar("SONOS_SEED_HOST"), + }, + autoRegister: bnbEnvVar("SONOS_AUTO_REGISTER", { default: false, parser: asBoolean }), + sid: bnbEnvVar("SONOS_SERVICE_ID", { default: 246, parser: asInt }), + enableS1: true, + }; + } +} + export default function (die: (code?: number) => never = process.exit) { const port = bnbEnvVar("PORT", { default: 4534, parser: asInt })!; const bonobUrl = bnbEnvVar("URL", { @@ -105,17 +130,7 @@ export default function (die: (code?: number) => never = process.exit) { }), }, logRequests: bnbEnvVar("SERVER_LOG_REQUESTS", { default: false, parser: asBoolean }), - sonos: { - serviceName: bnbEnvVar("SONOS_SERVICE_NAME", { default: "bonob" })!, - discovery: { - enabled: - bnbEnvVar("SONOS_DEVICE_DISCOVERY", { default: true, parser: asBoolean }), - seedHost: bnbEnvVar("SONOS_SEED_HOST"), - }, - autoRegister: - bnbEnvVar("SONOS_AUTO_REGISTER", { default: false, parser: asBoolean }), - sid: bnbEnvVar("SONOS_SERVICE_ID", { default: 246, parser: asInt }), - }, + sonos: sonosConfig(), subsonic: { url: url(bnbEnvVar("SUBSONIC_URL", { default: `http://${hostname()}:4533` })!), customClientsFor: bnbEnvVar("SUBSONIC_CUSTOM_CLIENTS"), diff --git a/src/routes/s1.ts b/src/routes/s1.ts new file mode 100644 index 0000000..2b5ee77 --- /dev/null +++ b/src/routes/s1.ts @@ -0,0 +1,77 @@ +import { Router, Request, Response, NextFunction } from "express"; +import { Sonos, Service } from "../sonos"; +import { Lang } from "../i8n"; +import { URLBuilder } from "../url_builder"; + +export const CREATE_REGISTRATION_ROUTE = "/s1/registration/add"; +export const REMOVE_REGISTRATION_ROUTE = "/s1/registration/remove"; + +export function makeS1Router( + sonos: Sonos, + service: Service, + langFor: (req: Request) => Lang, + bonobUrl: URLBuilder, + version: string, + enableS1: boolean +): Router { + const router = Router(); + + router.use((_req: Request, res: Response, next: NextFunction): void => { + if (!enableS1) { + res + .status(400) + .send("S1 routes are disabled, set BNB_SONOS_ENABLE_S1=true to enable"); + return; + } + next(); + }); + + router.get("/", (req, res) => { + const lang = langFor(req); + Promise.all([sonos.devices(), sonos.services()]).then( + ([devices, services]) => { + const registeredBonobService = services.find( + (it) => it.sid == service.sid + ); + res.render("s1", { + lang, + devices, + services, + bonobService: service, + registeredBonobService, + createRegistrationRoute: bonobUrl + .append({ pathname: CREATE_REGISTRATION_ROUTE }) + .pathname(), + removeRegistrationRoute: bonobUrl + .append({ pathname: REMOVE_REGISTRATION_ROUTE }) + .pathname(), + version, + }); + } + ); + }); + + router.post("/registration/add", (req, res) => { + const lang = langFor(req); + sonos.register(service).then((success) => { + if (success) { + res.render("success", { lang, message: lang("successfullyRegistered") }); + } else { + res.status(500).render("failure", { lang, message: lang("registrationFailed") }); + } + }); + }); + + router.post("/registration/remove", (req, res) => { + const lang = langFor(req); + sonos.remove(service.sid).then((success) => { + if (success) { + res.render("success", { lang, message: lang("successfullyRemovedRegistration") }); + } else { + res.status(500).render("failure", { lang, message: lang("failedToRemoveRegistration") }); + } + }); + }); + + return router; +} diff --git a/src/server.ts b/src/server.ts index 4b7c288..d28d579 100644 --- a/src/server.ts +++ b/src/server.ts @@ -15,14 +15,13 @@ import { PRESENTATION_MAP_ROUTE, SONOS_RECOMMENDED_IMAGE_SIZES, LOGIN_ROUTE, - CREATE_REGISTRATION_ROUTE, - REMOVE_REGISTRATION_ROUTE, sonosifyMimeType, ratingFromInt, ratingAsInt, splitId, shouldScrobble } from "./smapi"; +import { makeS1Router } from "./routes/s1"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { MusicService, AuthFailure, AuthSuccess } from "./music_library"; import bindSmapiSoapServiceToExpress from "./smapi"; @@ -105,6 +104,7 @@ export type ServerOpts = { smapiAuthTokens: SmapiAuthTokens; externalImageResolver: ImageFetcher; loginTheme: string; + enableS1: boolean; }; const DEFAULT_TIMEOUT = "1h" @@ -123,7 +123,8 @@ const DEFAULT_SERVER_OPTS: ServerOpts = { DEFAULT_TIMEOUT ), externalImageResolver: axiosImageFetcher, - loginTheme: DEFAULT_LOGIN_THEME + loginTheme: DEFAULT_LOGIN_THEME, + enableS1: false, }; function server( @@ -167,29 +168,11 @@ function server( return i8n(...asLANGs(req.headers["accept-language"])); }; - app.get("/", (req, res) => { - const lang = langFor(req); - Promise.all([sonos.devices(), sonos.services()]).then( - ([devices, services]) => { - const registeredBonobService = services.find( - (it) => it.sid == service.sid - ); - res.render("index", { - lang, - devices, - services, - bonobService: service, - registeredBonobService, - createRegistrationRoute: bonobUrl - .append({ pathname: CREATE_REGISTRATION_ROUTE }) - .pathname(), - removeRegistrationRoute: bonobUrl - .append({ pathname: REMOVE_REGISTRATION_ROUTE }) - .pathname(), - version: serverOpts.version || DEFAULT_SERVER_OPTS.version, - }); - } - ); + app.get("/", (_, res) => { + res.render("index", { + serviceName: service.name, + version: serverOpts.version || DEFAULT_SERVER_OPTS.version, + }); }); app.get("/about", (_, res) => { @@ -201,39 +184,7 @@ function server( }); }); - app.post(CREATE_REGISTRATION_ROUTE, (req, res) => { - const lang = langFor(req); - sonos.register(service).then((success) => { - if (success) { - res.render("success", { - lang, - message: lang("successfullyRegistered"), - }); - } else { - res.status(500).render("failure", { - lang, - message: lang("registrationFailed"), - }); - } - }); - }); - - app.post(REMOVE_REGISTRATION_ROUTE, (req, res) => { - const lang = langFor(req); - sonos.remove(service.sid).then((success) => { - if (success) { - res.render("success", { - lang, - message: lang("successfullyRemovedRegistration"), - }); - } else { - res.status(500).render("failure", { - lang, - message: lang("failedToRemoveRegistration"), - }); - } - }); - }); + app.use("/s1", makeS1Router(sonos, service, langFor, bonobUrl, serverOpts.version || DEFAULT_SERVER_OPTS.version, serverOpts.enableS1)); app.get(LOGIN_ROUTE, (req, res) => { const lang = langFor(req); diff --git a/src/smapi.ts b/src/smapi.ts index 2596f3a..3829aae 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -39,8 +39,6 @@ import { import { IncomingHttpHeaders } from "http"; export const LOGIN_ROUTE = "/login"; -export const CREATE_REGISTRATION_ROUTE = "/registration/add"; -export const REMOVE_REGISTRATION_ROUTE = "/registration/remove"; export const SOAP_PATH = "/ws/sonos"; export const STRINGS_ROUTE = "/sonos/strings.xml"; export const PRESENTATION_MAP_ROUTE = "/sonos/presentationMap.xml"; diff --git a/tests/config.test.ts b/tests/config.test.ts index 52f2455..b4e160a 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -305,6 +305,29 @@ describe("config", () => { describe("sonos", () => { beforeEach(() => { process.env["BNB_SECRET"] = "bonob"; + process.env["BNB_SONOS_ENABLE_S1"] = "true"; + }); + + describe("when BNB_SONOS_ENABLE_S1 is not set (default)", () => { + beforeEach(() => { + delete process.env["BNB_SONOS_ENABLE_S1"]; + }); + + it("should have fixed defaults regardless of env vars", () => { + process.env["BNB_SONOS_SERVICE_NAME"] = "custom-name"; + process.env["BNB_SONOS_DEVICE_DISCOVERY"] = "true"; + process.env["BNB_SONOS_SEED_HOST"] = "192.168.1.1"; + process.env["BNB_SONOS_AUTO_REGISTER"] = "true"; + process.env["BNB_SONOS_SERVICE_ID"] = "999"; + + const c = config().sonos; + expect(c.serviceName).toEqual("bonob"); + expect(c.discovery.enabled).toEqual(false); + expect(c.discovery.seedHost).toBeUndefined(); + expect(c.autoRegister).toEqual(false); + expect(c.sid).toEqual(-1); + expect(c.enableS1).toEqual(false); + }); }); describe("serviceName", () => { @@ -353,6 +376,13 @@ describe("config", () => { expect(config().sonos.sid).toEqual(786); }); }); + + describeBooleanConfigValue( + "enableS1", + "BNB_SONOS_ENABLE_S1", + false, + (config) => config.sonos.enableS1 + ); }); describe("subsonic", () => { @@ -411,6 +441,7 @@ describe("config", () => { true, (config) => config.subsonic.transcode ); + }); describe("scrobbling and reporting", () => { diff --git a/tests/scenarios.test.ts b/tests/scenarios.test.ts index 05bc679..df6d236 100644 --- a/tests/scenarios.test.ts +++ b/tests/scenarios.test.ts @@ -95,7 +95,7 @@ class SonosDriver { async register() { const action = await request(this.server) - .get(this.bonobUrl.append({ pathname: "/" }).pathname()) + .get(this.bonobUrl.append({ pathname: "/s1" }).pathname()) .expect(200) .then((response) => { const m = response.text.match(/ action="(.*)" /i); @@ -278,6 +278,7 @@ describe("scenarios", () => { musicService, { linkCodes: () => linkCodes, + enableS1: true, } ); @@ -295,7 +296,8 @@ describe("scenarios", () => { bonobUrl, musicService, { - linkCodes: () => linkCodes + linkCodes: () => linkCodes, + enableS1: true, } ); @@ -313,7 +315,8 @@ describe("scenarios", () => { bonobUrl, musicService, { - linkCodes: () => linkCodes + linkCodes: () => linkCodes, + enableS1: true, } ); diff --git a/tests/server.test.ts b/tests/server.test.ts index 38ca89b..0434f52 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -181,6 +181,63 @@ describe("server", () => { [bonobUrlWithNoContextPath, bonobUrlWithContextPath].forEach((bonobUrl) => { describe(`a bonobUrl of ${bonobUrl}`, () => { describe("/", () => { + describe("version", () => { + describe("when specified", () => { + const server = makeServer( + SONOS_DISABLED, + aService({ name: "myService" }), + bonobUrl, + new InMemoryMusicService(), + { version: "v123.456" } + ); + + it("should display it", async () => { + const res = await request(server) + .get(bonobUrl.append({ pathname: "/" }).pathname()) + .send(); + + expect(res.status).toEqual(200); + expect(res.text).toContain("v123.456"); + }); + }); + + describe("when not specified", () => { + const server = makeServer( + SONOS_DISABLED, + aService(), + bonobUrl, + new InMemoryMusicService() + ); + + it("should display the default", async () => { + const res = await request(server) + .get(bonobUrl.append({ pathname: "/" }).pathname()) + .send(); + + expect(res.status).toEqual(200); + expect(res.text).toContain("v?"); + }); + }); + }); + + it("should display the service name", async () => { + const server = makeServer( + SONOS_DISABLED, + aService({ name: "myService" }), + bonobUrl, + new InMemoryMusicService() + ); + + const res = await request(server) + .get(bonobUrl.append({ pathname: "/" }).pathname()) + .send(); + + expect(res.status).toEqual(200); + expect(res.text).toContain("myService"); + }); + }); + + describe("/s1", () => { describe("version", () => { describe("when specified", () => { const server = makeServer( @@ -190,15 +247,16 @@ describe("server", () => { new InMemoryMusicService(), { version: "v123.456", + enableS1: true, } ); - + it("should display it", async () => { const res = await request(server) - .get(bonobUrl.append({ pathname: "/" }).pathname()) + .get(bonobUrl.append({ pathname: "/s1" }).pathname()) .set("accept-language", acceptLanguage) .send(); - + expect(res.status).toEqual(200); expect(res.text).toContain('v123.456'); }); @@ -209,15 +267,16 @@ describe("server", () => { SONOS_DISABLED, aService(), bonobUrl, - new InMemoryMusicService() + new InMemoryMusicService(), + { enableS1: true } ); - + it("should display the default", async () => { const res = await request(server) - .get(bonobUrl.append({ pathname: "/" }).pathname()) + .get(bonobUrl.append({ pathname: "/s1" }).pathname()) .set("accept-language", acceptLanguage) .send(); - + expect(res.status).toEqual(200); expect(res.text).toContain("v?"); }); @@ -229,13 +288,14 @@ describe("server", () => { SONOS_DISABLED, aService(), bonobUrl, - new InMemoryMusicService() + new InMemoryMusicService(), + { enableS1: true } ); describe("devices list", () => { it("should be empty", async () => { const res = await request(server) - .get(bonobUrl.append({ pathname: "/" }).pathname()) + .get(bonobUrl.append({ pathname: "/s1" }).pathname()) .set("accept-language", acceptLanguage) .send(); @@ -265,13 +325,14 @@ describe("server", () => { fakeSonos, missingBonobService, bonobUrl, - new InMemoryMusicService() + new InMemoryMusicService(), + { enableS1: true } ); describe("devices list", () => { it("should be empty", async () => { const res = await request(server) - .get(bonobUrl.append({ pathname: "/" }).path()) + .get(bonobUrl.append({ pathname: "/s1" }).path()) .set("accept-language", acceptLanguage) .send(); @@ -285,7 +346,7 @@ describe("server", () => { describe("services", () => { it("should be empty", async () => { const res = await request(server) - .get(bonobUrl.append({ pathname: "/" }).path()) + .get(bonobUrl.append({ pathname: "/s1" }).path()) .set("accept-language", acceptLanguage) .send(); @@ -341,13 +402,14 @@ describe("server", () => { fakeSonos, missingBonobService, bonobUrl, - new InMemoryMusicService() + new InMemoryMusicService(), + { enableS1: true } ); describe("devices list", () => { it("should contain the devices returned from sonos", async () => { const res = await request(server) - .get(bonobUrl.append({ pathname: "/" }).path()) + .get(bonobUrl.append({ pathname: "/s1" }).path()) .set("accept-language", acceptLanguage) .send(); @@ -361,7 +423,7 @@ describe("server", () => { describe("services", () => { it("should contain a list of services returned from sonos", async () => { const res = await request(server) - .get(bonobUrl.append({ pathname: "/" }).path()) + .get(bonobUrl.append({ pathname: "/s1" }).path()) .set("accept-language", acceptLanguage) .send(); @@ -377,7 +439,7 @@ describe("server", () => { describe("registration status", () => { it("should be not-registered", async () => { const res = await request(server) - .get(bonobUrl.append({ pathname: "/" }).path()) + .get(bonobUrl.append({ pathname: "/s1" }).path()) .set("accept-language", acceptLanguage) .send(); expect(res.status).toEqual(200); @@ -429,13 +491,14 @@ describe("server", () => { fakeSonos, bonobService, bonobUrl, - new InMemoryMusicService() + new InMemoryMusicService(), + { enableS1: true } ); describe("registration status", () => { it("should be registered", async () => { const res = await request(server) - .get(bonobUrl.append({ pathname: "/" }).path()) + .get(bonobUrl.append({ pathname: "/s1" }).path()) .set("accept-language", acceptLanguage) .send(); expect(res.status).toEqual(200); @@ -498,7 +561,8 @@ describe("server", () => { sonos as unknown as Sonos, theService, bonobUrl, - new InMemoryMusicService() + new InMemoryMusicService(), + { enableS1: true } ); describe("registering", () => { @@ -507,7 +571,7 @@ describe("server", () => { sonos.register.mockResolvedValue(true); const res = await request(server) - .post(bonobUrl.append({ pathname: "/registration/add" }).path()) + .post(bonobUrl.append({ pathname: "/s1/registration/add" }).path()) .set("accept-language", acceptLanguage) .send(); @@ -525,7 +589,7 @@ describe("server", () => { sonos.register.mockResolvedValue(false); const res = await request(server) - .post(bonobUrl.append({ pathname: "/registration/add" }).path()) + .post(bonobUrl.append({ pathname: "/s1/registration/add" }).path()) .set("accept-language", acceptLanguage) .send(); @@ -546,7 +610,7 @@ describe("server", () => { const res = await request(server) .post( - bonobUrl.append({ pathname: "/registration/remove" }).path() + bonobUrl.append({ pathname: "/s1/registration/remove" }).path() ) .set("accept-language", acceptLanguage) .send(); @@ -566,7 +630,7 @@ describe("server", () => { const res = await request(server) .post( - bonobUrl.append({ pathname: "/registration/remove" }).path() + bonobUrl.append({ pathname: "/s1/registration/remove" }).path() ) .set("accept-language", acceptLanguage) .send(); @@ -580,6 +644,42 @@ describe("server", () => { }); }); }); + + describe("when S1 routes are disabled", () => { + const disabledServer = makeServer( + sonos as unknown as Sonos, + theService, + bonobUrl, + new InMemoryMusicService(), + { enableS1: false } + ); + + it("should return 400 for GET /s1", async () => { + const res = await request(disabledServer) + .get(bonobUrl.append({ pathname: "/s1" }).path()) + .send(); + expect(res.status).toEqual(400); + expect(res.text).toContain("S1 routes are disabled"); + }); + + it("should return 400 for POST /s1/registration/add", async () => { + const res = await request(disabledServer) + .post(bonobUrl.append({ pathname: "/s1/registration/add" }).path()) + .send(); + expect(res.status).toEqual(400); + expect(res.text).toContain("S1 routes are disabled"); + expect(sonos.register).not.toHaveBeenCalled(); + }); + + it("should return 400 for POST /s1/registration/remove", async () => { + const res = await request(disabledServer) + .post(bonobUrl.append({ pathname: "/s1/registration/remove" }).path()) + .send(); + expect(res.status).toEqual(400); + expect(res.text).toContain("S1 routes are disabled"); + expect(sonos.remove).not.toHaveBeenCalled(); + }); + }); }); describe("/login", () => { diff --git a/web/views/index.eta b/web/views/index.eta index 6568312..88460d9 100644 --- a/web/views/index.eta +++ b/web/views/index.eta @@ -2,44 +2,5 @@

\ No newline at end of file +

<%= it.serviceName %>

+ diff --git a/web/views/s1.eta b/web/views/s1.eta new file mode 100644 index 0000000..6568312 --- /dev/null +++ b/web/views/s1.eta @@ -0,0 +1,45 @@ +<% layout('./layout') %> + +
+
<%= it.version %>
+

<%= it.bonobService.name %> (<%= it.bonobService.sid %>)

+

<%= it.lang("expectedConfig") %>

+
<%= JSON.stringify(it.bonobService) %>
+
+ <% if(it.devices.length > 0) { %> +
+ "> +
+
+ <% } else { %> +

<%= it.lang("noSonosDevices") %>

+
+ <% } %> + + <% if(it.registeredBonobService) { %> +

<%= it.lang("existingServiceConfig") %>

+
<%= JSON.stringify(it.registeredBonobService) %>
+ <% } else { %> +

<%= it.lang("noExistingServiceRegistration") %>

+ <% } %> + <% if(it.registeredBonobService) { %> +
+
+ "> +
+ <% } %> + +
+

<%= it.lang("devices") %> (<%= it.devices.length %>)

+
    + <% it.devices.forEach(function(d){ %> +
  • <%= d.name %> (<%= d.ip %>:<%= d.port %>)
  • + <% }) %> +
+

<%= it.lang("services") %> (<%= it.services.length %>)

+
    + <% it.services.forEach(function(s){ %> +
  • <%= s.name %> (<%= s.sid %>)
  • + <% }) %> +
+
\ No newline at end of file From e46d40b6781a6d764d65ae5956562a78d4caa316 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:31:01 +1000 Subject: [PATCH 51/51] Bump form-data from 4.0.5 to 4.0.6 (#281) Bumps [form-data](https://github.com/form-data/form-data) from 4.0.5 to 4.0.6. - [Release notes](https://github.com/form-data/form-data/releases) - [Changelog](https://github.com/form-data/form-data/blob/master/CHANGELOG.md) - [Commits](https://github.com/form-data/form-data/compare/v4.0.5...v4.0.6) --- updated-dependencies: - dependency-name: form-data dependency-version: 4.0.6 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index ee18865..c56b895 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4028,16 +4028,16 @@ } }, "node_modules/form-data": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", + "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", - "hasown": "^2.0.2", - "mime-types": "^2.1.12" + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" @@ -4319,9 +4319,9 @@ } }, "node_modules/hasown": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", - "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", "license": "MIT", "dependencies": { "function-bind": "^1.1.2"