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/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 5572d34..2d93957 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,6 @@ -FROM node:22-bullseye +FROM node:22-bookworm + +ENV DEBIAN_FRONTEND=noninteractive LABEL maintainer=simojenki @@ -8,9 +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++ \ - vim + 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 <&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-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 f306284..77b60f8 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,28 +1,43 @@ { - "name": "bonob", + "name": "bonob-dev", "build": { "dockerfile": "Dockerfile" }, "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_SUBSONIC_URL": "${localEnv:BNB_DEV_SUBSONIC_URL}" + "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_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", + "--privileged", + "--security-opt=label=disable" + ], "forwardPorts": [4534], "features": { - "ghcr.io/devcontainers/features/docker-in-docker:2": { - "version": "latest", - "moby": true - } + "ghcr.io/anthropics/devcontainer-features/claude-code:1.0": {} + // , + // "ghcr.io/devcontainers/features/docker-in-docker:2": { + // "version": "latest", + // "moby": true + // } }, "customizations": { "vscode": { "extensions": [ - "esbenp.prettier-vscode", - "redhat.vscode-xml" - ] + "davidanson.vscode-markdownlint", + "esbenp.prettier-vscode", + "redhat.vscode-xml" + // "ms-azuretools.vscode-docker" + ] } } } 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/.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/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/Dockerfile b/Dockerfile index 6ad3552..b490405 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,7 @@ -FROM node:22-bullseye-slim AS build +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,16 +13,24 @@ 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-bullseye-slim +FROM node:22-bookworm-slim LABEL maintainer="simojenki" \ org.opencontainers.image.source="https://github.com/simojenki/bonob" \ @@ -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"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..7f9d2b4 --- /dev/null +++ b/Makefile @@ -0,0 +1,4 @@ +.PHONY: image + +image: + sudo podman build --format docker . \ No newline at end of file diff --git a/README.md b/README.md index f8b9878..6b05e60 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,39 @@ # 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 +- 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 - 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) +- Multiple registrations within a single household. + +### S1 specific features + +- Auto registration with Sonos on startup +- Auto discovery of Sonos devices +- Discovery of Sonos devices using seed IP address -## 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 -``` -or -```bash -docker pull ghcr.io/simojenki/bonob +docker run docker.io/simojenki/bonob +#or +docker run ghcr.io/simojenki/bonob ``` tag | description @@ -43,192 +42,76 @@ 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 -### 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 -``` +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). -#### 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 -``` - -### Running bonob and navidrome using docker-compose +The tldr; is: -```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 -``` +- 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. -### Running bonob on synology +[Sonos S2 setup](./docs/sonos-s2-setup.md) -[See this issue](https://github.com/simojenki/bonob/issues/15) +[Sonos S1 setup](./docs/sonos-s1-setup.md) ## Configuration 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_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_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) -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 +### Additional S1 configuration options -## Re-registering your bonob service with sonos App +These will have no effect if you do not set BNB_SONOS_ENABLE_S1=true -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); +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.** -- 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 +## Transcoding -Service should now be registered and everything should work as expected. +### Automatic (OpenSubsonic Transcoding extension) -## Multiple registrations within a single household. +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. -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. +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. -## Implementing a different music source other than a subsonic clone +**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. -- Implement the MusicService/MusicLibrary interface -- Startup bonob with your new implementation. +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. -## Transcoding +### Legacy transcoding options -### Transcode everything +#### 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 +#### 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 +120,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 +132,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" @@ -257,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 @@ -288,7 +170,48 @@ 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 + +### 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_SUBSONIC_URL: http://navidrome:4533 +``` + +### Running bonob on synology + +[See this issue](https://github.com/simojenki/bonob/issues/15) ## 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 0000000..efef817 Binary files /dev/null and b/docs/images/about.png differ diff --git a/docs/images/s2ImagePatterns.png b/docs/images/s2ImagePatterns.png new file mode 100644 index 0000000..49979fa Binary files /dev/null and b/docs/images/s2ImagePatterns.png differ diff --git a/docs/sonos-s1-setup.md b/docs/sonos-s1-setup.md new file mode 100644 index 0000000..234c40b --- /dev/null +++ b/docs/sonos-s1-setup.md @@ -0,0 +1,110 @@ +# Sonos S1 setup + +## Running bonob itself + +### Full Sonos device auto-discovery and auto-registration using docker --network host + +```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 \ + --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_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 \ + -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_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 \ + -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.md b/docs/sonos-s2-setup.md new file mode 100644 index 0000000..546b9eb --- /dev/null +++ b/docs/sonos-s2-setup.md @@ -0,0 +1,132 @@ +# Setting up Sonos Service + +Credit goes to [@wkulhanek](https://github.com/wkulhanek) for writing up these instructions and providing the navidrome artwork. + +## 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) + +* 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 0000000..7063b99 Binary files /dev/null and b/docs/sonos_service/images/about.png differ 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 0000000..ef6096c Binary files /dev/null and b/docs/sonos_service/sonos_artwork/navidrome 112x112.png differ 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 0000000..3a9299f Binary files /dev/null and b/docs/sonos_service/sonos_artwork/navidrome 200x200.png differ diff --git a/docs/sonos_service/sonos_artwork/navidrome 20x20.png b/docs/sonos_service/sonos_artwork/navidrome 20x20.png new file mode 100644 index 0000000..df74fd5 Binary files /dev/null and b/docs/sonos_service/sonos_artwork/navidrome 20x20.png differ diff --git a/docs/sonos_service/sonos_artwork/navidrome 400x400.png b/docs/sonos_service/sonos_artwork/navidrome 400x400.png new file mode 100644 index 0000000..5bf4627 Binary files /dev/null and b/docs/sonos_service/sonos_artwork/navidrome 400x400.png differ 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 0000000..f79ff00 Binary files /dev/null and b/docs/sonos_service/sonos_artwork/navidrome 40x40.png differ diff --git a/docs/sonos_service/sonos_artwork/navidrome 80x80.png b/docs/sonos_service/sonos_artwork/navidrome 80x80.png new file mode 100644 index 0000000..1d8bd45 Binary files /dev/null and b/docs/sonos_service/sonos_artwork/navidrome 80x80.png differ 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. + + + + + + + + + + + + + + + + + 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 a44dfee..c56b895 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,181 +9,88 @@ "version": "0.0.1", "license": "GPL-3.0-only", "dependencies": { - "@svrooij/sonos": "^2.6.0-beta.11", - "@types/express": "^4.17.21", - "@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/randomstring": "^1.3.0", + "@svrooij/sonos": "^2.6.0-beta.13", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/morgan": "^1.9.10", + "@types/node": "^24.12.2", "@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", + "@xmldom/xmldom": "^0.9.10", + "axios": "^1.15.0", + "dayjs": "^1.11.20", "eta": "^2.2.0", - "express": "^4.18.3", - "fp-ts": "^2.16.9", - "fs-extra": "^11.2.0", - "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", - "underscore": "^1.13.7", - "urn-lib": "^2.0.0", - "uuid": "^11.0.3", - "winston": "^3.17.0", + "express": "^5.2.1", + "fp-ts": "^2.16.11", + "jsonwebtoken": "^9.0.3", + "morgan": "^1.10.1", + "node-html-parser": "^7.1.0", + "sharp": "^0.34.5", + "soap": "^1.9.0", + "typescript": "^5.9.3", + "underscore": "^1.13.8", + "winston": "^3.19.0", "xmldom-ts": "^0.3.1", "xpath": "^0.0.34" }, "devDependencies": { - "@types/chai": "^5.0.1", - "@types/jest": "^29.5.14", - "@types/mocha": "^10.0.10", - "@types/supertest": "^6.0.2", - "@types/tmp": "^0.2.6", - "chai": "^5.1.2", - "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", + "@swc/core": "^1.15.24", + "@swc/jest": "^0.2.39", + "@types/jest": "^30.0.0", + "@types/jws": "^3.2.11", + "@types/supertest": "^6.0.3", + "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" } }, - "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", + "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 +106,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 +139,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 +182,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.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, - "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 +256,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 +269,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 +282,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 +290,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 +340,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 +349,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.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.22.5" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" @@ -545,6 +369,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 +382,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 +395,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 +408,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 +421,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 +434,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 +442,12 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-top-level-await": { + "node_modules/@babel/plugin-syntax-private-property-in-object": { "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==", + "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" }, @@ -627,13 +458,14 @@ "@babel/core": "^7.0.0-0" } }, - "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==", + "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.22.5" + "@babel/helper-plugin-utils": "^7.14.5" }, "engines": { "node": ">=6.9.0" @@ -642,62 +474,65 @@ "@babel/core": "^7.0.0-0" } }, - "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==", + "node_modules/@babel/plugin-syntax-typescript": { + "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": { - "regenerator-runtime": "^0.14.0" + "@babel/helper-plugin-utils": "^7.28.6" }, "engines": { "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-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 +542,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 +577,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.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.2.1", + "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.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": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "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, "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.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" ], @@ -779,13 +648,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.0.4" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "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.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" ], @@ -801,13 +670,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.0.4" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "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.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" ], @@ -821,9 +690,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.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" ], @@ -837,9 +706,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.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" ], @@ -853,9 +722,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.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" ], @@ -868,12 +737,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.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": [ - "s390x" + "ppc64" ], "license": "LGPL-3.0-or-later", "optional": true, @@ -884,12 +753,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-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": [ - "x64" + "riscv64" ], "license": "LGPL-3.0-or-later", "optional": true, @@ -900,12 +769,12 @@ "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-s390x": { + "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": [ - "arm64" + "s390x" ], "license": "LGPL-3.0-or-later", "optional": true, @@ -916,10 +785,10 @@ "url": "https://opencollective.com/libvips" } }, - "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==", + "node_modules/@img/sharp-libvips-linux-x64": { + "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" ], @@ -932,10 +801,42 @@ "url": "https://opencollective.com/libvips" } }, - "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==", + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "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" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.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" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "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" ], @@ -951,13 +852,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.0.5" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "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.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" ], @@ -973,13 +874,57 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.0.4" + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "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" + ], + "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" + ], + "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-riscv64": "1.2.4" } }, "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.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -995,13 +940,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.0.4" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "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.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -1017,13 +962,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.0.4" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "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.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -1039,13 +984,13 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "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.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -1061,21 +1006,40 @@ "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.0.4" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "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.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.2.0" + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "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" + ], + "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" }, @@ -1084,9 +1048,9 @@ } }, "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.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" ], @@ -1103,9 +1067,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.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" ], @@ -1121,11 +1085,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 +1130,60 @@ } }, "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.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": "^29.6.3", + "@jest/types": "30.3.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.3.0", + "jest-util": "30.3.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.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": "^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.3.0", + "@jest/pattern": "30.0.1", + "@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.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.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.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": { - "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 +1194,163 @@ } } }, + "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.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": { + "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.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": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "^29.7.0" + "jest-mock": "30.3.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.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": "^29.7.0", - "jest-snapshot": "^29.7.0" + "expect": "30.3.0", + "jest-snapshot": "30.3.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.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": { - "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.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": "^29.6.3", - "@sinonjs/fake-timers": "^10.0.2", + "@jest/types": "30.3.0", + "@sinonjs/fake-timers": "^15.0.0", "@types/node": "*", - "jest-message-util": "^29.7.0", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "jest-message-util": "30.3.0", + "jest-mock": "30.3.0", + "jest-util": "30.3.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.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.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" + } + }, + "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": { - "@jest/environment": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/types": "^29.6.3", - "jest-mock": "^29.7.0" + "@types/node": "*", + "jest-regex-util": "30.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/reporters": { - "version": "29.7.0", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", - "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", + "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": "^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.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.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.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": "^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.3.0", + "jest-util": "30.3.0", + "jest-worker": "30.3.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 +1362,146 @@ } }, "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.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": { - "@sinclair/typebox": "^0.27.8" + "@jest/types": "30.3.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.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": "^29.7.0", - "@jest/types": "^29.6.3", - "@types/istanbul-lib-coverage": "^2.0.0", - "collect-v8-coverage": "^1.0.0" + "@jest/console": "30.3.0", + "@jest/types": "30.3.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.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": "^29.7.0", - "graceful-fs": "^4.2.9", - "jest-haste-map": "^29.7.0", + "@jest/test-result": "30.3.0", + "graceful-fs": "^4.2.11", + "jest-haste-map": "30.3.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.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.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.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.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.3.0", + "jest-regex-util": "30.0.1", + "jest-util": "30.3.0", + "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.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": { - "@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,149 +1513,517 @@ "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": "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": { - "@sinonjs/commons": "^3.0.0" + "@sinonjs/commons": "^3.0.1" } }, - "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==", + "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": { - "@rgrove/parse-xml": "^4.1.0", - "debug": "4.3.4", - "html-entities": "^2.4.0", - "node-fetch": "^2.6.7", - "typed-emitter": "^2.1.0", - "ws": "^8.12.6" + "color": "^5.0.2", + "text-hex": "1.0.x" } }, - "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==", - "dev": true, + "node_modules/@svrooij/sonos": { + "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": { - "@babel/runtime": "^7.10.3" + "@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.18.1" } }, - "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/@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, + "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": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, - "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==", + "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, + "hasInstallScript": true, + "license": "Apache-2.0", "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==", + "@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/@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": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "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": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "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": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "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": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "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": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "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": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "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, + "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, + "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, + "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.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": { @@ -1601,16 +2035,6 @@ "@types/node": "*" } }, - "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==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*" - } - }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -1625,29 +2049,21 @@ "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": "4.17.21", - "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", - "integrity": "sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==", + "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": "^4.17.33", - "@types/qs": "*", - "@types/serve-static": "*" + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" } }, "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": "*", @@ -1656,28 +2072,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==", - "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", - "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", @@ -1704,37 +2103,31 @@ } }, "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" - } - }, - "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": "*" + "expect": "^30.0.0", + "pretty-format": "^30.0.0" } }, "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==", + "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -1746,50 +2139,34 @@ "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.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.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": "~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 - }, "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==", - "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==", + "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/range-parser": { @@ -1799,22 +2176,21 @@ "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": "*" } }, "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": "*" } }, @@ -1822,7 +2198,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,21 +2213,16 @@ } }, "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" } }, - "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", @@ -1863,12 +2235,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", @@ -1876,10 +2242,11 @@ "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==", + "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": "*" } @@ -1888,7 +2255,284 @@ "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 + "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": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "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", @@ -1900,9 +2544,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.10", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.9.10.tgz", + "integrity": "sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==", "license": "MIT", "engines": { "node": ">=14.6" @@ -1915,12 +2559,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 +2618,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 +2630,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,29 +2681,16 @@ "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", "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", @@ -2044,14 +2702,14 @@ "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.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.6", - "form-data": "^4.0.0", - "proxy-from-env": "^1.1.0" + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" } }, "node_modules/axios-ntlm": { @@ -2066,110 +2724,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.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": "^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.3.0", + "@types/babel__core": "^7.20.5", + "babel-plugin-istanbul": "^7.0.1", + "babel-preset-jest": "30.3.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.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": { - "@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.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": "^29.6.3", - "babel-preset-current-node-syntax": "^1.0.0" + "babel-plugin-jest-hoist": "30.3.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 +2828,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", @@ -2203,82 +2863,63 @@ "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 - }, "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.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": "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.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.0", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" }, "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" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "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.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": { "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 +2935,13 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "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" @@ -2307,18 +2950,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", @@ -2337,7 +2968,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 +2980,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 +3014,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 +3024,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,30 +3047,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] - }, - "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 - }, - "node_modules/chai": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-5.1.2.tgz", - "integrity": "sha512-aGtmf24DW6MLHHG5gCx4zaI3uBq3KRtxeVs0DjFH6Z0rDNbsvTxFASFvdj79pxjxZ8/5u3PIiN3IwEIQkiiuPw==", - "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" - } + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "4.1.2", @@ -2449,18 +3071,9 @@ "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, - "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==", - "dev": true, "license": "MIT", "engines": { - "node": ">= 16" + "node": ">=10" } }, "node_modules/chokidar": { @@ -2491,9 +3104,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 +3114,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.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" }, "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 +3141,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,48 +3250,51 @@ "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==", + "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-name": "^1.0.0", - "simple-swizzle": "^0.2.2" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=18" } }, - "node_modules/colorspace": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/colorspace/-/colorspace-1.1.4.tgz", - "integrity": "sha512-BgvKJiuVu1igBUF2kEjRCZXol6wiiGbY5ipL/oVPwm0BL9sIpMIzM8IK7vwuxIIzOXMV3Ey5w+vxhm0rR/TN8w==", - "dependencies": { - "color": "^3.1.3", - "text-hex": "1.0.x" + "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": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/color/-/color-3.2.1.tgz", - "integrity": "sha512-aBl7dZI9ENN6fUGC7mWpMTPNHmWUSNan9tuWN6ahh5ZLNk9baLJOnSMlrQkHcrfFgz2/RigjUVAjdx36VcemKA==", + "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-convert": "^1.9.3", - "color-string": "^1.6.0" + "color-name": "^2.0.0" + }, + "engines": { + "node": ">=14.6" } }, - "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==", - "dependencies": { - "color-name": "1.1.3" + "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/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/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -2637,9 +3323,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 +3359,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 +3374,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 +3381,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 +3422,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.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", "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 +3445,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.2", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", + "integrity": "sha512-WzMx3mW98SN+zn3hgemf4OzdmyNhhhKz5Ay0pUfQiMQ3e1g+xmTJWp/pKdwKVXhdSkAEGIIzqeuWrL3mV/AXbA==", "dev": true, + "license": "MIT", "peerDependencies": { "babel-plugin-macros": "^3.1.0" }, @@ -2786,39 +3459,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, + "license": "MIT", "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 +3494,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 +3508,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" } @@ -2889,23 +3528,15 @@ } }, "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" } }, - "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 +3588,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 +3622,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 +3643,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 +3664,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 +3676,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 +3703,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 +3751,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 +3761,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 +3796,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,118 +3815,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/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", + "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.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": "^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.3.0", + "@jest/get-type": "30.1.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": "^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" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "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==", - "dependencies": { - "ms": "2.0.0" - } - }, - "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, + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", "dependencies": { - "iobuffer": "^5.1.0" + "accepts": "^2.0.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", + "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/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, - "dependencies": { - "iobuffer": "^2.1.0", - "tiff": "^2.0.0" + "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.6" } }, - "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 - }, - "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, + "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": { - "iobuffer": "^2.1.0" + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">= 0.6" } }, "node_modules/fast-json-stable-stringify": { @@ -3291,23 +3920,6 @@ "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 - }, - "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==", - "dev": true, - "dependencies": { - "@types/pako": "^2.0.0", - "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", @@ -3330,59 +3942,12 @@ "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 - }, - "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/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 +3956,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", @@ -3442,9 +3991,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.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", @@ -3461,28 +4010,52 @@ } } }, + "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/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.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", - "mime-types": "^2.1.12" + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.4", + "mime-types": "^2.1.35" }, "engines": { "node": ">= 6" } }, "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,38 +4069,26 @@ } }, "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_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==", - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" + "node": ">= 0.8" } }, "node_modules/fs.realpath": { "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 +4126,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,14 +4160,15 @@ "resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz", "integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.0.0" } }, "node_modules/get-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": { @@ -3619,10 +4178,25 @@ "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", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -3630,21 +4204,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", "dev": true, + "license": "ISC", "dependencies": { - "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 +4238,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.1.0", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, "engines": { - "node": ">=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" @@ -3686,7 +4279,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==" + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true }, "node_modules/has-flag": { "version": "4.0.0", @@ -3697,28 +4291,10 @@ "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==", - "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" - } - }, - "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 +4303,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 +4319,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.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" }, @@ -3758,18 +4338,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 +4351,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,25 +4377,39 @@ "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.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" + "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": { @@ -3830,60 +4418,12 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "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==", - "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.1.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.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 +4443,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 +4452,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" @@ -3922,12 +4465,6 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "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 - }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -3936,23 +4473,12 @@ "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 - }, - "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 - }, "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 +4492,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", @@ -3987,23 +4501,12 @@ "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, - "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", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -4013,6 +4516,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" } @@ -4029,24 +4533,22 @@ "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, - "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", "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 +4564,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 +4577,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 +4593,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 +4606,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 +4622,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 +4650,39 @@ "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.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": "^29.7.0", - "@jest/types": "^29.6.3", - "import-local": "^3.0.2", - "jest-cli": "^29.7.0" + "@jest/core": "30.3.0", + "@jest/types": "30.3.0", + "import-local": "^3.2.0", + "jest-cli": "30.3.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 +4694,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.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.0.0", - "jest-util": "^29.7.0", + "execa": "^5.1.1", + "jest-util": "30.3.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.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": "^29.7.0", - "@jest/expect": "^29.7.0", - "@jest/test-result": "^29.7.0", - "@jest/types": "^29.6.3", + "@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.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.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": "^29.7.0", - "pure-rand": "^6.0.0", + "pretty-format": "30.3.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.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.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.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.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 +4774,236 @@ } }, "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.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.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.5.0", + "graceful-fs": "^4.2.11", + "jest-circus": "30.3.0", + "jest-docblock": "30.2.0", + "jest-environment-node": "30.3.0", + "jest-regex-util": "30.0.1", + "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": "^29.7.0", + "pretty-format": "30.3.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.3.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-30.3.0.tgz", + "integrity": "sha512-n3q4PDQjS4LrKxfWB3Z5KNk1XjXtZTBwQp71OP0Jo03Z6V60x++K5L8k6ZrW8MY8pOFylZvHM0zsjS1RqlHJZQ==", "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.3.0", + "@jest/get-type": "30.1.0", + "chalk": "^4.1.2", + "pretty-format": "30.3.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.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/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.3.0", + "chalk": "^4.1.2", + "jest-util": "30.3.0", + "pretty-format": "30.3.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.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": "^29.7.0", - "@jest/fake-timers": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/environment": "30.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-mock": "^29.7.0", - "jest-util": "^29.7.0" + "jest-mock": "30.3.0", + "jest-util": "30.3.0", + "jest-validate": "30.3.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.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": "^29.6.3", - "@types/graceful-fs": "^4.1.3", + "@jest/types": "30.3.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.3.0", + "jest-worker": "30.3.0", + "picomatch": "^4.0.3", "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-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": "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.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": "^29.6.3", - "pretty-format": "^29.7.0" + "@jest/get-type": "30.1.0", + "pretty-format": "30.3.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.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": { - "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.3.0", + "pretty-format": "30.3.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.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.3.0", + "@types/stack-utils": "^2.0.3", + "chalk": "^4.1.2", + "graceful-fs": "^4.2.11", + "picomatch": "^4.0.3", + "pretty-format": "30.3.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-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": "29.7.0", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", - "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "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": "^29.6.3", + "@jest/types": "30.3.0", "@types/node": "*", - "jest-util": "^29.7.0" + "jest-util": "30.3.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 +5011,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,163 +5025,156 @@ } }, "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.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.3.0", + "jest-pnp-resolver": "^1.2.3", + "jest-util": "30.3.0", + "jest-validate": "30.3.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.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": "^29.6.3", - "jest-snapshot": "^29.7.0" + "jest-regex-util": "30.0.1", + "jest-snapshot": "30.3.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.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": "^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.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.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.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" }, "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.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.3.0", + "@jest/fake-timers": "30.3.0", + "@jest/globals": "30.3.0", + "@jest/source-map": "30.0.1", + "@jest/test-result": "30.3.0", + "@jest/transform": "30.3.0", + "@jest/types": "30.3.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.5.0", + "graceful-fs": "^4.2.11", + "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.3.0", + "jest-snapshot": "30.3.0", + "jest-util": "30.3.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.3.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-30.3.0.tgz", + "integrity": "sha512-f14c7atpb4O2DeNhwcvS810Y63wEn8O1HqK/luJ4F6M4NjvxmAKQwBUWjbExUtMxWJQ0wVgmCKymeJK6NZMnfQ==", "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.3.0", + "@jest/get-type": "30.1.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.3.0", + "graceful-fs": "^4.2.11", + "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" + }, + "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.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -4669,44 +5182,53 @@ "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 - }, "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.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": "^29.6.3", + "@jest/types": "30.3.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.3" }, "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.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-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.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/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.3.0", + "camelcase": "^6.3.0", + "chalk": "^4.1.2", "leven": "^3.1.0", - "pretty-format": "^29.7.0" + "pretty-format": "30.3.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 +5236,7 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -4722,37 +5245,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.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": "^29.7.0", - "@jest/types": "^29.6.3", + "@jest/test-result": "30.3.0", + "@jest/types": "30.3.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.3.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.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": "*", - "jest-util": "^29.7.0", + "@ungap/structured-clone": "^1.3.0", + "jest-util": "30.3.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 +5286,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" }, @@ -4770,40 +5297,24 @@ "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 - }, "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==", + "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": { "argparse": "^1.0.7", "esprima": "^4.0.0" @@ -4813,22 +5324,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", @@ -4842,23 +5355,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", @@ -4874,25 +5384,6 @@ "npm": ">=6" } }, - "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==", - "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.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "dependencies": { - "jwa": "^1.4.1", - "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", @@ -4924,43 +5415,38 @@ "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" } }, - "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 +5455,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", @@ -4984,9 +5471,11 @@ } }, "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "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": { "version": "4.3.0", @@ -5018,12 +5507,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", @@ -5046,20 +5529,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 +5544,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 +5555,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.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -5106,12 +5568,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 +5583,32 @@ "tmpl": "1.0.5" } }, - "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==", + "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.6" + "node": ">= 0.4" } }, - "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 + "node_modules/media-typer": { + "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.8" + } }, "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" } @@ -5155,324 +5617,99 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true - }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "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==", - "dev": true, - "dependencies": { - "braces": "^3.0.2", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": { - "mime-db": "1.52.0" - }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mimic-fn": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", - "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "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==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "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, - "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, - "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, - "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, - "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, - "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 - }, - "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 - }, - "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 - }, - "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, - "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, - "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 - }, - "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 - }, - "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==", - "dev": true, - "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, - "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, - "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, - "dependencies": { - "is-any-array": "^2.0.0" - } + "dev": true }, - "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==", + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", "dev": true, - "dependencies": { - "ml-regression-base": "^2.1.3", - "ml-regression-simple-linear": "^2.0.3" + "license": "MIT", + "engines": { + "node": ">= 0.6" } }, - "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==", + "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, - "dependencies": { - "ml-matrix": "^6.10.1" + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4.0.0" } }, - "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, - "dependencies": { - "ml-matrix": "^6.8.0", - "ml-regression-base": "^2.1.3" + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" } }, - "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, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "dependencies": { - "ml-regression-base": "^2.0.1", - "ml-regression-simple-linear": "^2.0.2" + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" } }, - "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==", + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, - "dependencies": { - "ml-matrix": "^6.8.0", - "ml-regression-base": "^2.1.3" + "license": "MIT", + "engines": { + "node": ">=6" } }, - "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==", + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, + "node_modules/minimatch": { + "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": { - "ml-regression-base": "^2.0.1" + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" } }, - "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==", + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", "dev": true, - "dependencies": { - "ml-array-median": "^1.1.1", - "ml-regression-base": "^2.0.1" + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" } }, - "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 - }, - "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 - }, "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,36 +5740,43 @@ } }, "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/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 - }, - "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 - }, "node_modules/node-fetch": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", @@ -5553,9 +5797,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.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", @@ -5569,22 +5813,23 @@ "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.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", @@ -5603,6 +5848,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", @@ -5624,6 +5892,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", @@ -5666,11 +5950,27 @@ "node": ">=0.10.0" } }, + "node_modules/npm-check-updates": { + "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": { + "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 +5990,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 +6014,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 +6043,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 +6059,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 +6106,19 @@ "node": ">=6" } }, - "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 + "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/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 +6155,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,42 +6165,58 @@ "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.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", + "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", - "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" }, @@ -5903,10 +6225,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 +6239,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 +6248,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.3.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.3.0.tgz", + "integrity": "sha512-oG4T3wCbfeuvljnyAzhBvpN45E8iOTXCU/TD3zXW80HA3dQ4ahdqMkWGiPWZvjpQwlbyHrPTWUAqUzGzv4l1JQ==", "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 +6267,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 +6275,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", @@ -5975,9 +6288,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", @@ -5985,10 +6302,19 @@ "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": "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 +6325,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.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.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -6016,25 +6343,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/randombytes": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.3.tgz", - "integrity": "sha512-lDVjxQQFoCG1jcrP06LNo2lbWp4QTShEXnhActFBwYuHprllQV6VUpwreApsYqCgD+N1mHoqJ/BI/4eV4R2GYg==" - }, - "node_modules/randomstring": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/randomstring/-/randomstring-1.3.0.tgz", - "integrity": "sha512-gY7aQ4i1BgwZ8I1Op4YseITAyiDiajeZOPQUbIq9TPGPhUm5FX59izIaOpmKbME1nmnEiABf28d9K2VSii6BBg==", - "dependencies": { - "randombytes": "2.0.3" - }, - "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", @@ -6045,25 +6353,26 @@ } }, "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/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 +6400,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,62 +6432,27 @@ "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", - "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, + "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": { - "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 + "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" @@ -6240,10 +6493,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", @@ -6255,89 +6511,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 +6575,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.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "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.2", + "semver": "^7.7.3" }, "engines": { "node": "^18.17.0 || ^20.3.0 || >=21.0.0" @@ -6364,31 +6592,36 @@ "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.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": { - "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 +6635,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 +6648,22 @@ "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": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "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" @@ -6436,24 +6672,71 @@ "url": "https://github.com/sponsors/ljharb" } }, - "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 + "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/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==", + "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": { - "is-arrayish": "^0.3.1" + "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/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/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-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "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/simple-update-notifier": { "version": "2.0.0", @@ -6500,12 +6783,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,55 +6793,22 @@ } }, "node_modules/soap": { - "version": "1.1.6", - "resolved": "https://registry.npmjs.org/soap/-/soap-1.1.6.tgz", - "integrity": "sha512-em3PDqr5kQjzDRkWRQ4JMCPg32uMonSdLds0QgRJrJBLid1/LHdhUgQuPxJA6SFV1/58Wu7HWIypmW+vqmUPlw==", - "license": "MIT", - "dependencies": { - "axios": "^1.7.7", - "axios-ntlm": "^1.4.2", - "debug": "^4.3.6", - "formidable": "^3.5.1", - "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==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/soap/-/soap-1.9.0.tgz", + "integrity": "sha512-rPBwstctI7bM7WElH7exDn48ndK+KLEFNjbm1nz26TocSccO+LysxwVh9VtSlLrxCfbMfPygZmQN5EVTMf9DEQ==", "license": "MIT", "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" + "axios": "^1.13.6", + "axios-ntlm": "^1.4.6", + "debug": "^4.4.3", + "follow-redirects": "^1.15.11", + "formidable": "^3.5.4", + "sax": "^1.5.0", + "whatwg-mimetype": "5.0.0", + "xml-crypto": "^6.1.2" }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "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", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", "engines": { - "node": ">=4" + "node": ">=20.19.0" } }, "node_modules/source-map": { @@ -6572,6 +6816,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 +6826,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 +6836,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 +6852,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 +6861,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 +6883,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 +6892,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 +6949,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.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.2.2" + }, + "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 +7009,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 +7034,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 +7044,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 +7053,36 @@ } }, "node_modules/superagent": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/superagent/-/superagent-9.0.2.tgz", - "integrity": "sha512-xuW7dzkUpcJq7QnhOsnNUgtYp3xRwpt2F7abdRYIpCsAt0hhUqia0EdxyXZQQpNmGtsCzYHryaKSV3q3GJnq7w==", + "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": { - "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.5", + "formidable": "^3.5.4", "methods": "^1.1.2", "mime": "2.6.0", - "qs": "^6.11.0" + "qs": "^6.14.1" }, "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.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": "^9.0.1" + "superagent": "^10.3.0" }, "engines": { "node": ">=14.18.0" @@ -6759,16 +7100,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.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", "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,39 +7121,43 @@ "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", "minimatch": "^3.0.4" }, "engines": { - "node": ">=8" + "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==" - }, - "node_modules/tiff": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/tiff/-/tiff-5.0.3.tgz", - "integrity": "sha512-R0WckwRGhawWDNdha8iPQCjHyOiaEEmfFjhmalUVCIEELsON7Y/XO3eeGmBkoCXQp0Gg2nmTozN92Z4hlwbsow==", - "dev": true, - "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==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.14" - } + "integrity": "sha512-uuVGNWzgJ4yhRaNSiubPY7OjISw4sw4E5Uv0wbjp+OzcbmVU/rsT8ujgcXJhn9ypzsgr5vlzpPqP+MBBKcGvbg==", + "license": "MIT" }, "node_modules/tmpl": { "version": "1.0.5", @@ -6816,20 +7165,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" }, @@ -6874,9 +7215,16 @@ } }, "node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } }, "node_modules/triple-beam": { "version": "1.4.1", @@ -6887,76 +7235,6 @@ "node": ">= 14.0.0" } }, - "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==", - "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", - "json5": "^2.2.3", - "lodash.memoize": "^4.1.2", - "make-error": "^1.3.6", - "semver": "^7.6.3", - "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", - "@jest/types": "^29.0.0", - "babel-jest": "^29.0.0", - "jest": "^29.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 - } - } - }, - "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==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "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==", - "engines": { - "node": ">=12" - } - }, "node_modules/ts-mockito": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/ts-mockito/-/ts-mockito-2.6.1.tgz", @@ -7015,23 +7293,12 @@ "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", "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", "dev": true, + "license": "MIT", "engines": { "node": ">=4" } @@ -7041,6 +7308,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 +7317,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,14 +7355,15 @@ "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", "bin": { "tsc": "bin/tsc", @@ -7089,23 +7380,16 @@ "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": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" - }, - "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" - } + "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/unpipe": { "version": "1.0.0", @@ -7116,10 +7400,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 +7454,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" @@ -7146,38 +7466,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/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==", - "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", @@ -7185,10 +7479,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", @@ -7215,33 +7510,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 - }, "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": { @@ -7249,6 +7546,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 +7558,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.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", - "@dabh/diagnostics": "^2.0.2", + "@dabh/diagnostics": "^2.0.8", "async": "^3.2.3", "is-stream": "^2.0.0", "logform": "^2.7.0", @@ -7296,10 +7594,30 @@ } }, "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 +7630,88 @@ "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/ws": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz", - "integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==", + "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" }, @@ -7351,9 +7729,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 +7743,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.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" @@ -7386,6 +7764,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 +7789,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 +7798,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 +7829,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 +7888,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 e7b4e5a..c81c3cc 100644 --- a/package.json +++ b/package.json @@ -6,68 +6,57 @@ "author": "simojenki ", "license": "GPL-3.0-only", "dependencies": { - "@svrooij/sonos": "^2.6.0-beta.11", - "@types/express": "^4.17.21", - "@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/randomstring": "^1.3.0", + "@svrooij/sonos": "^2.6.0-beta.13", + "@types/express": "^5.0.6", + "@types/jsonwebtoken": "^9.0.10", + "@types/morgan": "^1.9.10", + "@types/node": "^24.12.2", "@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", + "@xmldom/xmldom": "^0.9.10", + "axios": "^1.15.0", + "dayjs": "^1.11.20", "eta": "^2.2.0", - "express": "^4.18.3", - "fp-ts": "^2.16.9", - "fs-extra": "^11.2.0", - "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", - "underscore": "^1.13.7", - "urn-lib": "^2.0.0", - "uuid": "^11.0.3", - "winston": "^3.17.0", + "express": "^5.2.1", + "fp-ts": "^2.16.11", + "jsonwebtoken": "^9.0.3", + "morgan": "^1.10.1", + "node-html-parser": "^7.1.0", + "sharp": "^0.34.5", + "soap": "^1.9.0", + "typescript": "^5.9.3", + "underscore": "^1.13.8", + "winston": "^3.19.0", "xmldom-ts": "^0.3.1", "xpath": "^0.0.34" }, "devDependencies": { - "@types/chai": "^5.0.1", - "@types/jest": "^29.5.14", - "@types/mocha": "^10.0.10", - "@types/supertest": "^6.0.2", - "@types/tmp": "^0.2.6", - "chai": "^5.1.2", - "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", + "@swc/core": "^1.15.24", + "@swc/jest": "^0.2.39", + "@types/jest": "^30.0.0", + "@types/jws": "^3.2.11", + "@types/supertest": "^6.0.3", + "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", "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-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_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", "testw": "jest --watch", "gitinfo": "git describe --tags > .gitinfo" 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 da16958..154585a 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"; @@ -47,7 +47,8 @@ const subsonic = new SubsonicMusicService( customPlayers, artistImageFetcher ), - customPlayers + customPlayers, + config.subsonic.transcode ); const featureFlagAwareMusicService: MusicService = { @@ -88,14 +89,16 @@ 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, logRequests: config.logRequests, version, smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout), - externalImageResolver: artistImageFetcher + externalImageResolver: artistImageFetcher, + loginTheme: config.loginTheme, + enableS1: config.sonos.enableS1, } ); 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/config.ts b/src/config.ts index 4ed7f6f..a14ab49 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+$/; @@ -49,19 +50,54 @@ 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"; const asInt = (value: string) => Number.parseInt(value); -export default function () { +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 + } +} + + +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", { - legacy: ["BONOB_WEB_ADDRESS"], default: `http://${hostname()}:${port}`, })!; @@ -69,14 +105,22 @@ 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" })!, - authTimeout: bnbEnvVar("AUTH_TIMEOUT", { default: "1h" })!, + secret, + authTimeout: bnbEnvVar("AUTH_TIMEOUT", { default: "1h" })!, icons: { foregroundColor: bnbEnvVar("ICON_FOREGROUND_COLOR", { validationPattern: COLOR, @@ -86,24 +130,16 @@ export default function () { }), }, 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", { 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 }), }, 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/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/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/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/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/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 8a90f37..d28d579 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"; @@ -15,14 +15,15 @@ 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_service"; +import { MusicService, AuthFailure, AuthSuccess } from "./music_library"; import bindSmapiSoapServiceToExpress from "./smapi"; import { APITokens, InMemoryAPITokens } from "./api_tokens"; import logger from "./logger"; @@ -30,7 +31,8 @@ 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 { DEFAULT_LOGIN_THEME } from './config' import _ from "underscore"; import morgan from "morgan"; import { parse } from "./burn"; @@ -39,6 +41,7 @@ import { JWTSmapiLoginTokens, SmapiAuthTokens, } from "./smapi_auth"; +import { isValidMimeType } from "./utils"; export const BONOB_ACCESS_TOKEN_HEADER = "bat"; @@ -46,6 +49,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+)-$/); @@ -92,11 +103,15 @@ export type ServerOpts = { version: string; smapiAuthTokens: SmapiAuthTokens; externalImageResolver: ImageFetcher; + loginTheme: string; + enableS1: boolean; }; +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, @@ -105,9 +120,11 @@ const DEFAULT_SERVER_OPTS: ServerOpts = { smapiAuthTokens: new JWTSmapiLoginTokens( SystemClock, `bonob-${uuid()}`, - "1m" + DEFAULT_TIMEOUT ), externalImageResolver: axiosImageFetcher, + loginTheme: DEFAULT_LOGIN_THEME, + enableS1: false, }; function server( @@ -123,6 +140,7 @@ function server( const smapiAuthTokens = serverOpts.smapiAuthTokens; const apiTokens = serverOpts.apiTokens(); const clock = serverOpts.clock; + const loginTheme = serverOpts.loginTheme || "classic" const startUpTime = dayjs(); @@ -133,6 +151,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); @@ -149,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) => { @@ -183,56 +184,27 @@ 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); - 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( @@ -243,18 +215,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"), @@ -369,26 +344,68 @@ function server( `); }); + 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 }), + 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 - )}, 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), - 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) @@ -499,6 +516,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(); @@ -534,7 +552,7 @@ function server( text: text }) ) - .apply(festivals(clock)) + .apply(apply_festivals ? festivals(clock) : no_festivals) .toString() ) .then(spec.responseFormatter) @@ -566,6 +584,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)) { @@ -582,16 +602,19 @@ 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 ${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(); diff --git a/src/smapi.ts b/src/smapi.ts index a4b0723..3829aae 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -10,19 +10,18 @@ import logger from "./logger"; import { LinkCodes } from "./link_codes"; import { - Album, AlbumQuery, AlbumSummary, ArtistSummary, Genre, Year, MusicService, - Playlist, RadioStation, Rating, slice2, Track, -} from "./music_service"; + PlaylistSummary +} from "./music_library"; import { APITokens } from "./api_tokens"; import { Clock } from "./clock"; import { URLBuilder } from "./url_builder"; @@ -37,10 +36,9 @@ 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"; -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"; @@ -56,6 +54,7 @@ export const SONOS_RECOMMENDED_IMAGE_SIZES = [ "600", "640", "750", + "1000", "1242", "1500", ]; @@ -65,12 +64,14 @@ const WSDL_FILE = path.resolve( "Sonoswsdl-1.19.6-20231024.wsdl" ); -export type Credentials = { - loginToken: { + +export type LoginToken = { token: string; - key: string; householdId: string; - }; +} + +export type Credentials = { + loginToken: LoginToken; deviceId: string; deviceProvider: string; }; @@ -87,7 +88,7 @@ export type GetAppLinkResult = { export type GetDeviceAuthTokenResult = { getDeviceAuthTokenResult: { authToken: string; - privateKey: string; + // todo: appears this thing can be optional userInfo: { nickname: string; userIdHashCode: string; @@ -97,6 +98,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) }; @@ -193,6 +195,8 @@ class SonosSoap { }; } + reportAccountAction = (_: { type: string }) => ({}) + getDeviceAuthToken({ linkCode, }: { @@ -206,7 +210,6 @@ class SonosSoap { return { getDeviceAuthTokenResult: { authToken: smapiAuthToken.token, - privateKey: smapiAuthToken.key, userInfo: { nickname: association.nickname, userIdHashCode: crypto @@ -244,11 +247,21 @@ 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}`, title: genre.name, - albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(), + albumArtURI: albumArtURI(iconArtURI(bonobUrl, iconForGenre(genre.name)).href()), }); const yyyy = (bonobUrl: URLBuilder, year: Year) => ({ @@ -256,20 +269,32 @@ 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()), }); -const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({ +export const shouldScrobble = (track: Track, playbackTime: number) => ( + (track.duration < 30 && playbackTime >= 10) || + (track.duration >= 30 && playbackTime >= 30)) + +// 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(), + albumArtURI: albumArtURI(coverArtURI(bonobUrl, playlist).href()), canPlay: true, attributes: { - readOnly: false, - userContent: false, - renameable: false, - }, + userContent: true, + }, }); export const coverArtURI = ( @@ -295,13 +320,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, @@ -327,7 +364,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, @@ -345,25 +382,39 @@ 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()), }); -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) }); } -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; }; @@ -371,6 +422,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, @@ -391,33 +453,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, - key: credentials.loginToken.key, + 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)) { @@ -432,17 +491,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: "nonsense", + }, }, }, - }, - })), + })), TE.getOrElse(() => T.of(SMAPI_FAULT_LOGIN_UNAUTHORIZED)) )(); } else { @@ -457,6 +516,8 @@ function bindSmapiSoapServiceToExpress( Sonos: { SonosSoap: { getAppLink: () => sonosSoap.getAppLink(), + reportAccountAction: ({ type } : { type: string }) => + sonosSoap.reportAccountAction({ type }), getDeviceAuthToken: ({ linkCode }: { linkCode: string }) => sonosSoap.getDeviceAuthToken({ linkCode }), getLastUpdate: () => ({ @@ -467,9 +528,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) @@ -487,7 +553,7 @@ function bindSmapiSoapServiceToExpress( TE.map((it) => ({ refreshAuthTokenResult: { authToken: it.token, - privateKey: it.key, + privateKey: "nonsense", }, })), TE.getOrElse((_) => { @@ -498,11 +564,12 @@ function bindSmapiSoapServiceToExpress( getMediaURI: async ( { id }: { id: string }, _, - soapyHeaders: SoapyHeaders - ) => - login(soapyHeaders?.credentials) - .then(splitId(id)) - .then(({ musicLibrary, credentials, type, typeId }) => { + soapyHeaders: SoapyHeaders, + { headers }: Pick + ) => + login(findLoginToken(soapyHeaders, headers)) + .then(withSplitId(id)) + .then(({ musicLibrary, apiKey, type, typeId }) => { switch (type) { case "internetRadioStation": return musicLibrary.radioStation(typeId).then((it) => ({ @@ -518,29 +585,27 @@ function bindSmapiSoapServiceToExpress( httpHeaders: [ { httpHeader: { - header: "bnbt", - value: credentials.loginToken.token, - }, - }, - { - httpHeader: { - header: "bnbk", - value: credentials.loginToken.key, + header: "authorization", + value: apiKey, }, }, ], }; default: - 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 }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) - .then(splitId(id)) + login(findLoginToken(soapyHeaders, headers)) + .then(withSplitId(id)) .then(async ({ musicLibrary, apiKey, type, typeId }) => { switch (type) { case "internetRadioStation": @@ -552,16 +617,20 @@ function bindSmapiSoapServiceToExpress( getMediaMetadataResult: track(urlWithToken(apiKey), it), })); default: - throw `Unsupported type:${type}`; + logger.info(`Sonos asked for an unsupported getMediaMetadata: ${type}:${typeId}`); + return { + getMediaMetadataResult: {} + } } }), search: async ( { id, term }: { id: string; term: string }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) - .then(splitId(id)) + login(findLoginToken(soapyHeaders, headers)) + .then(withSplitId(id)) .then(async ({ musicLibrary, apiKey }) => { switch (id) { case "albums": @@ -592,59 +661,49 @@ 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 + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) - .then(splitId(id)) + login(findLoginToken(soapyHeaders, headers)) + .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, @@ -653,17 +712,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: - throw `Unsupported getExtendedMetadata id=${id}`; + logger.info(`Sonos requested extended meta data for currently unsupported type=${type}, typeId=${typeId}`) + return { + getExtendedMetadataResult: {} + }; } }), getMetadata: async ( @@ -676,9 +739,9 @@ function bindSmapiSoapServiceToExpress( _, soapyHeaders: SoapyHeaders, { headers }: Pick - ) => - login(soapyHeaders?.credentials) - .then(splitId(id)) + ) => { + return login(findLoginToken(soapyHeaders, headers)) + .then(withSplitId(id)) .then(({ musicLibrary, apiKey, type, typeId }) => { const paging = { _index: index, _count: count }; const acceptLanguage = headers["accept-language"]; @@ -705,87 +768,85 @@ 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(), - itemType: "playlist", + albumArtURI: albumArtURI(iconArtURI(bonobUrl, "playlists").href()), + itemType: "collection", attributes: { - readOnly: false, userContent: true, - renameable: false, }, }, { 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", }, ], @@ -901,9 +962,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, }) @@ -911,26 +970,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, }); @@ -966,10 +1009,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({ @@ -982,7 +1022,8 @@ function bindSmapiSoapServiceToExpress( }); case "album": return musicLibrary - .tracks(typeId!) + .album(typeId!) + .then(it => it.tracks) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ @@ -994,15 +1035,23 @@ 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 ( { 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) @@ -1026,18 +1075,20 @@ 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) - .then(splitId(id)) + login(findLoginToken(soapyHeaders, headers)) + .then(withSplitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId) ) @@ -1045,10 +1096,11 @@ function bindSmapiSoapServiceToExpress( removeFromContainer: async ( { id, indices }: { id: string; indices: string }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) - .then(splitId(id)) + login(findLoginToken(soapyHeaders, headers)) + .then(withSplitId(id)) .then((it) => ({ ...it, indices: indices.split(",").map((it) => +it), @@ -1065,13 +1117,15 @@ function bindSmapiSoapServiceToExpress( } }) .then((_) => ({ removeFromContainerResult: { updateId: "" } })), + rateItem: async ( { id, rating }: { id: string; rating: number }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) - .then(splitId(id)) + login(findLoginToken(soapyHeaders, headers)) + .then(withSplitId(id)) .then(({ musicLibrary, typeId }) => musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating))) ) @@ -1080,18 +1134,16 @@ function bindSmapiSoapServiceToExpress( setPlayedSeconds: async ( { id, seconds }: { id: string; seconds: string }, _, - soapyHeaders: SoapyHeaders + soapyHeaders: SoapyHeaders, + { headers }: Pick ) => - login(soapyHeaders?.credentials) - .then(splitId(id)) + login(findLoginToken(soapyHeaders, headers)) + .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/src/smapi_auth.ts b/src/smapi_auth.ts index 585bb65..6479692 100644 --- a/src/smapi_auth.ts +++ b/src/smapi_auth.ts @@ -1,8 +1,7 @@ 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"; +import { StringValue } from 'ms' export type SmapiFault = { Fault: { faultcode: string; faultstring: string } }; export type SmapiRefreshTokenResultFault = SmapiFault & { @@ -24,8 +23,7 @@ export function isSmapiRefreshTokenResultFault( } export type SmapiToken = { - token: string; - key: string; + token: string }; export interface ToSmapiFault { @@ -105,50 +103,35 @@ 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 = 2; +export const SMAPI_TOKEN_VERSION = 5; 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 expiresIn: StringValue; constructor( clock: Clock, secret: string, - expiresIn: string, - keyGenerator: () => string = uuid, + expiresIn: StringValue, version: number = SMAPI_TOKEN_VERSION ) { this.clock = clock; - this.secret = secret; this.expiresIn = expiresIn; - this.version = version; - this.keyGenerator = keyGenerator; + this.secret = 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, - { expiresIn: this.expiresIn } - ), - key, - }; - }; + this.secret, + { + algorithm: "HS256", + expiresIn: this.expiresIn, + issuer: "bonob" + } + ) + }); verify = (smapiToken: SmapiToken): E.Either => { try { @@ -156,7 +139,7 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens { ( jwt.verify( smapiToken.token, - this.secret + this.version + smapiToken.key + this.secret ) as any ).serviceToken ); @@ -165,7 +148,7 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens { const serviceToken = ( jwt.verify( smapiToken.token, - this.secret + this.version + smapiToken.key, + this.secret, { ignoreExpiration: true } ) as any ).serviceToken; 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/src/subsonic.ts b/src/subsonic.ts index aaaae6d..f1b42af 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -2,56 +2,46 @@ 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, - 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"; +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 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 = { - 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) => - 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, @@ -109,7 +99,7 @@ type genre = { value: string; }; -type GetGenresResponse = SubsonicResponse & { +export type GetGenresResponse = SubsonicResponse & { genres: { genre: genre[]; }; @@ -169,57 +159,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 +236,7 @@ export type PingResponse = { serverVersion: string; }; -type Search3Response = SubsonicResponse & { +export type Search3Response = SubsonicResponse & { searchResult3: { artist: artist[]; album: album[]; @@ -241,18 +244,27 @@ type Search3Response = SubsonicResponse & { }; }; +export type OpenSubsonicExtension = { + name: string; + versions: number[]; +}; + +type GetOpenSubsonicExtensionsResponse = SubsonicResponse & { + openSubsonicExtensions: OpenSubsonicExtension[]; +}; + export function isError( subsonicResponse: SubsonicResponse ): subsonicResponse is SubsonicError { 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 +298,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 +333,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 +352,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 +372,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 +399,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(); @@ -408,24 +429,240 @@ 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 = - (cacheDir: string, delegate: ImageFetcher) => +export const cachingImageFetcher = ( + cacheDir: string, + delegate: ImageFetcher, + 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) => { if (image) { - return sharp(image.data) + return makeSharp(image.data) .png() .toBuffer() .then((png) => { - return fse - .writeFile(filename, png) + return writeFile(filename, png) .then(() => ({ contentType: "image/png", data: png })); }); } else { @@ -463,447 +700,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 +721,7 @@ export class Subsonic { this.externalImageFetcher = externalImageFetcher; } - get = async ( + private get = async ( { username, password }: Credentials, path: string, q: {} = {}, @@ -939,16 +741,42 @@ 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; }); - getJSON = async ( + 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, q: {} = {} @@ -961,6 +789,39 @@ 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( + () => 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 +839,7 @@ export class Subsonic { })) ); + // todo: should be getArtistInfo2? getArtistInfo = ( credentials: Credentials, id: string @@ -1001,30 +863,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 +917,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 +930,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 +970,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 +982,240 @@ 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, + })); + + 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 => ({ + 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, + })) + ); + + 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 new file mode 100644 index 0000000..a5f2aea --- /dev/null +++ b/src/subsonic_music_library.ts @@ -0,0 +1,310 @@ +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, + SONOS_CLIENT_INFO, +} from "./subsonic"; +import _ from "underscore"; + +import logger from "./logger"; +import { assertSystem, BUrn } from "./burn"; + +export class SubsonicMusicService implements MusicService { + subsonic: Subsonic; + customPlayers: CustomPlayers; + useTranscode: boolean; + + constructor( + subsonic: Subsonic, + customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS, + useTranscode: boolean = true + ) { + this.subsonic = subsonic; + this.customPlayers = customPlayers; + this.useTranscode = useTranscode; + } + + 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, + this.useTranscode + )); + }; +} + +export class SubsonicMusicLibrary implements MusicLibrary { + subsonic: Subsonic; + credentials: Credentials; + customPlayers: CustomPlayers; + useTranscode: boolean; + + constructor( + subsonic: Subsonic, + credentials: Credentials, + 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 + // 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; + }) => { + 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, + 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 + ); + } + } + } + + 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")) + .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/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/api_tokens.test.ts b/tests/api_tokens.test.ts index 4bad1e5..651975f 100644 --- a/tests/api_tokens.test.ts +++ b/tests/api_tokens.test.ts @@ -1,5 +1,6 @@ -import { v4 as uuid } from "uuid"; +import { randomUUID as uuid } from "crypto"; +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/builders.ts b/tests/builders.ts index 13d4701..869391b 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -1,21 +1,23 @@ +import { AxiosError } from "axios"; 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 { Credentials } from "../src/smapi"; +import { SoapyHeaders } from "../src/smapi"; import { Service, Device } from "../src/sonos"; 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"; @@ -23,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()}`, @@ -89,17 +106,18 @@ export function getAppLinkMessage() { sonosAppName: "", callbackPath: "", }; -} +}; -export function someCredentials({ token, key } : { token: string, key: string }): Credentials { +export function someSoapHeadersForToken(token: string): SoapyHeaders { return { - loginToken: { - token, - key, - householdId: "hh1", - }, - deviceId: "d1", - deviceProvider: "dp1", + credentials: { + loginToken: { + token, + householdId: `householdId-${uuid()}`, + }, + deviceId: `deviceId-${uuid()}`, + deviceProvider: `deviceProvider-${uuid()}`, + } }; } @@ -116,13 +134,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 }), @@ -166,9 +197,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 { @@ -181,28 +212,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 { @@ -216,20 +272,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/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); }); diff --git a/tests/config.test.ts b/tests/config.test.ts index 6324832..b4e160a 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$/}` ); }); @@ -110,44 +110,38 @@ 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_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 none of BNB_URL, BONOB_URL, BONOB_WEB_ADDRESS are specified", () => { - describe("when BONOB_PORT is not specified", () => { - it(`should default to http://${hostname()}:4534`, () => { - expect(config().bonobUrl.href()).toEqual( - `http://${hostname()}:4534/` - ); - }); + 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 BNB_PORT is specified as 3322", () => { - it(`should default to http://${hostname()}:3322`, () => { - process.env["BNB_PORT"] = "3322"; - expect(config().bonobUrl.href()).toEqual( - `http://${hostname()}:3322/` - ); - }); + describe("when BNB_URL is not specified", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; }); - describe("when BONOB_PORT is specified as 3322", () => { + it(`should default to http://${hostname()}:4534`, () => { + expect(config().bonobUrl.href()).toEqual( + `http://${hostname()}:4534/` + ); + }); + + describe("when BNB_PORT is specified as 3322", () => { it(`should default to http://${hostname()}:3322`, () => { - process.env["BONOB_PORT"] = "3322"; + process.env["BNB_PORT"] = "3322"; expect(config().bonobUrl.href()).toEqual( `http://${hostname()}:3322/` ); @@ -157,10 +151,13 @@ describe("config", () => { }); describe("icons", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + 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`, () => { @@ -203,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`, () => { @@ -244,23 +240,45 @@ describe("config", () => { }); }); + describe("login theme", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + + 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"); + 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([ - "BNB_SECRET", - "BONOB_SECRET" - ])("%s", (k) => { - it(`should be overridable using ${k}`, () => { - process.env[k] = "new secret"; - expect(config().secret).toEqual("new 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); }); }); describe("authTimeout", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + it("should default to 1h", () => { expect(config().authTimeout).toEqual("1h"); }); @@ -272,6 +290,10 @@ describe("config", () => { }); describe("logRequests", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + describeBooleanConfigValue( "logRequests", "BNB_SERVER_LOG_REQUESTS", @@ -281,69 +303,67 @@ 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", () => { it("should default to bonob", () => { 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", () => { @@ -351,56 +371,45 @@ 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); + }); }); + + describeBooleanConfigValue( + "enableS1", + "BNB_SONOS_ENABLE_S1", + false, + (config) => config.sonos.enableS1 + ); }); describe("subsonic", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + 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); }); }); @@ -409,15 +418,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"); }); }); @@ -431,32 +434,33 @@ describe("config", () => { expect(config().subsonic.artistImageCache).toEqual("/some/path"); }); }); + + describeBooleanConfigValue( + "transcode", + "BNB_SUBSONIC_TRANSCODE", + true, + (config) => config.subsonic.transcode + ); + }); - describe.each([ - "BNB_SCROBBLE_TRACKS", - "BONOB_SCROBBLE_TRACKS" - ])("%s", (k) => { + describe("scrobbling and reporting", () => { + beforeEach(() => { + process.env["BNB_SECRET"] = "bonob"; + }); + describeBooleanConfigValue( "scrobbleTracks", - k, + "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 + ); + }); }); 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/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index bddf417..1b5ae2e 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -5,9 +5,8 @@ import { InMemoryMusicService } from "./in_memory_music_service"; import { MusicLibrary, artistToArtistSummary, - albumToAlbumSummary, -} from "../src/music_service"; -import { v4 as uuid } from "uuid"; +} from "../src/music_library"; +import { randomUUID as uuid } from "crypto"; import { anArtist, anAlbum, @@ -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 81% rename from tests/music_service.test.ts rename to tests/music_library.test.ts index f3f1d42..b05542b 100644 --- a/tests/music_service.test.ts +++ b/tests/music_library.test.ts @@ -1,7 +1,7 @@ -import { v4 as uuid } from "uuid"; +import { randomUUID as uuid } from "crypto"; 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/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/scenarios.test.ts b/tests/scenarios.test.ts index fd7170c..df6d236 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 { randomUUID as uuid } from "crypto"; import { GetAppLinkResult, @@ -14,11 +15,10 @@ import { BOB_MARLEY, getAppLinkMessage, MADONNA, - someCredentials, } 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"; @@ -33,10 +33,14 @@ class LoggedInSonosDriver { this.client = client; this.token = token; this.client.addSoapHeader({ - credentials: someCredentials({ - token: this.token.getDeviceAuthTokenResult.authToken, - key: this.token.getDeviceAuthTokenResult.privateKey - }), + credentials: { + loginToken: { + token: this.token.getDeviceAuthTokenResult.authToken, + householdId: `householdId-${uuid()}`, + }, + deviceId: `deviceId-${uuid()}`, + deviceProvider: `deviceProvider-${uuid()}` + } }); } @@ -91,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); @@ -274,6 +278,7 @@ describe("scenarios", () => { musicService, { linkCodes: () => linkCodes, + enableS1: true, } ); @@ -291,7 +296,8 @@ describe("scenarios", () => { bonobUrl, musicService, { - linkCodes: () => linkCodes + linkCodes: () => linkCodes, + enableS1: true, } ); @@ -309,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 0a7e892..0434f52 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -1,10 +1,10 @@ -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_service"; +import { AuthFailure, MusicService } from "../src/music_library"; import makeServer, { BONOB_ACCESS_TOKEN_HEADER, RangeBytesFromFilter, @@ -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"; @@ -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", () => { @@ -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}` ); } @@ -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", () => { @@ -609,118 +709,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", () => { @@ -735,7 +836,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, @@ -751,7 +853,6 @@ describe("server", () => { const serviceToken = `serviceToken-${uuid()}`; const trackId = `t-${uuid()}`; - const smapiAuthToken: SmapiToken = { token: `token-${uuid()}`, key: `key-${uuid()}` }; const streamContent = (content: string) => { const self = { @@ -781,9 +882,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 @@ -792,18 +894,13 @@ describe("server", () => { }) .path(), ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .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,8 +922,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}`}) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(trackStream.status); expect(res.headers["content-type"]).toEqual( @@ -855,8 +951,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(404); @@ -879,9 +974,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( @@ -889,18 +985,13 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .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 = { @@ -918,8 +1009,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(404); @@ -954,8 +1044,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(stream.status); expect(res.headers["content-type"]).toEqual( @@ -998,8 +1087,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(stream.status); expect(res.headers["content-type"]).toEqual( @@ -1040,8 +1128,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( @@ -1083,8 +1170,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key); + .set('authorization', apiTokens.mint(serviceToken)); expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( @@ -1131,8 +1217,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key) + .set('authorization', apiTokens.mint(serviceToken)) .set("Range", requestedRange); expect(res.status).toEqual(stream.status); @@ -1178,8 +1263,7 @@ describe("server", () => { .append({ pathname: `/stream/track/${trackId}` }) .path() ) - .set('bnbt', smapiAuthToken.token) - .set('bnbk', smapiAuthToken.key) + .set('authorization', apiTokens.mint(serviceToken)) .set("Range", "4000-5000"); expect(res.status).toEqual(stream.status); @@ -1205,7 +1289,6 @@ describe("server", () => { }); }); }); - }); }); @@ -1274,11 +1357,49 @@ 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", () => { + [ + ["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] + ); + }); + }); + }); + }); + + 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({}); + const coverArt = coverArtResponse({ + contentType: "not-valid" + }); musicService.login.mockResolvedValue(musicLibrary); @@ -1290,16 +1411,7 @@ describe("server", () => { ) .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 - ); + expect(res.status).toEqual(502); }); }); @@ -1449,9 +1561,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); }); }); @@ -1479,7 +1591,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(); @@ -1567,16 +1679,16 @@ 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); }); }); 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); @@ -1591,6 +1703,178 @@ describe("server", () => { }); }); }); + + describe("/report/timePlayed", () => { + const musicService = { + login: jest.fn(), + }; + const musicLibrary = { + track: jest.fn(), + scrobble: jest.fn(), + }; + const smapiAuthTokens = { + verify: jest.fn(), + }; + const server = makeServer( + jest.fn() as unknown as Sonos, + aService(), + bonobUrl, + musicService as unknown as MusicService, + { + smapiAuthTokens: smapiAuthTokens as unknown as SmapiAuthTokens + } + ); + const authToken = `token-${uuid()}` + const serviceToken = `serviceToken-${uuid()}`; + + 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" }); + }); + }); + + 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 }); + }); + + 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); + }); + }); + + }); + }); }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 3f64a5f..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"; @@ -24,7 +24,8 @@ import { sonosifyMimeType, ratingAsInt, ratingFromInt, - internetRadioStation + internetRadioStation, + findLoginToken } from "../src/smapi"; import { keys as i8nKeys } from "../src/i8n"; @@ -34,13 +35,15 @@ import { anArtist, anAlbum, aTrack, - someCredentials, POP, ROCK, TRIP_HOP, PUNK, aPlaylist, aRadioStation, + anArtistSummary, + anAlbumSummary, + someSoapHeadersForToken, } from "./builders"; import { InMemoryMusicService } from "./in_memory_music_service"; import supersoap from "./supersoap"; @@ -49,7 +52,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"; @@ -87,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"); @@ -620,7 +671,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 +685,7 @@ describe("wsdl api", () => { const serviceToken = `serviceToken-${uuid()}`; const apiToken = `apiToken-${uuid()}`; const smapiAuthToken: SmapiToken = { - token: `smapiAuthToken.token-${uuid()}`, - key: `smapiAuthToken.key-${uuid()}` + token: `smapiAuthToken.token-${uuid()}` }; const bonobUrlWithAccessToken = bonobUrl.append({ @@ -661,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", () => { @@ -705,6 +762,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 () => { @@ -727,7 +799,6 @@ describe("wsdl api", () => { expect(result[0]).toEqual({ getDeviceAuthTokenResult: { authToken: smapiAuthToken.token, - privateKey: smapiAuthToken.key, userInfo: { nickname: association.nickname, userIdHashCode: crypto @@ -814,16 +885,14 @@ describe("wsdl api", () => { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ - credentials: someCredentials(smapiAuthToken), - }); + randomlySetAuthenticationMethod(ws, smapiAuthToken.token) const result = await ws.refreshAuthTokenAsync({}); expect(result[0]).toEqual({ refreshAuthTokenResult: { authToken: newSmapiAuthToken.token, - privateKey: newSmapiAuthToken.key, + privateKey: "nonsense" }, }); @@ -840,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")) @@ -867,16 +934,14 @@ describe("wsdl api", () => { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ - credentials: someCredentials(smapiAuthToken), - }); + randomlySetAuthenticationMethod(ws, smapiAuthToken.token) const result = await ws.refreshAuthTokenAsync({}); expect(result[0]).toEqual({ refreshAuthTokenResult: { authToken: newSmapiAuthToken.token, - privateKey: newSmapiAuthToken.key + privateKey: "nonsense" }, }); @@ -994,6 +1059,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, + }) + ); + }); + }); }); }); @@ -1029,7 +1112,7 @@ describe("wsdl api", () => { endpoint: service.uri, httpClient: supersoap(server), }); - ws.addSoapHeader({ credentials: someCredentials({ token: 'tokenThatFails', key: `keyThatFails` }) }); + randomlySetAuthenticationMethod(ws, 'tokenThatFails'); await action(ws) .then(() => fail("shouldnt get here")) @@ -1076,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) => { @@ -1088,7 +1169,7 @@ describe("wsdl api", () => { detail: { refreshAuthTokenResult: { authToken: newToken.token, - privateKey: newToken.key, + privateKey: "nonsense", }, }, }); @@ -1160,10 +1241,8 @@ describe("wsdl api", () => { id: "playlists", title: "Playlists", albumArtURI: iconArtURI(bonobUrl, "playlists").href(), - itemType: "playlist", + itemType: "collection", attributes: { - readOnly: "false", - renameable: "false", userContent: "true", }, }, @@ -1260,10 +1339,8 @@ describe("wsdl api", () => { id: "playlists", title: "Afspeellijsten", albumArtURI: iconArtURI(bonobUrl, "playlists").href(), - itemType: "playlist", + itemType: "collection", attributes: { - readOnly: "false", - renameable: "false", userContent: "true", }, }, @@ -1318,14 +1395,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" }, @@ -1498,9 +1592,7 @@ describe("wsdl api", () => { ).href(), canPlay: true, attributes: { - readOnly: "false", - userContent: "false", - renameable: "false", + userContent: "true", }, })), index: 0, @@ -1530,9 +1622,7 @@ describe("wsdl api", () => { ).href(), canPlay: true, attributes: { - readOnly: "false", - userContent: "false", - renameable: "false", + userContent: "true", }, }) ), @@ -2356,10 +2446,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 }); @@ -2370,7 +2458,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", () => { @@ -2394,7 +2487,7 @@ describe("wsdl api", () => { total: tracks.length, }) ); - expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id); + expect(musicLibrary.album).toHaveBeenCalledWith(album.id); }); }); @@ -2421,7 +2514,7 @@ describe("wsdl api", () => { total: tracks.length, }) ); - expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id); + expect(musicLibrary.album).toHaveBeenCalledWith(album.id); }); }); }); @@ -2569,7 +2662,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", () => { @@ -2584,71 +2677,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(); @@ -2661,8 +2689,7 @@ describe("wsdl api", () => { { ...similar2, inLibrary: false }, { ...similar3, inLibrary: false }, { ...similar4, inLibrary: true }, - ], - albums: [], + ] }); beforeEach(() => { @@ -2670,28 +2697,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", + }], }, }); }); @@ -2709,16 +2731,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(), + } }, }); }); @@ -2741,16 +2764,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(), + } }, }); }); @@ -2889,6 +2913,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 + }); + }); + }); }); }); @@ -2922,20 +2958,12 @@ describe("wsdl api", () => { pathname: `/stream/track/${trackId}`, }) .href(), - httpHeaders: [ - { + httpHeaders: { httpHeader: [{ - header: "bnbt", - value: smapiAuthToken.token, + header: "authorization", + value: apiToken, }], }, - { - httpHeader: [{ - header: "bnbk", - value: smapiAuthToken.key, - }], - } - ], }); expect(musicService.login).toHaveBeenCalledWith(serviceToken); @@ -2961,6 +2989,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); + }); }); }); }); @@ -2973,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, @@ -3027,7 +3068,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, + }); + }); + }); }); }); diff --git a/tests/smapi_auth.test.ts b/tests/smapi_auth.test.ts index d4db82c..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 { @@ -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", () => { @@ -63,8 +45,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,8 +58,12 @@ describe("auth", () => { serviceToken, iat: clock.now().unix(), }, - secret + SMAPI_TOKEN_VERSION + key, - { expiresIn } + secret + "." + SMAPI_TOKEN_VERSION, + { + algorithm: "HS256", + expiresIn, + issuer: "bonob" + } ); expect(smapiToken.token).toEqual(expected); @@ -100,16 +85,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 +128,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")) ); diff --git a/tests/sonos.test.ts b/tests/sonos.test.ts index 16dbcf3..939fd70 100644 --- a/tests/sonos.test.ts +++ b/tests/sonos.test.ts @@ -7,9 +7,13 @@ 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 { 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 85c663f..4291037 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -1,79 +1,63 @@ -import { Md5 } from "ts-md5"; -import { v4 as uuid } from "uuid"; -import tmp from "tmp"; -import fse from "fs-extra"; +import { option as O, either as E } from "fp-ts"; +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"; -import { pipe } from "fp-ts/lib/function"; -import { option as O, taskEither as TE, task as T, either as E } from "fp-ts"; +const tmpDir = () => ({ name: mkdtempSync(path.join(os.tmpdir(), "bonob-")) }); +import { pipe } from "fp-ts/lib/function"; + +import sharp from "sharp"; +jest.mock("sharp"); + + +import axios from "axios"; +jest.mock("axios", () => ({ + ...jest.requireActual("axios"), + get: jest.fn(), + post: jest.fn(), +})); + +import * as random from "../src/random"; +jest.mock("../src/random"); + +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, + OpenSubsonicExtension, + SONOS_CLIENT_INFO, + TranscodeDecision, } 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, a404 } 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", () => { 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")); }); }); @@ -97,26 +81,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", () => { @@ -176,8 +167,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"); @@ -188,24 +179,25 @@ 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); 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); @@ -218,8 +210,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); @@ -228,76 +220,18 @@ describe("cachingImageFetcher", () => { expect(result).toBeUndefined(); expect(delegate).toHaveBeenCalledWith(url); - expect(fse.existsSync(cacheFile)).toEqual(false); + expect(existsSync(cacheFile)).toEqual(false); }); }); }); -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 +260,41 @@ 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 anOpenSubsonicExtension = (fields: Partial = {}): OpenSubsonicExtension => ({ + name: `extension-${uuid()}`, + versions: [1], + ...fields, }); -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 - } -}) + ...pingResponse, + }, +}); -const PING_OK = pingJson({ status: "ok" }); -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 +304,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 +315,7 @@ describe("artistURN", () => { expect( artistImageURN({ artistId: "-1", - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}`, }) ).toBeUndefined(); }); @@ -621,7 +326,7 @@ describe("artistURN", () => { expect( artistImageURN({ artistId: undefined, - artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}` + artistImageURL: `http://example.com/${DODGY_IMAGE_NAME}`, }) ).toBeUndefined(); }); @@ -631,19 +336,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 +372,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 +396,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 +413,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 +441,276 @@ 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)); - }); - }); +const ok = (data: string | object) => ({ + status: 200, + data, +}); - 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)); +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", +}); - mockPOST.mockImplementationOnce(() => Promise.resolve(ok({ token: 'theBearerToken' }))) - - const bearerToken = await pipe(client.bearerToken(credentials))(); - expect(bearerToken).toStrictEqual(E.right('theBearerToken')); +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), +}); - expect(axios.post).toHaveBeenCalledWith(url.append({ pathname: '/auth/login' }).href(), credentials) - }); - }); - }); +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" + })), + } }); + +const getOpenSubsonicExtensionsJson = (extensions: OpenSubsonicExtension[]) => + subsonicOK({ openSubsonicExtensions: extensions }); + +const aTranscodeDecision = (fields: Partial = {}): TranscodeDecision => ({ + canDirectPlay: false, + canTranscode: false, + ...fields, }); -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 getTranscodeDecisionJson = (decision: TranscodeDecision) => + subsonicOK({ transcodeDecision: decision }); +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 +729,1157 @@ describe("SubsonicMusicLibrary", () => { "User-Agent": "bonob", }; + beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); - describe("getting genres", () => { - describe("when there are none", () => { + (random.generateRandomString as jest.Mock) = 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") }); - - 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 }), - ], - }); + 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]; - beforeEach(() => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistJson(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoJson(artist))) - ); - }); + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ); + }); - it("should return the similar artists", async () => { - const result: Artist = await subsonic.artist(artist.id!); + it("should return all the artists", async () => { + const artists = await subsonic.getArtists(credentials); - expect(result).toEqual({ - id: `${artist.id}`, - name: artist.name, - image: { system:"subsonic", resource:`art:${artist.id}` }, - albums: artist.albums, - similarArtists: artist.similarArtists, - }); + const expectedResults = [artist1, artist2, artist3, artist4].map( + (it) => ({ + id: it.id, + image: it.image, + name: it.name, + albumCount: it.albums.length + }) + ); - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, - }); + expect(artists).toEqual(expectedResults); - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getArtists" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), 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, + 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 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))) + 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 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, + + describe("and has only 1 album", () => { + const album = anAlbumSummary({ genre: POP }); + + const artist: Artist = anArtist({ + albums: [album] }); - - 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, + 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, + } + ); }); }); - }); - - 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}))) + + 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, + } ); + }); }); - 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: [], + describe("and has an artistImageUrl", () => { + const artist: Artist = anArtist({ + albums: [] }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, + + const artistImageUrl = `http://localhost:1234/somewhere.jpg`; + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve( + ok(getArtistJson(artist, { artistImageUrl })) + ) + ) }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, + + it("should return the artist image url", async () => { + const result = await subsonic.getArtist(credentials, artist.id!); + + expect(result).toEqual({ id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, + 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, + } + ); }); - }); + }); }); - 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 }))) - ); - }); + // todo: what happens when the artist doesnt exist? + }); - it("should use the external url", async () => { - const result: Artist = await subsonic.artist(artist.id!); + describe("getArtistInfo", () => { + // todo: what happens when the artist doesnt exist? - 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: [], + 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 }), + ], }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - }), - headers, + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoJson(artist))) + ) }); - - expect(axios.get).toHaveBeenCalledWith(url.append({ pathname: '/rest/getArtistInfo2' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id: artist.id, - count: 50, - includeNotPresent: true, - }), - headers, + + 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 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' }))) + + describe("and has one similar artist", () => { + const artist = anArtist({ + similarArtists: [ + aSimilarArtist({ + id: "similar1.id", + name: "similar1", + inLibrary: true, + }), + ], + }); + + 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, + } ); + }); }); - - 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, + + describe("and has no similar artists", () => { + const artist = anArtist({ 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, + + 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 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: [], - }); - - 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 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))) - ); - }); - - 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 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)]); + + describe("and has some images", () => { + const artist: Artist = anArtist({ + albums: [], + similarArtists: [], + }); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 20, - albumCount: 0, - songCount: 0, - query: "foo", - }), - headers, + const smallImageUrl = "http://small"; + const mediumImageUrl = "http://medium"; + const largeImageUrl = "http://large" + + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => + Promise.resolve( + ok( + getArtistInfoJson(artist, { + smallImageUrl, + mediumImageUrl, + largeImageUrl, + }) + ) + ) + ); + }); + + 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("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("getting genres", () => { + describe("when there are none", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson([]))) + ); }); - }); - - describe("when there are no search results", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ artists: [] }))) - ); - const result = await subsonic.searchArtists("foo"); + it("should return empty array", async () => { + const result = await subsonic.getGenres(credentials); expect(result).toEqual([]); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 20, - albumCount: 0, - songCount: 0, - query: "foo", - }), - headers, - }); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getGenres" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + 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] }); + 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(getSearchResult3Json({ albums: [{ artist, album }] })) - ) - ); + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); + }); - const result = await subsonic.searchAlbums("foo"); + it("should return them alphabetically sorted", async () => { + const result = await subsonic.getGenres(credentials); - expect(result).toEqual([albumToAlbumSummary(album)]); + expect(result).toEqual([{ id: b64Encode("genre1"), name: "genre1" }]); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/search3' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - artistCount: 0, - albumCount: 20, - songCount: 0, - query: "foo", - }), - headers, - }); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getGenres" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + 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] }); + 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 }, + ]; - 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 }, - ], - }) - ) - ) - ); + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getGenresJson(genres))) + ); + }); - const result = await subsonic.searchAlbums("moo"); + it("should return them alphabetically sorted", async () => { + const result = await subsonic.getGenres(credentials); expect(result).toEqual([ - albumToAlbumSummary(album1), - albumToAlbumSummary(album2), + { 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/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, - }); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getGenres" }).href(), + { + params: asURLSearchParams(authParamsPlusJson), + 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, + 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))) + ); }); - - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSearchResult3Json({ tracks: [track] }))) - ) - .mockImplementationOnce(() => Promise.resolve(ok(getSongJson(track)))) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, 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.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, []))) + 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))) ); - - 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: [] }))) + + 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.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(); + describe("when a custom player is configured for the mime type", () => { + const hipHop = asGenre("Hip-Hop"); + const tripHop = asGenre("Trip-Hop"); - expect(result).toEqual([playlist]); + const albumSummary = anAlbumSummary({ id: "album1", name: "Burnin", genre: hipHop }); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylists' }).href(), { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); + const artistSummary = anArtistSummary({ + id: "artist1", + name: "Bob Marley" }); - }); - - 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, - }); + const alac = aTrack({ + artist: artistSummary, + album: albumSummary, + encoding: { + player: "bonob", + mimeType: "audio/alac", + }, + genre: hipHop, + rating: { + love: true, + stars: 3, + }, }); - }); - - 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, - }); + const m4a = aTrack({ + artist: artistSummary, + album: albumSummary, + encoding: { + player: "bonob", + mimeType: "audio/m4a", + }, + genre: hipHop, + rating: { + love: false, + stars: 0, + }, }); - }); - }); - - 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"); + const mp3 = aTrack({ + artist: artistSummary, + album: albumSummary, + encoding: { + player: "bonob", + mimeType: "audio/mp3", + }, + genre: tripHop, + rating: { + love: true, + stars: 5, + }, }); - }); - - 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), - }); + 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( - getPlayListJson({ - id, - name, - entries: [track1, track2], - }) - ) - ) - ); + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getAlbumJson(album))) + ); + }); - const result = await subsonic.playlist(id); + it("should return the album with custom players applied", async () => { + const result = await subsonic.getAlbum(credentials, album.id); - expect(result).toEqual({ - id, - name, - entries: [ - { ...track1, number: 1 }, - { ...track2, number: 2 }, - ], - }); + 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 + } + }, + ] + }); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylist' }).href(), { + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getAlbum" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, - id, + id: album.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))) - ); + 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", + }); + }); + }); + }); - const result = await subsonic.playlist(playlist.id); + describe("stars and unstars", () => { + const id = uuid(); - expect(result).toEqual(playlist); + describe("staring a track", () => { + describe("when ok", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "ok" }))) + ); + }); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getPlaylist' }).href(), { + 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: playlist.id, + 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 }))) + describe("when not ok", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "not-ok" }))) ); + }); - const result = await subsonic.createPlaylist(name); - - expect(result).toEqual({ id, name }); + it("should return false", async () => { + const result = await subsonic.star(credentials, { id }); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/createPlaylist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - f: "json", - name, - }), - headers, + expect(result).toEqual(false); }); }); }); + }); - describe("deleting a playlist", () => { - it("should delete the playlist by id", async () => { - const id = "id-to-delete"; + describe("setting ratings", () => { + const id = uuid(); - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + describe("when the rating is valid", () => { + describe("when response is ok", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "ok" }))) + ); + }); - const result = await subsonic.deletePlaylist(id); + 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, + } + ); + }); + }); - expect(result).toEqual(true); + describe("when response is not ok", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "not-ok" }))) + ); + }); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/deletePlaylist' }).href(), { - params: asURLSearchParams({ - ...authParamsPlusJson, - id, - }), - headers, + it("should return false", async () => { + const result = await subsonic.setRating(credentials, id, 2); + + expect(result).toEqual(false); }); }); }); + }); - describe("editing playlists", () => { - describe("adding a track to a playlist", () => { - it("should add it", async () => { - const playlistId = uuid(); - const trackId = uuid(); + describe("scrobble", () => { + const id = uuid(); - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(EMPTY))); + describe("with submission", () => { + const submission = true; - const result = await subsonic.addToPlaylist(playlistId, trackId); + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "ok" }))) + ); + }); - expect(result).toEqual(true); + it("should scrobble and return true", async () => { + const result = await subsonic.scrobble(credentials, id, submission); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/updatePlaylist' }).href(), { + expect(result).toEqual(true); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/scrobble" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, - playlistId, - songIdToAdd: trackId, + id, + submission }), 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))); + describe("without submission", () => { + const submission = false; - const result = await subsonic.removeFromPlaylist(playlistId, indicies); + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "ok" }))) + ); + }); - expect(result).toEqual(true); + it("should scrobble and return true", async () => { + const result = await subsonic.scrobble(credentials, id, submission); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/updatePlaylist' }).href(), { + expect(result).toEqual(true); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/scrobble" }).href(), + { params: asURLSearchParams({ ...authParamsPlusJson, - playlistId, - songIndexToRemove: indicies, + id, + submission }), 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: albumToAlbumSummary(album1), - genre: pop, - }); - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([track1]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist1, album1, []))) - ); - - const result = await subsonic.similarSongs(id); + describe("when fails", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(subsonicResponse({ status: "not-ok" }))) + ); + }); - expect(result).toEqual([track1]); + it("should return false", async () => { + const result = await subsonic.scrobble(credentials, id, false); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSimilarSongs2' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, - }); + expect(result).toEqual(false); }); }); + }); - 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]))) - ) - .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); + describe("getOpenSubsonicExtensions", () => { + describe("when there are no extensions", () => { + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([]))) + ); + }); - expect(result).toEqual([track1, track2, track3]); + it("should return an empty array and call subsonic with correct params", async () => { + const result = await subsonic.getOpenSubsonicExtensions(credentials); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSimilarSongs2' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, - }); + expect(result).toEqual([]); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getOpenSubsonicExtensions.view" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers } + ); }); }); - describe("when there are no similar songs", () => { - it("should return []", async () => { - const id = "idWithNoTracks"; + describe("when there are extensions", () => { + const extension1 = anOpenSubsonicExtension({ name: "transcoding", versions: [1] }); + const extension2 = anOpenSubsonicExtension({ name: "formPost", versions: [1, 2] }); - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getSimilarSongsJson([]))) - ); - - const result = await subsonic.similarSongs(id); + beforeEach(() => { + mockGET.mockImplementationOnce(() => + Promise.resolve(ok(getOpenSubsonicExtensionsJson([extension1, extension2]))) + ); + }); - expect(result).toEqual([]); + it("should return the extensions and call subsonic with correct params", async () => { + const result = await subsonic.getOpenSubsonicExtensions(credentials); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getSimilarSongs2' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - id, - count: 50, - }), - headers, - }); + expect(result).toEqual([extension1, extension2]); + expect(axios.get).toHaveBeenCalledWith( + url.append({ pathname: "/rest/getOpenSubsonicExtensions.view" }).href(), + { params: asURLSearchParams(authParamsPlusJson), headers } + ); }); }); - describe("when the id doesnt exist", () => { - it("should fail", async () => { - const id = "idThatHasAnError"; + describe("when the server returns 404", () => { + beforeEach(() => { + mockGET.mockRejectedValue(a404()) + }); - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(error("70", "data not found"))) - ); + it("should return an empty array", async () => { + const result = await subsonic.getOpenSubsonicExtensions(credentials); - return expect( - subsonic.similarSongs(id) - ).rejects.toEqual("Subsonic error:data not found"); + expect(result).toEqual([]); }); }); }); - 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]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) - ); - - const result = await subsonic.topSongs(artistId); + describe("getTranscodeDecision", () => { + const mediaId = `media-${uuid()}`; - expect(result).toEqual([track1]); - - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getTopSongs' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, - }); + describe("when the server can transcode", () => { + const decision = aTranscodeDecision({ + canDirectPlay: false, + canTranscode: true, + transcodeParams: "some-transcode-params", + transcodeReason: ["AudioCodecNotSupported"], }); - }); - - 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]))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album2, []))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getAlbumJson(artist, album1, []))) - ); - const result = await subsonic.topSongs(artistId); + beforeEach(() => { + mockPOST.mockImplementationOnce(() => + Promise.resolve(ok(getTranscodeDecisionJson(decision))) + ); + }); - expect(result).toEqual([track1, track2, track3]); + it("should return the decision and call subsonic with correct params", async () => { + const result = await subsonic.getTranscodeDecision(credentials, mediaId, SONOS_CLIENT_INFO); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getTopSongs' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, - }); + 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 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 the server requires direct play", () => { + const decision = aTranscodeDecision({ canDirectPlay: true, canTranscode: false }); - const result = await subsonic.topSongs(artistId); + beforeEach(() => { + mockPOST.mockImplementationOnce(() => + Promise.resolve(ok(getTranscodeDecisionJson(decision))) + ); + }); - expect(result).toEqual([]); + it("should return the decision", async () => { + const result = await subsonic.getTranscodeDecision(credentials, mediaId, SONOS_CLIENT_INFO); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getTopSongs' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json", - artist: artistName, - count: 50, - }), - headers, - }); + expect(result).toEqual(decision); }); }); }); - describe("radioStations", () => { - beforeEach(() => { - customPlayers.encodingFor.mockReturnValue(O.none); - }); - - describe("when there some radio stations", () => { - const station1 = aRadioStation(); - const station2 = aRadioStation(); - const station3 = aRadioStation(); + 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(ok(getRadioStationsJson([ - station1, - station2, - station3, - ]))) - ); + mockGET.mockImplementationOnce(() => Promise.resolve(streamResponse)); }); - 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, - }); - }); - }); + it("should return the stream response and call subsonic with correct params", async () => { + const result = await subsonic.getTranscodeStream(credentials, mediaId, transcodeParams, undefined); - 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(), { + 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, - f: "json" + mediaId, + mediaType: "song", + transcodeParams, }), - headers, - }); - }); + headers: { "User-Agent": "bonob" }, + responseType: "stream", + } + ); }); }); - describe("when there are no radio stations", () => { - it("should return []", async () => { - mockGET - .mockImplementationOnce(() => - Promise.resolve(ok(getRadioStationsJson([]))) - ); + describe("with range", () => { + const range = "1000-2000"; - const result = await subsonic.radioStations(); + beforeEach(() => { + mockGET.mockImplementationOnce(() => Promise.resolve(streamResponse)); + }); - expect(result).toEqual([]); + it("should include the Range header", async () => { + await subsonic.getTranscodeStream(credentials, mediaId, transcodeParams, range); - expect(mockGET).toHaveBeenCalledWith(url.append({ pathname: '/rest/getInternetRadioStations' }).href(), { - params: asURLSearchParams({ - ...authParams, - f: "json" - }), - headers, - }); + 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 new file mode 100644 index 0000000..9ade775 --- /dev/null +++ b/tests/subsonic_music_library.test.ts @@ -0,0 +1,3764 @@ +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", () => ({ + ...jest.requireActual("axios"), + get: jest.fn(), + post: jest.fn(), +})); + +import * as random from "../src/random"; +jest.mock("../src/random"); + +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 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, + 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(); + + (random.generateRandomString as jest.Mock) = 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(getOpenSubsonicExtensionsJson([]))) + ) + .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(getOpenSubsonicExtensionsJson([]))) + ) + .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(getOpenSubsonicExtensionsJson([]))) + ) + .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(getOpenSubsonicExtensionsJson([]))) + ) + .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(getOpenSubsonicExtensionsJson([]))) + ) + .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(`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(getOpenSubsonicExtensionsJson([]))) + ) + .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(getOpenSubsonicExtensionsJson([]))) + ) + .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(getOpenSubsonicExtensionsJson([]))) + ) + .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("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("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", () => { + 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, + } + ); + }); + }); + }); +}); 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" + ); + }); + }); }); 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]) + }); +}); 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 0000000..07c10ba Binary files /dev/null and b/web/public/navidrome-ish/android-icon-192x192-D_ka5daf.png differ 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/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 @@
<%= 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 +

<%= it.serviceName %>

+ 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 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