Compare commits
2 Commits
c122b9ac90
...
feature/ye
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d86b536aa | ||
|
|
e6a291be40 |
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-bullseye
|
FROM node:20-bullseye
|
||||||
|
|
||||||
LABEL maintainer=simojenki
|
LABEL maintainer=simojenki
|
||||||
|
|
||||||
|
|||||||
8
.github/workflows/ci.yml
vendored
@@ -47,19 +47,21 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Docker meta
|
name: Docker meta
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@v4
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
simojenki/bonob
|
simojenki/bonob
|
||||||
ghcr.io/simojenki/bonob
|
ghcr.io/simojenki/bonob
|
||||||
-
|
-
|
||||||
name: Login to DockerHub
|
name: Login to DockerHub
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
-
|
-
|
||||||
name: Log in to GitHub Container registry
|
name: Log in to GitHub Container registry
|
||||||
|
if: github.event_name != 'pull_request'
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
with:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
@@ -67,10 +69,10 @@ jobs:
|
|||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
-
|
-
|
||||||
name: Push image
|
name: Push image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v4
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||||
push: true
|
push: ${{ github.event_name != 'pull_request' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|||||||
5
.gitignore
vendored
@@ -10,7 +10,4 @@ node_modules
|
|||||||
!.yarn/plugins
|
!.yarn/plugins
|
||||||
!.yarn/sdks
|
!.yarn/sdks
|
||||||
!.yarn/versions
|
!.yarn/versions
|
||||||
.pnp.*
|
.pnp.*
|
||||||
log.txt
|
|
||||||
navidrome.txt
|
|
||||||
bonob.txt
|
|
||||||
187
CLAUDE.md
@@ -1,187 +0,0 @@
|
|||||||
# CLAUDE.md
|
|
||||||
|
|
||||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
|
||||||
|
|
||||||
## Project Overview
|
|
||||||
|
|
||||||
bonob is a Sonos SMAPI (Sonos Music API) implementation that bridges Subsonic API clones (like Navidrome and Gonic) with Sonos devices. It acts as a middleware service that translates between the Subsonic API and Sonos's proprietary music service protocol, allowing users to stream their personal music libraries to Sonos speakers.
|
|
||||||
|
|
||||||
## Development Commands
|
|
||||||
|
|
||||||
### Building and Running
|
|
||||||
```bash
|
|
||||||
# Build TypeScript to JavaScript
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# Development mode with auto-reload (requires environment variables)
|
|
||||||
npm run dev
|
|
||||||
# OR with auto-registration
|
|
||||||
npm run devr
|
|
||||||
|
|
||||||
# Register bonob service with Sonos devices
|
|
||||||
npm run register-dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
```bash
|
|
||||||
# Run all tests
|
|
||||||
npm test
|
|
||||||
|
|
||||||
# Run tests in watch mode
|
|
||||||
npm run testw
|
|
||||||
|
|
||||||
# Set custom test timeout (default: 5000ms)
|
|
||||||
JEST_TIMEOUT=10000 npm test
|
|
||||||
```
|
|
||||||
|
|
||||||
### Environment Variables for Development
|
|
||||||
When running locally, you need to set several environment variables:
|
|
||||||
- `BNB_DEV_HOST_IP`: Your machine's IP address (so Sonos can reach bonob)
|
|
||||||
- `BNB_DEV_SONOS_DEVICE_IP`: IP address of a Sonos device for discovery
|
|
||||||
- `BNB_DEV_SUBSONIC_URL`: URL of your Subsonic API server (e.g., Navidrome)
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
|
|
||||||
**`src/app.ts`** - Application entry point
|
|
||||||
- Reads configuration from environment variables
|
|
||||||
- Initializes all services (Subsonic, Sonos, authentication)
|
|
||||||
- Wires together the Express server with appropriate dependencies
|
|
||||||
- Handles SIGTERM for graceful shutdown
|
|
||||||
|
|
||||||
**`src/server.ts`** - Express HTTP server
|
|
||||||
- Serves web UI for service registration and login
|
|
||||||
- Handles music streaming (`/stream/track/:id`)
|
|
||||||
- Generates icons and cover art (`/icon/...`, `/art/...`)
|
|
||||||
- Serves Sonos-specific XML files (strings, presentation map)
|
|
||||||
- Binds SOAP service for Sonos SMAPI communication
|
|
||||||
|
|
||||||
**`src/smapi.ts`** - Sonos SMAPI SOAP implementation (1200+ lines)
|
|
||||||
- Implements the Sonos Music API Protocol via SOAP/XML
|
|
||||||
- Core operations: `getMetadata`, `getMediaURI`, `search`, `getExtendedMetadata`
|
|
||||||
- Handles authentication flow with link codes and device auth tokens
|
|
||||||
- Manages token refresh and session management
|
|
||||||
- Maps music library concepts to Sonos browse hierarchy
|
|
||||||
|
|
||||||
**`src/subsonic.ts`** - Subsonic API client
|
|
||||||
- Implements `MusicService` and `MusicLibrary` interfaces
|
|
||||||
- Handles authentication with Subsonic servers using token-based auth
|
|
||||||
- Translates between Subsonic data models and bonob's domain types
|
|
||||||
- Supports custom player configurations for transcoding
|
|
||||||
- Special handling for Navidrome (bearer token authentication)
|
|
||||||
- Implements artist image fetching with optional caching
|
|
||||||
|
|
||||||
**`src/music_service.ts`** - Core domain types and interfaces
|
|
||||||
- Defines `MusicService` interface (auth and login)
|
|
||||||
- Defines `MusicLibrary` interface (browsing, search, streaming, rating, scrobbling)
|
|
||||||
- Domain types: `Artist`, `Album`, `Track`, `Playlist`, `Genre`, `Rating`, etc.
|
|
||||||
- Uses `fp-ts` for functional programming patterns (`TaskEither`, `Option`, `Either`)
|
|
||||||
|
|
||||||
**`src/sonos.ts`** - Sonos device discovery and registration
|
|
||||||
- Discovers Sonos devices on the network using SSDP/UPnP
|
|
||||||
- Registers/unregisters bonob as a music service with Sonos systems
|
|
||||||
- Supports both auto-discovery and seed-host based discovery
|
|
||||||
- Uses `@svrooij/sonos` library for device communication
|
|
||||||
|
|
||||||
**`src/smapi_auth.ts`** - Authentication token management
|
|
||||||
- Implements JWT-based SMAPI tokens (token + key pairs)
|
|
||||||
- Handles token verification and expiry
|
|
||||||
- Token refresh flow using `fp-ts` `TaskEither`
|
|
||||||
|
|
||||||
**`src/config.ts`** - Configuration management
|
|
||||||
- Reads and validates environment variables (all prefixed with `BNB_`)
|
|
||||||
- Legacy environment variable support (BONOB_ prefix)
|
|
||||||
- Type-safe configuration with defaults
|
|
||||||
|
|
||||||
### Key Abstractions
|
|
||||||
|
|
||||||
**BUrn (Bonob URN)** - Resource identifier system (`src/burn.ts`)
|
|
||||||
- Format: `{ system: string, resource: string }`
|
|
||||||
- Systems: `subsonic` (for cover art), `external` (for URLs like Spotify images)
|
|
||||||
- Used for abstracting art/image sources across different backends
|
|
||||||
|
|
||||||
**URL Builder** (`src/url_builder.ts`)
|
|
||||||
- Wraps URL manipulation with a builder pattern
|
|
||||||
- Handles context path for reverse proxy deployments
|
|
||||||
- Used throughout for generating URLs that Sonos devices can access
|
|
||||||
|
|
||||||
**Custom Players** (`src/subsonic.ts`)
|
|
||||||
- Allows mime-type specific transcoding configurations
|
|
||||||
- Maps source mime types to transcoded types
|
|
||||||
- Creates custom "client" names in Subsonic (e.g., "bonob+audio/flac")
|
|
||||||
- Example: `BNB_SUBSONIC_CUSTOM_CLIENTS="audio/flac>audio/mp3"`
|
|
||||||
|
|
||||||
### Data Flow
|
|
||||||
|
|
||||||
1. **Sonos App Request** → SOAP endpoint (`/ws/sonos`)
|
|
||||||
2. **SOAP Service** → Verifies auth token, calls `MusicLibrary` methods
|
|
||||||
3. **MusicLibrary** → Makes Subsonic API calls, transforms data
|
|
||||||
4. **SOAP Response** → Returns XML formatted for Sonos
|
|
||||||
|
|
||||||
For streaming:
|
|
||||||
1. **Sonos Device** → `GET /stream/track/:id` with custom headers (bnbt, bnbk)
|
|
||||||
2. **Stream Handler** → Verifies token, calls `MusicLibrary.stream()`
|
|
||||||
3. **Subsonic Stream** → Proxies audio with proper mime-type handling
|
|
||||||
4. **Response** → Streams audio to Sonos, reports "now playing"
|
|
||||||
|
|
||||||
### Icon System (`src/icon.ts`)
|
|
||||||
- SVG-based icon generation with dynamic colors
|
|
||||||
- Supports foreground/background color customization via `BNB_ICON_FOREGROUND_COLOR` and `BNB_ICON_BACKGROUND_COLOR`
|
|
||||||
- Genre-specific icons
|
|
||||||
- Text overlay support (e.g., year icons like "1984")
|
|
||||||
- Holiday/festival decorations (auto-applied based on date)
|
|
||||||
- Legacy mode: renders to 80x80 PNG for older Sonos systems
|
|
||||||
|
|
||||||
### Authentication Flow
|
|
||||||
1. Sonos app requests link code via `getAppLink()`
|
|
||||||
2. User visits login URL with link code
|
|
||||||
3. User enters Subsonic credentials
|
|
||||||
4. bonob validates with Subsonic, generates service token
|
|
||||||
5. bonob associates link code with service token
|
|
||||||
6. Sonos polls `getDeviceAuthToken()` with link code
|
|
||||||
7. bonob returns SMAPI token (JWT) to Sonos
|
|
||||||
8. Subsequent requests use SMAPI token, which maps to service token
|
|
||||||
|
|
||||||
### Testing Philosophy
|
|
||||||
- Jest with ts-jest preset
|
|
||||||
- In-memory implementations for `LinkCodes`, `APITokens` for testing
|
|
||||||
- Mocking with `ts-mockito`
|
|
||||||
- Test helpers in `tests/` directory
|
|
||||||
- Console.log suppressed in tests (see `tests/setup.js`)
|
|
||||||
|
|
||||||
## Common Patterns
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- Use `fp-ts` `TaskEither<AuthFailure, T>` for async operations that can fail with auth errors
|
|
||||||
- SOAP faults for Sonos-specific errors (see SMAPI_FAULT_* constants)
|
|
||||||
- Promise-based error handling with `.catch()` for most async operations
|
|
||||||
|
|
||||||
### Type Safety
|
|
||||||
- Strict TypeScript (`strict: true`, `noImplicitAny: true`, `noUncheckedIndexedAccess: true`)
|
|
||||||
- Extensive use of discriminated unions
|
|
||||||
- Interface-based design for pluggable services
|
|
||||||
|
|
||||||
### Logging
|
|
||||||
- Winston-based logger (`src/logger.ts`)
|
|
||||||
- Log level controlled by `BNB_LOG_LEVEL`
|
|
||||||
- Request logging optional via `BNB_SERVER_LOG_REQUESTS`
|
|
||||||
|
|
||||||
### Functional Programming
|
|
||||||
- Heavy use of `fp-ts` for `Option`, `Either`, `TaskEither`
|
|
||||||
- Pipe-based composition (`pipe(data, fn1, fn2, ...)`)
|
|
||||||
- Immutable data transformations
|
|
||||||
|
|
||||||
## File Organization
|
|
||||||
- `src/` - TypeScript source code
|
|
||||||
- `tests/` - Jest test files (mirrors src/ structure)
|
|
||||||
- `build/` - Compiled JavaScript (gitignored)
|
|
||||||
- `web/` - HTML templates (Eta templating) and static assets
|
|
||||||
- `typings/` - Custom TypeScript definitions
|
|
||||||
|
|
||||||
## Important Constraints
|
|
||||||
- bonob must be accessible from Sonos devices at `BNB_URL`
|
|
||||||
- `BNB_URL` cannot contain "localhost" (validation error)
|
|
||||||
- Sonos requires specific XML formats (SMAPI WSDL v1.19.6)
|
|
||||||
- Streaming must handle HTTP range requests for seek functionality
|
|
||||||
- Token lifetime (`BNB_AUTH_TIMEOUT`) should be less than Subsonic session timeout
|
|
||||||
14
Dockerfile
@@ -1,4 +1,4 @@
|
|||||||
FROM node:22-trixie-slim AS build
|
FROM node:20-bullseye-slim as build
|
||||||
|
|
||||||
WORKDIR /bonob
|
WORKDIR /bonob
|
||||||
|
|
||||||
@@ -36,12 +36,12 @@ RUN apt-get update && \
|
|||||||
NODE_ENV=production npm install --omit=dev
|
NODE_ENV=production npm install --omit=dev
|
||||||
|
|
||||||
|
|
||||||
FROM node:22-trixie-slim
|
FROM node:20-bullseye-slim
|
||||||
|
|
||||||
LABEL maintainer="simojenki" \
|
LABEL maintainer="simojenki" \
|
||||||
org.opencontainers.image.source="https://github.com/simojenki/bonob" \
|
org.opencontainers.image.source="https://github.com/simojenki/bonob" \
|
||||||
org.opencontainers.image.description="bonob SONOS SMAPI implementation" \
|
org.opencontainers.image.description="bonob SONOS SMAPI implementation" \
|
||||||
org.opencontainers.image.licenses="GPLv3"
|
org.opencontainers.image.licenses="GPLv3"
|
||||||
|
|
||||||
ENV BNB_PORT=4534
|
ENV BNB_PORT=4534
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
@@ -58,7 +58,7 @@ COPY --from=build /bonob/build/src ./src
|
|||||||
COPY --from=build /bonob/node_modules ./node_modules
|
COPY --from=build /bonob/node_modules ./node_modules
|
||||||
COPY --from=build /bonob/.gitinfo ./
|
COPY --from=build /bonob/.gitinfo ./
|
||||||
COPY web ./web
|
COPY web ./web
|
||||||
COPY src/Sonoswsdl-1.19.6-20231024.wsdl ./src/Sonoswsdl-1.19.6-20231024.wsdl
|
COPY src/Sonoswsdl-1.19.4-20190411.142401-3.wsdl ./src/Sonoswsdl-1.19.4-20190411.142401-3.wsdl
|
||||||
|
|
||||||
RUN apt-get update && \
|
RUN apt-get update && \
|
||||||
apt-get -y upgrade && \
|
apt-get -y upgrade && \
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Integrates with Subsonic API clones (Navidrome, Gonic)
|
- 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
|
- Browse by Artist, Albums, Random, Favourites, Top Rated, Playlist, Genres, Recently Added Albums, Recently Played Albums, Most Played Albums
|
||||||
- Artist & Album Art
|
- Artist & Album Art
|
||||||
- View Related Artists via Artist -> '...' -> Menu -> Related Arists
|
- View Related Artists via Artist -> '...' -> Menu -> Related Arists
|
||||||
- Now playing & Track Scrobbling
|
- Now playing & Track Scrobbling
|
||||||
- Search by Album, Artist, Track
|
- Search by Album, Artist, Track
|
||||||
- Playlist editing through sonos app.
|
- Playlist editing through sonos app.
|
||||||
- Marking of songs as favourites and with ratings through the 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)
|
- Localization (only en-US, da-DK & nl-NL supported currently, require translations for other languages). [Sonos localization and supported languages](https://docs.sonos.com/docs/localization)
|
||||||
- Auto discovery of sonos devices
|
- Auto discovery of sonos devices
|
||||||
- Discovery of sonos devices using seed IP address
|
- Discovery of sonos devices using seed IP address
|
||||||
- Auto registration with sonos on start
|
- Auto registration with sonos on start
|
||||||
|
|||||||
45
UPDATES.md
@@ -1,45 +0,0 @@
|
|||||||
# Updates for SMAPI
|
|
||||||
|
|
||||||
Run Bonob on your server.
|
|
||||||
|
|
||||||
Bonob now needs a volume to store OAuth Tokens. In the example below that directory is `/var/containers/bonob`. Adapt as needed.
|
|
||||||
Also the example below uses a `bonob` user on the system with ID `1210` and group `100`. The directory should be owned by that user.
|
|
||||||
|
|
||||||
Example systemd file (`/usr/lib/systemd/system/bonob.service`):
|
|
||||||
```
|
|
||||||
[Unit]
|
|
||||||
Description=bonob Container Service
|
|
||||||
Wants=network.target
|
|
||||||
After=network-online.target
|
|
||||||
|
|
||||||
[Service]
|
|
||||||
Environment=PODMAN_SYSTEMD_UNIT=%n
|
|
||||||
Restart=always
|
|
||||||
ExecStartPre=-/usr/bin/podman rm -f bonob
|
|
||||||
ExecStart=/usr/bin/podman run --rm \
|
|
||||||
--name bonob \
|
|
||||||
--label "io.containers.autoupdate=image" \
|
|
||||||
--user 1210:100 \
|
|
||||||
--env BNB_SONOS_SERVICE_NAME="Navidrome" \
|
|
||||||
--env BNB_PORT=8200 \
|
|
||||||
--env BNB_URL="https://bonob.mydomain.com" \
|
|
||||||
--env BNB_SECRET="Some random string" \
|
|
||||||
--env BNB_SONOS_SERVICE_ID=Your Sonos ID \
|
|
||||||
--env BNB_SUBSONIC_URL=https://music.mydomain.com \
|
|
||||||
--env BNB_ICON_FOREGROUND_COLOR="black" \
|
|
||||||
--env BNB_ICON_BACKGROUND_COLOR="#65d7f4" \
|
|
||||||
--env BNB_SONOS_AUTO_REGISTER=false \
|
|
||||||
--env BNB_SONOS_DEVICE_DISCOVERY=false \
|
|
||||||
--env BNB_LOG_LEVEL="info" \
|
|
||||||
--env TZ="Europe/Vienna" \
|
|
||||||
--volume /var/containers/bonob:/config:Z \
|
|
||||||
--publish 8200:8200 \
|
|
||||||
quay.io/wkulhanek/bonob:latest
|
|
||||||
ExecStop=/usr/bin/podman rm -f bonob
|
|
||||||
StandardOutput=syslog
|
|
||||||
StandardError=syslog
|
|
||||||
SyslogIdentifier=bonob
|
|
||||||
|
|
||||||
[Install]
|
|
||||||
WantedBy=multi-user.target default.target
|
|
||||||
```
|
|
||||||
1669
package-lock.json
generated
64
package.json
@@ -6,56 +6,56 @@
|
|||||||
"author": "simojenki <simojenki@users.noreply.github.com>",
|
"author": "simojenki <simojenki@users.noreply.github.com>",
|
||||||
"license": "GPL-3.0-only",
|
"license": "GPL-3.0-only",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@svrooij/sonos": "^2.6.0-beta.11",
|
"@svrooij/sonos": "^2.6.0-beta.7",
|
||||||
"@types/express": "^4.17.21",
|
"@types/express": "^4.17.21",
|
||||||
"@types/fs-extra": "^11.0.4",
|
"@types/fs-extra": "^11.0.4",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.5",
|
||||||
"@types/jws": "^3.2.10",
|
"@types/jws": "^3.2.9",
|
||||||
"@types/morgan": "^1.9.9",
|
"@types/morgan": "^1.9.9",
|
||||||
"@types/node": "^20.11.5",
|
"@types/node": "^20.11.5",
|
||||||
"@types/randomstring": "^1.3.0",
|
"@types/randomstring": "^1.1.11",
|
||||||
"@types/underscore": "^1.13.0",
|
"@types/underscore": "^1.11.15",
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^9.0.7",
|
||||||
"@types/xmldom": "^0.1.34",
|
"@types/xmldom": "0.1.34",
|
||||||
"@xmldom/xmldom": "^0.9.7",
|
"axios": "^1.6.5",
|
||||||
"axios": "^1.7.8",
|
"dayjs": "^1.11.10",
|
||||||
"dayjs": "^1.11.13",
|
|
||||||
"eta": "^2.2.0",
|
"eta": "^2.2.0",
|
||||||
"express": "^4.18.3",
|
"express": "^4.18.2",
|
||||||
"fp-ts": "^2.16.9",
|
"fp-ts": "^2.16.2",
|
||||||
"fs-extra": "^11.2.0",
|
"fs-extra": "^11.2.0",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jws": "^4.0.0",
|
"jws": "^4.0.0",
|
||||||
|
"libxmljs2": "^0.33.0",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"node-html-parser": "^6.1.13",
|
"node-html-parser": "^6.1.12",
|
||||||
"randomstring": "^1.3.0",
|
"randomstring": "^1.3.0",
|
||||||
"sharp": "^0.33.5",
|
"sharp": "^0.33.2",
|
||||||
"soap": "^1.1.6",
|
"soap": "^1.0.0",
|
||||||
"ts-md5": "^1.3.1",
|
"ts-md5": "^1.3.1",
|
||||||
"typescript": "^5.7.2",
|
"typescript": "^5.3.3",
|
||||||
"underscore": "^1.13.7",
|
"underscore": "^1.13.6",
|
||||||
"urn-lib": "^2.0.0",
|
"urn-lib": "^2.0.0",
|
||||||
"uuid": "^11.0.3",
|
"uuid": "^9.0.1",
|
||||||
"winston": "^3.17.0",
|
"winston": "^3.11.0",
|
||||||
"xmldom-ts": "^0.3.1",
|
"xmldom-ts": "^0.3.1"
|
||||||
"xpath": "^0.0.34"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/chai": "^5.0.1",
|
"@types/chai": "^4.3.11",
|
||||||
"@types/jest": "^29.5.14",
|
"@types/jest": "^29.5.11",
|
||||||
"@types/mocha": "^10.0.10",
|
"@types/mocha": "^10.0.6",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
"@types/tmp": "^0.2.6",
|
"@types/tmp": "^0.2.6",
|
||||||
"chai": "^5.1.2",
|
"chai": "^5.0.0",
|
||||||
"get-port": "^7.1.0",
|
"get-port": "^7.0.0",
|
||||||
"image-js": "^0.35.6",
|
"image-js": "^0.35.5",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"nodemon": "^3.1.7",
|
"nodemon": "^3.0.3",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^6.3.4",
|
||||||
"tmp": "^0.2.3",
|
"tmp": "^0.2.1",
|
||||||
"ts-jest": "^29.2.5",
|
"ts-jest": "^29.1.2",
|
||||||
"ts-mockito": "^2.6.1",
|
"ts-mockito": "^2.6.1",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
|
"xmldom-ts": "^0.3.1",
|
||||||
"xpath-ts": "^1.3.13"
|
"xpath-ts": "^1.3.13"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
"clean": "rm -Rf build node_modules",
|
"clean": "rm -Rf build node_modules",
|
||||||
"build": "tsc",
|
"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",
|
"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",
|
"devr": "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=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",
|
"register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534",
|
||||||
"test": "jest",
|
"test": "jest",
|
||||||
"testw": "jest --watch",
|
"testw": "jest --watch",
|
||||||
|
|||||||
@@ -1,85 +0,0 @@
|
|||||||
= Setting up Sonos Service
|
|
||||||
|
|
||||||
== Prerequisites
|
|
||||||
* In your Sonos App get your Sonos ID (About my Sonos System)
|
|
||||||
+
|
|
||||||
image::images/about.png[]
|
|
||||||
|
|
||||||
* Navidrome running and available from the Internet. E.g. via https://music.mydomain.com
|
|
||||||
* Bonob running and available from the Internet. E.g. via https://bonob.mydomain.com
|
|
||||||
|
|
||||||
You can use any method to make these URLs available. Cloudflare Tunnels, Pangolin, reverse proxy, etc.
|
|
||||||
|
|
||||||
== Sonos Service Integration
|
|
||||||
|
|
||||||
* Log into https://play.sonos.com
|
|
||||||
* Once logged in go to https://developer.sonos.com/s/integrations
|
|
||||||
|
|
||||||
* Create a *New Content Integration*
|
|
||||||
|
|
||||||
** General Information
|
|
||||||
*** Service Name: Navidrome
|
|
||||||
*** Service Availability: Global
|
|
||||||
*** Checkbox checked
|
|
||||||
*** Website/Social Media URLs: https://music.mydomain.com (Some URL - e.g. your Navidrome server)
|
|
||||||
|
|
||||||
** 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/v1
|
|
||||||
*** Reporting Endpoint Version: 2.3
|
|
||||||
*** 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 `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
|
|
||||||
|
|
||||||
*** No changes
|
|
||||||
|
|
||||||
** 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 (from About my system). 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.
|
|
||||||
|
Before Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 4.1 KiB |
|
Before Width: | Height: | Size: 10 KiB |
@@ -1,18 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="800" height="200" viewBox="0 0 800 200" role="img" aria-labelledby="title desc">
|
|
||||||
<title id="title">Bonob Subsonic Logo</title>
|
|
||||||
<desc id="desc">A blue music-note icon with family dots on the left, followed by stacked text Bonob Navidrome in blue.</desc>
|
|
||||||
|
|
||||||
<!-- Icon (scaled and positioned) -->
|
|
||||||
<g transform="translate(20,20) scale(4)">
|
|
||||||
<rect x="0" y="0" width="40" height="40" rx="6" ry="6" fill="#1976d2"/>
|
|
||||||
<circle cx="11.5" cy="15.5" r="1.6" fill="#ffffff"/>
|
|
||||||
<circle cx="11.5" cy="22.5" r="1.6" fill="#ffffff"/>
|
|
||||||
<circle cx="22" cy="26" r="4" fill="#ffffff"/>
|
|
||||||
<rect x="24" y="10" width="1.6" height="16" rx="0.8" fill="#ffffff"/>
|
|
||||||
<path d="M25.6 11.2 C29.0 10.6 30.2 13.6 27.4 14.6 C29.2 15.4 27.6 16.6 25.6 15.8 Z" fill="#ffffff"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Text stacked in two lines -->
|
|
||||||
<text x="240" y="90" font-family="Arial, Helvetica, sans-serif" font-size="80" fill="#1976d2">Bonob</text>
|
|
||||||
<text x="240" y="160" font-family="Arial, Helvetica, sans-serif" font-size="80" fill="#1976d2">Navidrome</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -1,19 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="180" height="20" viewBox="0 0 180 20" role="img" aria-labelledby="title desc">
|
|
||||||
<title id="title">Bonob Subsonic Logo</title>
|
|
||||||
<desc id="desc">A blue music-note icon with family dots, followed by the text Bonob Subsonic in blue.</desc>
|
|
||||||
|
|
||||||
<!-- Icon scaled down to fit height -->
|
|
||||||
<g transform="scale(0.45) translate(0,0)">
|
|
||||||
<rect x="0" y="0" width="40" height="40" rx="6" ry="6" fill="#1976d2"/>
|
|
||||||
<circle cx="11.5" cy="15.5" r="1.6" fill="#ffffff"/>
|
|
||||||
<circle cx="11.5" cy="22.5" r="1.6" fill="#ffffff"/>
|
|
||||||
<circle cx="22" cy="26" r="4" fill="#ffffff"/>
|
|
||||||
<rect x="24" y="10" width="1.6" height="16" rx="0.8" fill="#ffffff"/>
|
|
||||||
<path d="M25.6 11.2 C29.0 10.6 30.2 13.6 27.4 14.6 C29.2 15.4 27.6 16.6 25.6 15.8 Z" fill="#ffffff"/>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
<!-- Text -->
|
|
||||||
<text x="28" y="15" font-family="Arial, Helvetica, sans-serif" font-size="12" fill="#1976d2">
|
|
||||||
Bonob Subsonic
|
|
||||||
</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 950 B |
@@ -1,20 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 40 40" role="img" aria-labelledby="title desc">
|
|
||||||
<title id="title">Bonob Navidrome Music Server</title>
|
|
||||||
<desc id="desc">Blue rounded square with a white music note and two small circles representing family.</desc>
|
|
||||||
|
|
||||||
<!-- Blue rounded background -->
|
|
||||||
<rect x="0" y="0" width="40" height="40" rx="6" ry="6" fill="#1976d2"/>
|
|
||||||
|
|
||||||
<!-- Family dots (simple, symbolic) -->
|
|
||||||
<circle cx="11.5" cy="15.5" r="1.6" fill="#ffffff"/>
|
|
||||||
<circle cx="11.5" cy="22.5" r="1.6" fill="#ffffff"/>
|
|
||||||
|
|
||||||
<!-- Note head -->
|
|
||||||
<circle cx="22" cy="26" r="4" fill="#ffffff"/>
|
|
||||||
|
|
||||||
<!-- Note stem -->
|
|
||||||
<rect x="24" y="10" width="1.6" height="16" rx="0.8" fill="#ffffff"/>
|
|
||||||
|
|
||||||
<!-- Note flag (simple, filled shape) -->
|
|
||||||
<path d="M25.6 11.2 C29.0 10.6 30.2 13.6 27.4 14.6 C29.2 15.4 27.6 16.6 25.6 15.8 Z" fill="#ffffff"/>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 877 B |
@@ -97,7 +97,7 @@
|
|||||||
<xs:complexType>
|
<xs:complexType>
|
||||||
<xs:sequence>
|
<xs:sequence>
|
||||||
<xs:element name="token" type="xs:string"/>
|
<xs:element name="token" type="xs:string"/>
|
||||||
<xs:element name="key" type="xs:string" minOccurs="0"/>
|
<xs:element name="key" type="xs:string"/>
|
||||||
<xs:element name="householdId" type="xs:string"/>
|
<xs:element name="householdId" type="xs:string"/>
|
||||||
</xs:sequence>
|
</xs:sequence>
|
||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
@@ -111,12 +111,11 @@
|
|||||||
</xs:simpleType>
|
</xs:simpleType>
|
||||||
</xs:element>
|
</xs:element>
|
||||||
|
|
||||||
<xs:simpleType name="userAccountTier">
|
<xs:simpleType name="userAccountType">
|
||||||
<xs:restriction base="xs:string">
|
<xs:restriction base="xs:string">
|
||||||
<xs:enumeration value="paidPremium"/>
|
<xs:enumeration value="premium"/>
|
||||||
<xs:enumeration value="paidLimited"/>
|
<xs:enumeration value="trial"/>
|
||||||
<xs:enumeration value="free"/>
|
<xs:enumeration value="free"/>
|
||||||
<xs:enumeration value="none"/>
|
|
||||||
</xs:restriction>
|
</xs:restriction>
|
||||||
</xs:simpleType>
|
</xs:simpleType>
|
||||||
|
|
||||||
@@ -240,12 +239,6 @@
|
|||||||
</xs:simpleContent>
|
</xs:simpleContent>
|
||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
|
|
||||||
<xs:complexType name="contentKeys">
|
|
||||||
<xs:sequence>
|
|
||||||
<xs:element name="contentKey" type="tns:contentKey" maxOccurs="8"/>
|
|
||||||
</xs:sequence>
|
|
||||||
</xs:complexType>
|
|
||||||
|
|
||||||
<xs:simpleType name="mediaUriAction">
|
<xs:simpleType name="mediaUriAction">
|
||||||
<xs:restriction base="xs:string">
|
<xs:restriction base="xs:string">
|
||||||
<xs:enumeration value="IMPLICIT"/>
|
<xs:enumeration value="IMPLICIT"/>
|
||||||
@@ -362,11 +355,13 @@
|
|||||||
|
|
||||||
<xs:complexType name="userInfo">
|
<xs:complexType name="userInfo">
|
||||||
<xs:sequence>
|
<xs:sequence>
|
||||||
<!-- accountStatus potentially for future use -->
|
<!-- Everything except userIdHashCode and nickname are for future use -->
|
||||||
<xs:element name="userIdHashCode" type="xs:string" minOccurs="1"/>
|
<xs:element name="userIdHashCode" type="xs:string" minOccurs="1"/>
|
||||||
<xs:element name="accountTier" type="tns:userAccountTier" minOccurs="0"/>
|
<xs:element name="accountType" type="tns:userAccountType" minOccurs="0"/>
|
||||||
<xs:element name="accountStatus" type="tns:userAccountStatus" minOccurs="0"/>
|
<xs:element name="accountStatus" type="tns:userAccountStatus" minOccurs="0"/>
|
||||||
<xs:element ref="tns:nickname" minOccurs="0"/>
|
<xs:element ref="tns:nickname" minOccurs="0"/>
|
||||||
|
<xs:element name="profileUrl" type="tns:sonosUri" minOccurs="0"/>
|
||||||
|
<xs:element name="pictureUrl" type="tns:sonosUri" minOccurs="0"/>
|
||||||
</xs:sequence>
|
</xs:sequence>
|
||||||
</xs:complexType>
|
</xs:complexType>
|
||||||
|
|
||||||
@@ -893,10 +888,7 @@
|
|||||||
<xs:element name="getMediaURIResult" type="xs:anyURI"/>
|
<xs:element name="getMediaURIResult" type="xs:anyURI"/>
|
||||||
<xs:element name="deviceSessionToken" type="tns:deviceSessionToken" minOccurs="0" maxOccurs="1"/>
|
<xs:element name="deviceSessionToken" type="tns:deviceSessionToken" minOccurs="0" maxOccurs="1"/>
|
||||||
<xs:element name="deviceSessionKey" type="tns:encryptionContext" minOccurs="0" maxOccurs="1"/>
|
<xs:element name="deviceSessionKey" type="tns:encryptionContext" minOccurs="0" maxOccurs="1"/>
|
||||||
<xs:choice minOccurs="0">
|
<xs:element name="contentKey" type="tns:encryptionContext" minOccurs="0" maxOccurs="1"/>
|
||||||
<xs:element name="contentKey" type="tns:encryptionContext" minOccurs="0" maxOccurs="1"/>
|
|
||||||
<xs:element name="contentKeys" type="tns:contentKeys" minOccurs="0" maxOccurs="1"/>
|
|
||||||
</xs:choice>
|
|
||||||
<xs:element name="httpHeaders" type="tns:httpHeaders" minOccurs="0" maxOccurs="1"/>
|
<xs:element name="httpHeaders" type="tns:httpHeaders" minOccurs="0" maxOccurs="1"/>
|
||||||
<xs:element name="uriTimeout" type="xs:int" minOccurs="0" maxOccurs="1"/>
|
<xs:element name="uriTimeout" type="xs:int" minOccurs="0" maxOccurs="1"/>
|
||||||
<xs:element name="positionInformation" type="tns:positionInformation" minOccurs="0" maxOccurs="1"/>
|
<xs:element name="positionInformation" type="tns:positionInformation" minOccurs="0" maxOccurs="1"/>
|
||||||
@@ -2067,7 +2059,7 @@
|
|||||||
|
|
||||||
<wsdl:service name="Sonos">
|
<wsdl:service name="Sonos">
|
||||||
<wsdl:port name="SonosSoap" binding="tns:SonosSoap">
|
<wsdl:port name="SonosSoap" binding="tns:SonosSoap">
|
||||||
<soap:address location="http://moapi.sonos.com/Test/TestService.php"/>
|
<soap:address location="/about"/>
|
||||||
</wsdl:port>
|
</wsdl:port>
|
||||||
</wsdl:service>
|
</wsdl:service>
|
||||||
|
|
||||||
20
src/app.ts
@@ -6,10 +6,9 @@ import logger from "./logger";
|
|||||||
import {
|
import {
|
||||||
axiosImageFetcher,
|
axiosImageFetcher,
|
||||||
cachingImageFetcher,
|
cachingImageFetcher,
|
||||||
SubsonicMusicService,
|
Subsonic,
|
||||||
TranscodingCustomPlayers,
|
TranscodingCustomPlayers,
|
||||||
NO_CUSTOM_PLAYERS,
|
NO_CUSTOM_PLAYERS
|
||||||
Subsonic
|
|
||||||
} from "./subsonic";
|
} from "./subsonic";
|
||||||
import { InMemoryAPITokens, sha256 } from "./api_tokens";
|
import { InMemoryAPITokens, sha256 } from "./api_tokens";
|
||||||
import { InMemoryLinkCodes } from "./link_codes";
|
import { InMemoryLinkCodes } from "./link_codes";
|
||||||
@@ -18,7 +17,6 @@ import sonos, { bonobService } from "./sonos";
|
|||||||
import { MusicService } from "./music_service";
|
import { MusicService } from "./music_service";
|
||||||
import { SystemClock } from "./clock";
|
import { SystemClock } from "./clock";
|
||||||
import { JWTSmapiLoginTokens } from "./smapi_auth";
|
import { JWTSmapiLoginTokens } from "./smapi_auth";
|
||||||
import { FileSmapiTokenStore } from "./smapi_token_store";
|
|
||||||
|
|
||||||
const config = readConfig();
|
const config = readConfig();
|
||||||
const clock = SystemClock;
|
const clock = SystemClock;
|
||||||
@@ -42,13 +40,10 @@ const artistImageFetcher = config.subsonic.artistImageCache
|
|||||||
? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher)
|
? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher)
|
||||||
: axiosImageFetcher;
|
: axiosImageFetcher;
|
||||||
|
|
||||||
const subsonic = new SubsonicMusicService(
|
const subsonic = new Subsonic(
|
||||||
new Subsonic(
|
config.subsonic.url,
|
||||||
config.subsonic.url,
|
customPlayers,
|
||||||
customPlayers,
|
artistImageFetcher
|
||||||
artistImageFetcher
|
|
||||||
),
|
|
||||||
customPlayers
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const featureFlagAwareMusicService: MusicService = {
|
const featureFlagAwareMusicService: MusicService = {
|
||||||
@@ -96,8 +91,7 @@ const app = server(
|
|||||||
logRequests: config.logRequests,
|
logRequests: config.logRequests,
|
||||||
version,
|
version,
|
||||||
smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout),
|
smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout),
|
||||||
externalImageResolver: artistImageFetcher,
|
externalImageResolver: artistImageFetcher
|
||||||
smapiTokenStore: new FileSmapiTokenStore("/config/tokens.json")
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
10
src/burn.ts
@@ -1,8 +1,6 @@
|
|||||||
import _ from "underscore";
|
import _ from "underscore";
|
||||||
import { createUrnUtil } from "urn-lib";
|
import { createUrnUtil } from "urn-lib";
|
||||||
import randomstring from "randomstring";
|
import randomstring from "randomstring";
|
||||||
import { pipe } from "fp-ts/lib/function";
|
|
||||||
import { either as E } from "fp-ts";
|
|
||||||
|
|
||||||
import jwsEncryption from "./encryption";
|
import jwsEncryption from "./encryption";
|
||||||
|
|
||||||
@@ -80,13 +78,7 @@ export const parse = (burn: string): BUrn => {
|
|||||||
resource: result.resource as string,
|
resource: result.resource as string,
|
||||||
};
|
};
|
||||||
if(x.system == "encrypted") {
|
if(x.system == "encrypted") {
|
||||||
return pipe(
|
return parse(encryptor.decrypt(x.resource));
|
||||||
encryptor.decrypt(x.resource),
|
|
||||||
E.match(
|
|
||||||
(err) => { throw new Error(err) },
|
|
||||||
(z) => parse(z)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return x;
|
return x;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,14 +4,13 @@ import {
|
|||||||
randomBytes,
|
randomBytes,
|
||||||
createHash,
|
createHash,
|
||||||
} from "crypto";
|
} from "crypto";
|
||||||
import { option as O, either as E } from "fp-ts";
|
|
||||||
import { Either, left, right } from 'fp-ts/Either'
|
|
||||||
import { pipe } from "fp-ts/lib/function";
|
|
||||||
import jws from "jws";
|
import jws from "jws";
|
||||||
|
|
||||||
const ALGORITHM = "aes-256-cbc";
|
const ALGORITHM = "aes-256-cbc";
|
||||||
const IV = randomBytes(16);
|
const IV = randomBytes(16);
|
||||||
|
|
||||||
|
|
||||||
export type Hash = {
|
export type Hash = {
|
||||||
iv: string;
|
iv: string;
|
||||||
encryptedData: string;
|
encryptedData: string;
|
||||||
@@ -19,7 +18,7 @@ export type Hash = {
|
|||||||
|
|
||||||
export type Encryption = {
|
export type Encryption = {
|
||||||
encrypt: (value: string) => string;
|
encrypt: (value: string) => string;
|
||||||
decrypt: (value: string) => Either<string, string>;
|
decrypt: (value: string) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const jwsEncryption = (secret: string): Encryption => {
|
export const jwsEncryption = (secret: string): Encryption => {
|
||||||
@@ -29,15 +28,7 @@ export const jwsEncryption = (secret: string): Encryption => {
|
|||||||
payload: value,
|
payload: value,
|
||||||
secret: secret,
|
secret: secret,
|
||||||
}),
|
}),
|
||||||
decrypt: (value: string) => pipe(
|
decrypt: (value: string) => jws.decode(value).payload
|
||||||
jws.decode(value),
|
|
||||||
O.fromNullable,
|
|
||||||
O.map(it => it.payload),
|
|
||||||
O.match(
|
|
||||||
() => left("Failed to decrypt jws"),
|
|
||||||
(payload) => right(payload)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,8 +36,7 @@ export const cryptoEncryption = (secret: string): Encryption => {
|
|||||||
const key = createHash("sha256")
|
const key = createHash("sha256")
|
||||||
.update(String(secret))
|
.update(String(secret))
|
||||||
.digest("base64")
|
.digest("base64")
|
||||||
.substring(0, 32);
|
.substr(0, 32);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
encrypt: (value: string) => {
|
encrypt: (value: string) => {
|
||||||
const cipher = createCipheriv(ALGORITHM, key, IV);
|
const cipher = createCipheriv(ALGORITHM, key, IV);
|
||||||
@@ -55,23 +45,20 @@ export const cryptoEncryption = (secret: string): Encryption => {
|
|||||||
cipher.final(),
|
cipher.final(),
|
||||||
]).toString("hex")}`;
|
]).toString("hex")}`;
|
||||||
},
|
},
|
||||||
decrypt: (value: string) => pipe(
|
decrypt: (value: string) => {
|
||||||
right(value),
|
const parts = value.split(".");
|
||||||
E.map(it => it.split(".")),
|
if(parts.length != 2) throw `Invalid value to decrypt`;
|
||||||
E.flatMap(it => it.length == 2 ? right({ iv: it[0]!, data: it[1]! }) : left("Invalid value to decrypt")),
|
|
||||||
E.map(it => ({
|
const decipher = createDecipheriv(
|
||||||
hash: it,
|
ALGORITHM,
|
||||||
decipher: createDecipheriv(
|
key,
|
||||||
ALGORITHM,
|
Buffer.from(parts[0]!, "hex")
|
||||||
key,
|
);
|
||||||
Buffer.from(it.iv, "hex")
|
return Buffer.concat([
|
||||||
)
|
decipher.update(Buffer.from(parts[1]!, "hex")),
|
||||||
})),
|
decipher.final(),
|
||||||
E.map(it => Buffer.concat([
|
]).toString();
|
||||||
it.decipher.update(Buffer.from(it.hash.data, "hex")),
|
},
|
||||||
it.decipher.final(),
|
|
||||||
]).toString())
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ export type KEY =
|
|||||||
| "loginFailed"
|
| "loginFailed"
|
||||||
| "noSonosDevices"
|
| "noSonosDevices"
|
||||||
| "favourites"
|
| "favourites"
|
||||||
| "years"
|
|
||||||
| "LOVE"
|
| "LOVE"
|
||||||
| "LOVE_SUCCESS"
|
| "LOVE_SUCCESS"
|
||||||
| "STAR"
|
| "STAR"
|
||||||
@@ -84,7 +83,6 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
loginFailed: "Login failed!",
|
loginFailed: "Login failed!",
|
||||||
noSonosDevices: "No sonos devices",
|
noSonosDevices: "No sonos devices",
|
||||||
favourites: "Favourites",
|
favourites: "Favourites",
|
||||||
years: "Years",
|
|
||||||
STAR: "Star",
|
STAR: "Star",
|
||||||
UNSTAR: "Un-star",
|
UNSTAR: "Un-star",
|
||||||
STAR_SUCCESS: "Track starred",
|
STAR_SUCCESS: "Track starred",
|
||||||
@@ -127,7 +125,6 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
loginFailed: "Log på fejlede!",
|
loginFailed: "Log på fejlede!",
|
||||||
noSonosDevices: "Ingen Sonos enheder",
|
noSonosDevices: "Ingen Sonos enheder",
|
||||||
favourites: "Favoritter",
|
favourites: "Favoritter",
|
||||||
years: "Flere år",
|
|
||||||
STAR: "Tilføj stjerne",
|
STAR: "Tilføj stjerne",
|
||||||
UNSTAR: "Fjern stjerne",
|
UNSTAR: "Fjern stjerne",
|
||||||
STAR_SUCCESS: "Stjerne tilføjet",
|
STAR_SUCCESS: "Stjerne tilføjet",
|
||||||
@@ -170,7 +167,6 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
loginFailed: "La connexion a échoué !",
|
loginFailed: "La connexion a échoué !",
|
||||||
noSonosDevices: "Aucun appareil Sonos",
|
noSonosDevices: "Aucun appareil Sonos",
|
||||||
favourites: "Favoris",
|
favourites: "Favoris",
|
||||||
years: "Années",
|
|
||||||
STAR: "Suivre",
|
STAR: "Suivre",
|
||||||
UNSTAR: "Ne plus suivre",
|
UNSTAR: "Ne plus suivre",
|
||||||
STAR_SUCCESS: "Piste suivie",
|
STAR_SUCCESS: "Piste suivie",
|
||||||
@@ -213,7 +209,6 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
|||||||
loginFailed: "Inloggen mislukt!",
|
loginFailed: "Inloggen mislukt!",
|
||||||
noSonosDevices: "Geen Sonos-apparaten",
|
noSonosDevices: "Geen Sonos-apparaten",
|
||||||
favourites: "Favorieten",
|
favourites: "Favorieten",
|
||||||
years: "Jaren",
|
|
||||||
STAR: "Ster ",
|
STAR: "Ster ",
|
||||||
UNSTAR: "Een ster",
|
UNSTAR: "Een ster",
|
||||||
STAR_SUCCESS: "Nummer met ster",
|
STAR_SUCCESS: "Nummer met ster",
|
||||||
|
|||||||
94
src/icon.ts
@@ -1,5 +1,4 @@
|
|||||||
import * as xpath from "xpath";
|
import libxmljs, { Element, Attribute } from "libxmljs2";
|
||||||
import { DOMParser, Node } from '@xmldom/xmldom';
|
|
||||||
import _ from "underscore";
|
import _ from "underscore";
|
||||||
import fs from "fs";
|
import fs from "fs";
|
||||||
|
|
||||||
@@ -14,10 +13,11 @@ import {
|
|||||||
isMay4,
|
isMay4,
|
||||||
SystemClock,
|
SystemClock,
|
||||||
} from "./clock";
|
} from "./clock";
|
||||||
import { xmlTidy } from "./utils";
|
|
||||||
import path from "path";
|
import path from "path";
|
||||||
|
|
||||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
const SVG_NS = {
|
||||||
|
svg: "http://www.w3.org/2000/svg",
|
||||||
|
};
|
||||||
|
|
||||||
class ViewBox {
|
class ViewBox {
|
||||||
minX: number;
|
minX: number;
|
||||||
@@ -48,16 +48,8 @@ export type IconFeatures = {
|
|||||||
viewPortIncreasePercent: number | undefined;
|
viewPortIncreasePercent: number | undefined;
|
||||||
backgroundColor: string | undefined;
|
backgroundColor: string | undefined;
|
||||||
foregroundColor: string | undefined;
|
foregroundColor: string | undefined;
|
||||||
text: string | undefined;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NO_FEATURES: IconFeatures = {
|
|
||||||
viewPortIncreasePercent: undefined,
|
|
||||||
backgroundColor: undefined,
|
|
||||||
foregroundColor: undefined,
|
|
||||||
text: undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
export type IconSpec = {
|
export type IconSpec = {
|
||||||
svg: string | undefined;
|
svg: string | undefined;
|
||||||
features: Partial<IconFeatures> | undefined;
|
features: Partial<IconFeatures> | undefined;
|
||||||
@@ -101,11 +93,17 @@ export class SvgIcon implements Icon {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
svg: string,
|
svg: string,
|
||||||
features: Partial<IconFeatures> = {}
|
features: Partial<IconFeatures> = {
|
||||||
|
viewPortIncreasePercent: undefined,
|
||||||
|
backgroundColor: undefined,
|
||||||
|
foregroundColor: undefined,
|
||||||
|
}
|
||||||
) {
|
) {
|
||||||
this.svg = svg;
|
this.svg = svg;
|
||||||
this.features = {
|
this.features = {
|
||||||
...NO_FEATURES,
|
viewPortIncreasePercent: undefined,
|
||||||
|
backgroundColor: undefined,
|
||||||
|
foregroundColor: undefined,
|
||||||
...features,
|
...features,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -119,44 +117,38 @@ export class SvgIcon implements Icon {
|
|||||||
});
|
});
|
||||||
|
|
||||||
public toString = () => {
|
public toString = () => {
|
||||||
const doc = new DOMParser().parseFromString(this.svg, 'text/xml') as unknown as Document;
|
const xml = libxmljs.parseXmlString(this.svg, {
|
||||||
const select = xpath.useNamespaces({ svg: SVG_NS });
|
noblanks: true,
|
||||||
|
net: false,
|
||||||
const elements = (path: string) => (select(path, doc) as Element[])
|
});
|
||||||
const element = (path: string) => elements(path)[0]!
|
const viewBoxAttr = xml.get("//svg:svg/@viewBox", SVG_NS) as Attribute;
|
||||||
|
let viewBox = new ViewBox(viewBoxAttr.value());
|
||||||
let viewBox = new ViewBox(select("string(//svg:svg/@viewBox)", doc) as string);
|
|
||||||
if (
|
if (
|
||||||
this.features.viewPortIncreasePercent &&
|
this.features.viewPortIncreasePercent &&
|
||||||
this.features.viewPortIncreasePercent > 0
|
this.features.viewPortIncreasePercent > 0
|
||||||
) {
|
) {
|
||||||
viewBox = viewBox.increasePercent(this.features.viewPortIncreasePercent);
|
viewBox = viewBox.increasePercent(this.features.viewPortIncreasePercent);
|
||||||
element("//svg:svg").setAttribute("viewBox", viewBox.toString());
|
viewBoxAttr.value(viewBox.toString());
|
||||||
}
|
|
||||||
if(this.features.text) {
|
|
||||||
elements("//svg:text").forEach((text) => {
|
|
||||||
text.textContent = this.features.text!
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (this.features.foregroundColor) {
|
|
||||||
elements("//svg:path|//svg:text").forEach((path) => {
|
|
||||||
if (path.getAttribute("fill")) path.setAttribute("stroke", this.features.foregroundColor!);
|
|
||||||
else path.setAttribute("fill", this.features.foregroundColor!);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
if (this.features.backgroundColor) {
|
if (this.features.backgroundColor) {
|
||||||
const rect = doc.createElementNS(SVG_NS, "rect");
|
(xml.get("//svg:svg/*[1]", SVG_NS) as Element).addPrevSibling(
|
||||||
rect.setAttribute("x", `${viewBox.minX}`);
|
new Element(xml, "rect").attr({
|
||||||
rect.setAttribute("y", `${viewBox.minY}`);
|
x: `${viewBox.minX}`,
|
||||||
rect.setAttribute("width", `${Math.abs(viewBox.minX) + viewBox.width}`);
|
y: `${viewBox.minY}`,
|
||||||
rect.setAttribute("height", `${Math.abs(viewBox.minY) + viewBox.height}`);
|
width: `${Math.abs(viewBox.minX) + viewBox.width}`,
|
||||||
rect.setAttribute("fill", this.features.backgroundColor);
|
height: `${Math.abs(viewBox.minY) + viewBox.height}`,
|
||||||
|
fill: this.features.backgroundColor,
|
||||||
const svg = element("//svg:svg")
|
})
|
||||||
svg.insertBefore(rect, svg.childNodes[0]!);
|
);
|
||||||
}
|
}
|
||||||
|
if (this.features.foregroundColor) {
|
||||||
return xmlTidy(doc as unknown as Node);
|
(xml.find("//svg:path", SVG_NS) as Element[]).forEach((path) => {
|
||||||
|
if (path.attr("fill"))
|
||||||
|
path.attr({ stroke: this.features.foregroundColor! });
|
||||||
|
else path.attr({ fill: this.features.foregroundColor! });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return xml.toString();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,24 +229,20 @@ export type ICON =
|
|||||||
| "yoda"
|
| "yoda"
|
||||||
| "heart"
|
| "heart"
|
||||||
| "star"
|
| "star"
|
||||||
| "solidStar"
|
| "solidStar";
|
||||||
| "yy"
|
|
||||||
| "yyyy";
|
|
||||||
|
|
||||||
const svgFrom = (name: string) =>
|
const iconFrom = (name: string) =>
|
||||||
new SvgIcon(
|
new SvgIcon(
|
||||||
fs
|
fs
|
||||||
.readFileSync(path.resolve(__dirname, "..", "web", "icons", name))
|
.readFileSync(path.resolve(__dirname, "..", "web", "icons", name))
|
||||||
.toString()
|
.toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
const iconFrom = (name: string) => svgFrom(name).with({ features: { viewPortIncreasePercent: 80 } });
|
|
||||||
|
|
||||||
export const ICONS: Record<ICON, SvgIcon> = {
|
export const ICONS: Record<ICON, SvgIcon> = {
|
||||||
artists: iconFrom("navidrome-artists.svg"),
|
artists: iconFrom("navidrome-artists.svg"),
|
||||||
albums: iconFrom("navidrome-all.svg"),
|
albums: iconFrom("navidrome-all.svg"),
|
||||||
radio: iconFrom("navidrome-radio.svg"),
|
radio: iconFrom("navidrome-radio.svg"),
|
||||||
blank: svgFrom("blank.svg"),
|
blank: iconFrom("blank.svg"),
|
||||||
playlists: iconFrom("navidrome-playlists.svg"),
|
playlists: iconFrom("navidrome-playlists.svg"),
|
||||||
genres: iconFrom("Theatre-Mask-111172.svg"),
|
genres: iconFrom("Theatre-Mask-111172.svg"),
|
||||||
random: iconFrom("navidrome-random.svg"),
|
random: iconFrom("navidrome-random.svg"),
|
||||||
@@ -319,9 +307,7 @@ export const ICONS: Record<ICON, SvgIcon> = {
|
|||||||
yoda: iconFrom("Yoda-68107.svg"),
|
yoda: iconFrom("Yoda-68107.svg"),
|
||||||
heart: iconFrom("Heart-85038.svg"),
|
heart: iconFrom("Heart-85038.svg"),
|
||||||
star: iconFrom("Star-16101.svg"),
|
star: iconFrom("Star-16101.svg"),
|
||||||
solidStar: iconFrom("Star-43879.svg"),
|
solidStar: iconFrom("Star-43879.svg")
|
||||||
yy: svgFrom("yy.svg"),
|
|
||||||
yyyy: svgFrom("yyyy.svg"),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STAR_WARS = [ICONS.c3po, ICONS.chewy, ICONS.darth, ICONS.skywalker, ICONS.leia, ICONS.r2d2, ICONS.yoda];
|
export const STAR_WARS = [ICONS.c3po, ICONS.chewy, ICONS.darth, ICONS.skywalker, ICONS.leia, ICONS.r2d2, ICONS.yoda];
|
||||||
|
|||||||
@@ -46,10 +46,6 @@ export type Genre = {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Year = {
|
|
||||||
year: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Rating = {
|
export type Rating = {
|
||||||
love: boolean;
|
love: boolean;
|
||||||
stars: number;
|
stars: number;
|
||||||
@@ -104,13 +100,11 @@ export const asResult = <T>([results, total]: [T[], number]) => ({
|
|||||||
|
|
||||||
export type ArtistQuery = Paging;
|
export type ArtistQuery = Paging;
|
||||||
|
|
||||||
export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'byYear' | 'random' | 'recentlyPlayed' | 'mostPlayed' | 'recentlyAdded' | 'favourited' | 'starred';
|
export type AlbumQueryType = 'alphabeticalByArtist' | 'alphabeticalByName' | 'byGenre' | 'random' | 'recentlyPlayed' | 'mostPlayed' | 'recentlyAdded' | 'favourited' | 'starred';
|
||||||
|
|
||||||
export type AlbumQuery = Paging & {
|
export type AlbumQuery = Paging & {
|
||||||
type: AlbumQueryType;
|
type: AlbumQueryType;
|
||||||
genre?: string;
|
genre?: string;
|
||||||
fromYear?: string;
|
|
||||||
toYear?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const artistToArtistSummary = (it: Artist): ArtistSummary => ({
|
export const artistToArtistSummary = (it: Artist): ArtistSummary => ({
|
||||||
@@ -179,7 +173,6 @@ export interface MusicLibrary {
|
|||||||
tracks(albumId: string): Promise<Track[]>;
|
tracks(albumId: string): Promise<Track[]>;
|
||||||
track(trackId: string): Promise<Track>;
|
track(trackId: string): Promise<Track>;
|
||||||
genres(): Promise<Genre[]>;
|
genres(): Promise<Genre[]>;
|
||||||
years(): Promise<Year[]>;
|
|
||||||
stream({
|
stream({
|
||||||
trackId,
|
trackId,
|
||||||
range,
|
range,
|
||||||
|
|||||||
158
src/server.ts
@@ -39,29 +39,9 @@ import {
|
|||||||
JWTSmapiLoginTokens,
|
JWTSmapiLoginTokens,
|
||||||
SmapiAuthTokens,
|
SmapiAuthTokens,
|
||||||
} from "./smapi_auth";
|
} from "./smapi_auth";
|
||||||
import { SmapiTokenStore, InMemorySmapiTokenStore } from "./smapi_token_store";
|
|
||||||
|
|
||||||
export const BONOB_ACCESS_TOKEN_HEADER = "bat";
|
export const BONOB_ACCESS_TOKEN_HEADER = "bat";
|
||||||
|
|
||||||
// Session storage for tracking active streams (for scrobbling)
|
|
||||||
type StreamSession = {
|
|
||||||
serviceToken: string;
|
|
||||||
trackId: string;
|
|
||||||
timestamp: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const streamSessions = new Map<string, StreamSession>();
|
|
||||||
|
|
||||||
// Clean up old sessions (older than 1 hour)
|
|
||||||
setInterval(() => {
|
|
||||||
const oneHourAgo = Date.now() - (60 * 60 * 1000);
|
|
||||||
for (const [sid, session] of streamSessions.entries()) {
|
|
||||||
if (session.timestamp < oneHourAgo) {
|
|
||||||
streamSessions.delete(sid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 5 * 60 * 1000).unref(); // Run every 5 minutes, but don't prevent process exit
|
|
||||||
|
|
||||||
interface RangeFilter extends Transform {
|
interface RangeFilter extends Transform {
|
||||||
range: (length: number) => string;
|
range: (length: number) => string;
|
||||||
}
|
}
|
||||||
@@ -112,7 +92,6 @@ export type ServerOpts = {
|
|||||||
version: string;
|
version: string;
|
||||||
smapiAuthTokens: SmapiAuthTokens;
|
smapiAuthTokens: SmapiAuthTokens;
|
||||||
externalImageResolver: ImageFetcher;
|
externalImageResolver: ImageFetcher;
|
||||||
smapiTokenStore: SmapiTokenStore;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_SERVER_OPTS: ServerOpts = {
|
const DEFAULT_SERVER_OPTS: ServerOpts = {
|
||||||
@@ -129,7 +108,6 @@ const DEFAULT_SERVER_OPTS: ServerOpts = {
|
|||||||
"1m"
|
"1m"
|
||||||
),
|
),
|
||||||
externalImageResolver: axiosImageFetcher,
|
externalImageResolver: axiosImageFetcher,
|
||||||
smapiTokenStore: new InMemorySmapiTokenStore(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
function server(
|
function server(
|
||||||
@@ -155,7 +133,6 @@ function server(
|
|||||||
app.use(morgan("combined"));
|
app.use(morgan("combined"));
|
||||||
}
|
}
|
||||||
app.use(express.urlencoded({ extended: false }));
|
app.use(express.urlencoded({ extended: false }));
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
app.use(express.static(path.resolve(__dirname, "..", "web", "public")));
|
app.use(express.static(path.resolve(__dirname, "..", "web", "public")));
|
||||||
app.engine("eta", Eta.renderFile);
|
app.engine("eta", Eta.renderFile);
|
||||||
@@ -420,14 +397,6 @@ function server(
|
|||||||
if (!serviceToken) {
|
if (!serviceToken) {
|
||||||
return res.status(401).send();
|
return res.status(401).send();
|
||||||
} else {
|
} else {
|
||||||
// Store session for scrobbling later (when Sonos reports playback)
|
|
||||||
streamSessions.set(id, {
|
|
||||||
serviceToken,
|
|
||||||
trackId: id,
|
|
||||||
timestamp: Date.now(),
|
|
||||||
});
|
|
||||||
logger.debug(`Stored stream session for track ${id}`);
|
|
||||||
|
|
||||||
return musicService
|
return musicService
|
||||||
.login(serviceToken)
|
.login(serviceToken)
|
||||||
.then((it) =>
|
.then((it) =>
|
||||||
@@ -529,18 +498,16 @@ function server(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get("/icon/:type_text/size/:size", (req, res) => {
|
app.get("/icon/:type/size/:size", (req, res) => {
|
||||||
const match = (req.params["type_text"] || "")!.match("^([A-Za-z0-9]+)(?:\:([A-Za-z0-9]+))?$")
|
const type = req.params["type"]!;
|
||||||
if (!match)
|
|
||||||
return res.status(400).send();
|
|
||||||
|
|
||||||
const type = match[1]!
|
|
||||||
const text = match[2]
|
|
||||||
const size = req.params["size"]!;
|
const size = req.params["size"]!;
|
||||||
|
|
||||||
if (!Object.keys(ICONS).includes(type)) {
|
if (!Object.keys(ICONS).includes(type)) {
|
||||||
return res.status(404).send();
|
return res.status(404).send();
|
||||||
} else if (size != "legacy" && !SONOS_RECOMMENDED_IMAGE_SIZES.includes(size)) {
|
} else if (
|
||||||
|
size != "legacy" &&
|
||||||
|
!SONOS_RECOMMENDED_IMAGE_SIZES.includes(size)
|
||||||
|
) {
|
||||||
return res.status(400).send();
|
return res.status(400).send();
|
||||||
} else {
|
} else {
|
||||||
let icon = (ICONS as any)[type]! as Icon;
|
let icon = (ICONS as any)[type]! as Icon;
|
||||||
@@ -561,8 +528,8 @@ function server(
|
|||||||
icon
|
icon
|
||||||
.apply(
|
.apply(
|
||||||
features({
|
features({
|
||||||
|
viewPortIncreasePercent: 80,
|
||||||
...serverOpts.iconColors,
|
...serverOpts.iconColors,
|
||||||
text: text
|
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.apply(festivals(clock))
|
.apply(festivals(clock))
|
||||||
@@ -629,113 +596,6 @@ function server(
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sonos Reporting Endpoint for playback analytics
|
|
||||||
app.post("/report/:version/timePlayed", async (req, res) => {
|
|
||||||
const version = req.params["version"];
|
|
||||||
logger.debug(`Received Sonos reporting event (v${version}): ${JSON.stringify(req.body)}`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Sonos may send an array of reports or a single report with items array
|
|
||||||
const reports = Array.isArray(req.body) ? req.body : [req.body];
|
|
||||||
|
|
||||||
for (const report of reports) {
|
|
||||||
// Handle both direct report format and items array format
|
|
||||||
const items = report.items || [report];
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const {
|
|
||||||
reportId,
|
|
||||||
mediaUrl,
|
|
||||||
durationPlayedMillis,
|
|
||||||
positionMillis,
|
|
||||||
type,
|
|
||||||
} = item;
|
|
||||||
|
|
||||||
// Extract track ID from mediaUrl (format: /stream/track/{id} or x-sonos-http:track%3a{id}.mp3)
|
|
||||||
let trackId: string | undefined;
|
|
||||||
|
|
||||||
if (mediaUrl) {
|
|
||||||
// Try standard URL format first
|
|
||||||
const standardMatch = mediaUrl.match(/\/stream\/track\/([^?]+)/);
|
|
||||||
if (standardMatch) {
|
|
||||||
trackId = standardMatch[1];
|
|
||||||
} else {
|
|
||||||
// Try x-sonos-http format (track%3a{id}.mp3)
|
|
||||||
const sonosMatch = mediaUrl.match(/track%3[aA]([^.?&]+)/);
|
|
||||||
if (sonosMatch) {
|
|
||||||
trackId = sonosMatch[1];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!trackId) {
|
|
||||||
logger.warn(`Could not extract track ID from mediaUrl: ${mediaUrl}, full report: ${JSON.stringify(report)}`);
|
|
||||||
continue; // Skip this report, process next one
|
|
||||||
}
|
|
||||||
|
|
||||||
const durationPlayedSeconds = Math.floor((durationPlayedMillis || 0) / 1000);
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`Sonos reporting: type=${type}, trackId=${trackId}, reportId=${reportId}, ` +
|
|
||||||
`durationPlayed=${durationPlayedSeconds}s, position=${positionMillis}ms`
|
|
||||||
);
|
|
||||||
|
|
||||||
// For "final" reports, determine if we should scrobble
|
|
||||||
if (type === "final" && durationPlayedSeconds > 0) {
|
|
||||||
// Retrieve authentication from stream session storage
|
|
||||||
const session = streamSessions.get(trackId!);
|
|
||||||
let serviceToken: string | undefined = session?.serviceToken;
|
|
||||||
|
|
||||||
if (!serviceToken) {
|
|
||||||
// Fallback: try to extract from Authorization header (if present)
|
|
||||||
const authHeader = req.headers["authorization"];
|
|
||||||
if (authHeader && authHeader.startsWith("Bearer ")) {
|
|
||||||
const token = authHeader.substring(7);
|
|
||||||
const smapiToken = serverOpts.smapiTokenStore.get(token);
|
|
||||||
if (smapiToken) {
|
|
||||||
serviceToken = pipe(
|
|
||||||
smapiAuthTokens.verify({ token, key: smapiToken.key }),
|
|
||||||
E.getOrElseW(() => undefined)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (serviceToken) {
|
|
||||||
await musicService.login(serviceToken).then((musicLibrary) => {
|
|
||||||
// Get track duration to determine scrobbling threshold
|
|
||||||
return musicLibrary.track(trackId!).then((track) => {
|
|
||||||
const shouldScrobble =
|
|
||||||
(track.duration < 30 && durationPlayedSeconds >= 10) ||
|
|
||||||
(track.duration >= 30 && durationPlayedSeconds >= 30);
|
|
||||||
|
|
||||||
if (shouldScrobble) {
|
|
||||||
logger.info(`Scrobbling track ${trackId} after ${durationPlayedSeconds}s playback`);
|
|
||||||
return musicLibrary.scrobble(trackId!);
|
|
||||||
} else {
|
|
||||||
logger.debug(
|
|
||||||
`Not scrobbling track ${trackId}: duration=${track.duration}s, played=${durationPlayedSeconds}s`
|
|
||||||
);
|
|
||||||
return Promise.resolve(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}).catch((e) => {
|
|
||||||
logger.error(`Failed to process scrobble for track ${trackId}`, { error: e });
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.debug("No authentication available for reporting endpoint scrobble");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return res.status(200).json({ status: "ok" });
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Error processing Sonos reporting event", { error });
|
|
||||||
return res.status(500).json({ status: "error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
bindSmapiSoapServiceToExpress(
|
bindSmapiSoapServiceToExpress(
|
||||||
app,
|
app,
|
||||||
SOAP_PATH,
|
SOAP_PATH,
|
||||||
@@ -745,9 +605,7 @@ function server(
|
|||||||
apiTokens,
|
apiTokens,
|
||||||
clock,
|
clock,
|
||||||
i8n,
|
i8n,
|
||||||
serverOpts.smapiAuthTokens,
|
serverOpts.smapiAuthTokens
|
||||||
serverOpts.smapiTokenStore,
|
|
||||||
serverOpts.logRequests
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (serverOpts.applyContextPath) {
|
if (serverOpts.applyContextPath) {
|
||||||
|
|||||||
362
src/smapi.ts
@@ -15,7 +15,6 @@ import {
|
|||||||
AlbumSummary,
|
AlbumSummary,
|
||||||
ArtistSummary,
|
ArtistSummary,
|
||||||
Genre,
|
Genre,
|
||||||
Year,
|
|
||||||
MusicService,
|
MusicService,
|
||||||
Playlist,
|
Playlist,
|
||||||
RadioStation,
|
RadioStation,
|
||||||
@@ -36,11 +35,7 @@ import {
|
|||||||
SmapiAuthTokens,
|
SmapiAuthTokens,
|
||||||
SMAPI_FAULT_LOGIN_UNAUTHORIZED,
|
SMAPI_FAULT_LOGIN_UNAUTHORIZED,
|
||||||
ToSmapiFault,
|
ToSmapiFault,
|
||||||
SmapiToken,
|
|
||||||
} from "./smapi_auth";
|
} from "./smapi_auth";
|
||||||
import { InvalidTokenError } from "./smapi_auth";
|
|
||||||
import { IncomingHttpHeaders } from "http2";
|
|
||||||
import { SmapiTokenStore } from "./smapi_token_store";
|
|
||||||
|
|
||||||
export const LOGIN_ROUTE = "/login";
|
export const LOGIN_ROUTE = "/login";
|
||||||
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
|
export const CREATE_REGISTRATION_ROUTE = "/registration/add";
|
||||||
@@ -66,7 +61,7 @@ export const SONOS_RECOMMENDED_IMAGE_SIZES = [
|
|||||||
|
|
||||||
const WSDL_FILE = path.resolve(
|
const WSDL_FILE = path.resolve(
|
||||||
__dirname,
|
__dirname,
|
||||||
"Sonoswsdl-1.19.6-20231024.wsdl"
|
"Sonoswsdl-1.19.4-20190411.142401-3.wsdl"
|
||||||
);
|
);
|
||||||
|
|
||||||
export type Credentials = {
|
export type Credentials = {
|
||||||
@@ -165,20 +160,17 @@ class SonosSoap {
|
|||||||
bonobUrl: URLBuilder;
|
bonobUrl: URLBuilder;
|
||||||
smapiAuthTokens: SmapiAuthTokens;
|
smapiAuthTokens: SmapiAuthTokens;
|
||||||
clock: Clock;
|
clock: Clock;
|
||||||
tokenStore: SmapiTokenStore;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
bonobUrl: URLBuilder,
|
bonobUrl: URLBuilder,
|
||||||
linkCodes: LinkCodes,
|
linkCodes: LinkCodes,
|
||||||
smapiAuthTokens: SmapiAuthTokens,
|
smapiAuthTokens: SmapiAuthTokens,
|
||||||
clock: Clock,
|
clock: Clock
|
||||||
tokenStore: SmapiTokenStore
|
|
||||||
) {
|
) {
|
||||||
this.bonobUrl = bonobUrl;
|
this.bonobUrl = bonobUrl;
|
||||||
this.linkCodes = linkCodes;
|
this.linkCodes = linkCodes;
|
||||||
this.smapiAuthTokens = smapiAuthTokens;
|
this.smapiAuthTokens = smapiAuthTokens;
|
||||||
this.clock = clock;
|
this.clock = clock;
|
||||||
this.tokenStore = tokenStore;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getAppLink(): GetAppLinkResult {
|
getAppLink(): GetAppLinkResult {
|
||||||
@@ -200,11 +192,6 @@ class SonosSoap {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
reportAccountAction = (args: any, _headers: any) => {
|
|
||||||
logger.info('Sonos reportAccountAction: ' + JSON.stringify(args));
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
getDeviceAuthToken({
|
getDeviceAuthToken({
|
||||||
linkCode,
|
linkCode,
|
||||||
}: {
|
}: {
|
||||||
@@ -245,18 +232,6 @@ class SonosSoap {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
getCredentialsForToken(token: string): SmapiToken | undefined {
|
|
||||||
logger.debug("getCredentialsForToken called with: " + token);
|
|
||||||
logger.debug("Current tokens: " + JSON.stringify(this.tokenStore.getAll()));
|
|
||||||
return this.tokenStore.get(token);
|
|
||||||
}
|
|
||||||
associateCredentialsForToken(token: string, fullSmapiToken: SmapiToken, oldToken?:string) {
|
|
||||||
logger.debug("Adding token: " + token + " " + JSON.stringify(fullSmapiToken));
|
|
||||||
if(oldToken) {
|
|
||||||
this.tokenStore.delete(oldToken);
|
|
||||||
}
|
|
||||||
this.tokenStore.set(token, fullSmapiToken);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ContainerType = "container" | "search" | "albumList";
|
export type ContainerType = "container" | "search" | "albumList";
|
||||||
@@ -269,20 +244,12 @@ export type Container = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
|
const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
|
||||||
itemType: "albumList",
|
itemType: "container",
|
||||||
id: `genre:${genre.id}`,
|
id: `genre:${genre.id}`,
|
||||||
title: genre.name,
|
title: genre.name,
|
||||||
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(),
|
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const yyyy = (bonobUrl: URLBuilder, year: Year) => ({
|
|
||||||
itemType: "albumList",
|
|
||||||
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(),
|
|
||||||
});
|
|
||||||
|
|
||||||
const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
|
const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
|
||||||
itemType: "playlist",
|
itemType: "playlist",
|
||||||
id: `playlist:${playlist.id}`,
|
id: `playlist:${playlist.id}`,
|
||||||
@@ -311,9 +278,9 @@ export const coverArtURI = (
|
|||||||
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
|
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
|
||||||
);
|
);
|
||||||
|
|
||||||
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON, text: string | undefined = undefined) =>
|
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) =>
|
||||||
bonobUrl.append({
|
bonobUrl.append({
|
||||||
pathname: `/icon/${text == undefined ? icon : `${icon}:${text}`}/size/legacy`,
|
pathname: `/icon/${icon}/size/legacy`,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const sonosifyMimeType = (mimeType: string) =>
|
export const sonosifyMimeType = (mimeType: string) =>
|
||||||
@@ -404,30 +371,9 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
apiKeys: APITokens,
|
apiKeys: APITokens,
|
||||||
clock: Clock,
|
clock: Clock,
|
||||||
i8n: I8N,
|
i8n: I8N,
|
||||||
smapiAuthTokens: SmapiAuthTokens,
|
smapiAuthTokens: SmapiAuthTokens
|
||||||
tokenStore: SmapiTokenStore,
|
|
||||||
_logRequests: boolean
|
|
||||||
) {
|
) {
|
||||||
const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock, tokenStore);
|
const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock);
|
||||||
|
|
||||||
// Clean up expired tokens on startup
|
|
||||||
try {
|
|
||||||
const cleaned = tokenStore.cleanupExpired(smapiAuthTokens);
|
|
||||||
if (cleaned > 0) {
|
|
||||||
logger.info(`Cleaned up ${cleaned} expired token(s) on startup`);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to cleanup expired tokens on startup", { error });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up expired tokens every hour
|
|
||||||
setInterval(() => {
|
|
||||||
try {
|
|
||||||
tokenStore.cleanupExpired(smapiAuthTokens);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error("Failed to cleanup expired tokens", { error });
|
|
||||||
}
|
|
||||||
}, 60 * 60 * 1000).unref(); // Run every hour, but don't prevent process exit
|
|
||||||
|
|
||||||
const urlWithToken = (accessToken: string) =>
|
const urlWithToken = (accessToken: string) =>
|
||||||
bonobUrl.append({
|
bonobUrl.append({
|
||||||
@@ -440,39 +386,18 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
const credentialsFrom = E.fromNullable(new MissingLoginTokenError());
|
const credentialsFrom = E.fromNullable(new MissingLoginTokenError());
|
||||||
return pipe(
|
return pipe(
|
||||||
credentialsFrom(credentials),
|
credentialsFrom(credentials),
|
||||||
E.chain((credentials) => {
|
E.chain((credentials) =>
|
||||||
// Check if token/key is associated with a user
|
pipe(
|
||||||
const smapiToken = sonosSoap.getCredentialsForToken(credentials.loginToken.token);
|
|
||||||
if (!smapiToken) {
|
|
||||||
logger.warn("Token not found in store - possibly old/expired token from Sonos cache. Try removing and re-adding the service in Sonos app.");
|
|
||||||
return E.left(new InvalidTokenError("Token not found"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// If credentials don't have a key, use the stored one
|
|
||||||
const effectiveKey = credentials.loginToken.key || smapiToken.key;
|
|
||||||
|
|
||||||
if (smapiToken.key !== effectiveKey) {
|
|
||||||
logger.warn("Token key mismatch", { storedKey: smapiToken.key, providedKey: effectiveKey });
|
|
||||||
return E.left(new InvalidTokenError("Token key mismatch"));
|
|
||||||
}
|
|
||||||
|
|
||||||
return pipe(
|
|
||||||
smapiAuthTokens.verify({
|
smapiAuthTokens.verify({
|
||||||
token: credentials.loginToken.token,
|
token: credentials.loginToken.token,
|
||||||
key: effectiveKey,
|
key: credentials.loginToken.key,
|
||||||
}),
|
}),
|
||||||
E.map((serviceToken) => ({
|
E.map((serviceToken) => ({
|
||||||
serviceToken,
|
serviceToken,
|
||||||
credentials: {
|
credentials,
|
||||||
...credentials,
|
|
||||||
loginToken: {
|
|
||||||
...credentials.loginToken,
|
|
||||||
key: effectiveKey,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}))
|
}))
|
||||||
);
|
)
|
||||||
}),
|
),
|
||||||
E.map(({ serviceToken, credentials }) => ({
|
E.map(({ serviceToken, credentials }) => ({
|
||||||
serviceToken,
|
serviceToken,
|
||||||
credentials,
|
credentials,
|
||||||
@@ -481,49 +406,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const swapToken = (expiredToken: string | undefined) => (newToken: SmapiToken) => {
|
const login = async (credentials?: Credentials) => {
|
||||||
logger.debug("oldToken: " + expiredToken);
|
|
||||||
logger.debug("newToken: " + JSON.stringify(newToken));
|
|
||||||
if (expiredToken) {
|
|
||||||
sonosSoap.associateCredentialsForToken(newToken.token, newToken, expiredToken);
|
|
||||||
} else {
|
|
||||||
sonosSoap.associateCredentialsForToken(newToken.token, newToken);
|
|
||||||
}
|
|
||||||
return TE.right(newToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
const useHeaderIfPresent = (credentials?: Credentials, headers?: IncomingHttpHeaders) => {
|
|
||||||
const headersProvidedWithToken = headers!==null && headers!== undefined && headers["authorization"];
|
|
||||||
if(headersProvidedWithToken) {
|
|
||||||
logger.debug("Will use authorization header");
|
|
||||||
const bearer = headers["authorization"];
|
|
||||||
const token = bearer?.split(" ")[1];
|
|
||||||
if(token) {
|
|
||||||
const credsForToken = sonosSoap.getCredentialsForToken(token);
|
|
||||||
if(credsForToken==undefined) {
|
|
||||||
logger.debug("No creds for "+JSON.stringify(token));
|
|
||||||
} else {
|
|
||||||
credentials = {
|
|
||||||
...credentials!,
|
|
||||||
loginToken: {
|
|
||||||
...credentials?.loginToken!,
|
|
||||||
token: credsForToken.token,
|
|
||||||
key: credsForToken.key,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
logger.debug("Updated credentials to " + JSON.stringify(credentials));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return credentials;
|
|
||||||
}
|
|
||||||
|
|
||||||
const login = async (credentials?: Credentials, headers?: IncomingHttpHeaders) => {
|
|
||||||
|
|
||||||
const credentialsProvidedWithoutAuthToken = credentials && credentials.loginToken.token==null;
|
|
||||||
if(credentialsProvidedWithoutAuthToken) {
|
|
||||||
credentials = useHeaderIfPresent(credentials, headers);
|
|
||||||
}
|
|
||||||
const authOrFail = pipe(
|
const authOrFail = pipe(
|
||||||
auth(credentials),
|
auth(credentials),
|
||||||
E.getOrElseW((fault) => fault)
|
E.getOrElseW((fault) => fault)
|
||||||
@@ -536,16 +419,9 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
|
throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
|
||||||
});
|
});
|
||||||
} else if (isExpiredTokenError(authOrFail)) {
|
} else if (isExpiredTokenError(authOrFail)) {
|
||||||
// Don't pass old token here to avoid circular reference issues with Jest/SOAP
|
|
||||||
// Old expired tokens will be cleaned up by TTL or manual cleanup later
|
|
||||||
logger.info("Token expired, attempting refresh...");
|
|
||||||
throw await pipe(
|
throw await pipe(
|
||||||
musicService.refreshToken(authOrFail.expiredToken),
|
musicService.refreshToken(authOrFail.expiredToken),
|
||||||
TE.map((it) => {
|
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
|
||||||
logger.info("Token refresh successful, issuing new SMAPI token");
|
|
||||||
return smapiAuthTokens.issue(it.serviceToken);
|
|
||||||
}),
|
|
||||||
TE.tap(swapToken(undefined)),
|
|
||||||
TE.map((newToken) => ({
|
TE.map((newToken) => ({
|
||||||
Fault: {
|
Fault: {
|
||||||
faultcode: "Client.TokenRefreshRequired",
|
faultcode: "Client.TokenRefreshRequired",
|
||||||
@@ -558,10 +434,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
TE.getOrElse((err) => {
|
TE.getOrElse(() => T.of(SMAPI_FAULT_LOGIN_UNAUTHORIZED))
|
||||||
logger.error("Token refresh failed", { error: err });
|
|
||||||
return T.of(SMAPI_FAULT_LOGIN_UNAUTHORIZED);
|
|
||||||
})
|
|
||||||
)();
|
)();
|
||||||
} else {
|
} else {
|
||||||
throw authOrFail.toSmapiFault();
|
throw authOrFail.toSmapiFault();
|
||||||
@@ -575,18 +448,8 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
Sonos: {
|
Sonos: {
|
||||||
SonosSoap: {
|
SonosSoap: {
|
||||||
getAppLink: () => sonosSoap.getAppLink(),
|
getAppLink: () => sonosSoap.getAppLink(),
|
||||||
reportAccountAction: (args: any) =>
|
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
|
||||||
sonosSoap.reportAccountAction(args, undefined),
|
sonosSoap.getDeviceAuthToken({ linkCode }),
|
||||||
getDeviceAuthToken: ({ linkCode }: { linkCode: string}) =>{
|
|
||||||
const deviceAuthTokenResult = sonosSoap.getDeviceAuthToken({ linkCode });
|
|
||||||
const smapiToken:SmapiToken = {
|
|
||||||
token: deviceAuthTokenResult.getDeviceAuthTokenResult.authToken,
|
|
||||||
key: deviceAuthTokenResult.getDeviceAuthTokenResult.privateKey
|
|
||||||
}
|
|
||||||
|
|
||||||
sonosSoap.associateCredentialsForToken(smapiToken.token, smapiToken);
|
|
||||||
return deviceAuthTokenResult;
|
|
||||||
},
|
|
||||||
getLastUpdate: () => ({
|
getLastUpdate: () => ({
|
||||||
getLastUpdateResult: {
|
getLastUpdateResult: {
|
||||||
autoRefreshEnabled: true,
|
autoRefreshEnabled: true,
|
||||||
@@ -595,11 +458,9 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
pollInterval: 60,
|
pollInterval: 60,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders,
|
refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders) => {
|
||||||
{ headers }: Pick<Request, "headers">) => {
|
|
||||||
const creds = useHeaderIfPresent(soapyHeaders?.credentials, headers);
|
|
||||||
const serviceToken = pipe(
|
const serviceToken = pipe(
|
||||||
auth(creds),
|
auth(soapyHeaders?.credentials),
|
||||||
E.fold(
|
E.fold(
|
||||||
(fault) =>
|
(fault) =>
|
||||||
isExpiredTokenError(fault)
|
isExpiredTokenError(fault)
|
||||||
@@ -611,12 +472,9 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
throw fault.toSmapiFault();
|
throw fault.toSmapiFault();
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
// Don't pass old token here to avoid circular reference issues with Jest/SOAP
|
|
||||||
// Old expired tokens will be cleaned up by TTL or manual cleanup later
|
|
||||||
return pipe(
|
return pipe(
|
||||||
musicService.refreshToken(serviceToken),
|
musicService.refreshToken(serviceToken),
|
||||||
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
|
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
|
||||||
TE.tap(swapToken(undefined)), // ignores the return value, like a tee or peek
|
|
||||||
TE.map((it) => ({
|
TE.map((it) => ({
|
||||||
refreshAuthTokenResult: {
|
refreshAuthTokenResult: {
|
||||||
authToken: it.token,
|
authToken: it.token,
|
||||||
@@ -631,10 +489,9 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
getMediaURI: async (
|
getMediaURI: async (
|
||||||
{ id }: { id: string },
|
{ id }: { id: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
{ headers }: Pick<Request, "headers">
|
) =>
|
||||||
) => {
|
login(soapyHeaders?.credentials)
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
.then(({ musicLibrary, credentials, type, typeId }) => {
|
.then(({ musicLibrary, credentials, type, typeId }) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -667,15 +524,13 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
default:
|
default:
|
||||||
throw `Unsupported type:${type}`;
|
throw `Unsupported type:${type}`;
|
||||||
}
|
}
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
getMediaMetadata: async (
|
getMediaMetadata: async (
|
||||||
{ id }: { id: string },
|
{ id }: { id: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
{ headers }: Pick<Request, "headers">
|
) =>
|
||||||
) => {
|
login(soapyHeaders?.credentials)
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
.then(async ({ musicLibrary, apiKey, type, typeId }) => {
|
.then(async ({ musicLibrary, apiKey, type, typeId }) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -690,15 +545,13 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
default:
|
default:
|
||||||
throw `Unsupported type:${type}`;
|
throw `Unsupported type:${type}`;
|
||||||
}
|
}
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
search: async (
|
search: async (
|
||||||
{ id, term }: { id: string; term: string },
|
{ id, term }: { id: string; term: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
{ headers }: Pick<Request, "headers">
|
) =>
|
||||||
) => {
|
login(soapyHeaders?.credentials)
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
.then(async ({ musicLibrary, apiKey }) => {
|
.then(async ({ musicLibrary, apiKey }) => {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
@@ -724,16 +577,15 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
return musicLibrary.searchTracks(term).then((it) =>
|
return musicLibrary.searchTracks(term).then((it) =>
|
||||||
searchResult({
|
searchResult({
|
||||||
count: it.length,
|
count: it.length,
|
||||||
mediaMetadata: it.map((aTrack) =>
|
mediaCollection: it.map((aTrack) =>
|
||||||
track(urlWithToken(apiKey), aTrack)
|
album(urlWithToken(apiKey), aTrack.album)
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
throw `Unsupported search by:${id}`;
|
throw `Unsupported search by:${id}`;
|
||||||
}
|
}
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
getExtendedMetadata: async (
|
getExtendedMetadata: async (
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -742,10 +594,9 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
}: // recursive,
|
}: // recursive,
|
||||||
{ id: string; index: number; count: number; recursive: boolean },
|
{ id: string; index: number; count: number; recursive: boolean },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
{ headers }: Pick<Request, "headers">
|
) =>
|
||||||
) => {
|
login(soapyHeaders?.credentials)
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
.then(async ({ musicLibrary, apiKey, type, typeId }) => {
|
.then(async ({ musicLibrary, apiKey, type, typeId }) => {
|
||||||
const paging = { _index: index, _count: count };
|
const paging = { _index: index, _count: count };
|
||||||
@@ -805,8 +656,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
default:
|
default:
|
||||||
throw `Unsupported getExtendedMetadata id=${id}`;
|
throw `Unsupported getExtendedMetadata id=${id}`;
|
||||||
}
|
}
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
getMetadata: async (
|
getMetadata: async (
|
||||||
{
|
{
|
||||||
id,
|
id,
|
||||||
@@ -817,12 +667,12 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders,
|
||||||
{ headers }: Pick<Request, "headers">
|
{ headers }: Pick<Request, "headers">
|
||||||
) => {
|
) =>
|
||||||
const acceptLanguage = headers["accept-language"];
|
login(soapyHeaders?.credentials)
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
.then(({ musicLibrary, apiKey, type, typeId }) => {
|
.then(({ musicLibrary, apiKey, type, typeId }) => {
|
||||||
const paging = { _index: index, _count: count };
|
const paging = { _index: index, _count: count };
|
||||||
|
const acceptLanguage = headers["accept-language"];
|
||||||
logger.debug(
|
logger.debug(
|
||||||
`Fetching metadata type=${type}, typeId=${typeId}, acceptLanguage=${acceptLanguage}`
|
`Fetching metadata type=${type}, typeId=${typeId}, acceptLanguage=${acceptLanguage}`
|
||||||
);
|
);
|
||||||
@@ -890,12 +740,6 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||||
itemType: "container",
|
itemType: "container",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "years",
|
|
||||||
title: lang("years"),
|
|
||||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
|
||||||
itemType: "container",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "recentlyAdded",
|
id: "recentlyAdded",
|
||||||
title: lang("recentlyAdded"),
|
title: lang("recentlyAdded"),
|
||||||
@@ -973,13 +817,6 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
genre: typeId,
|
genre: typeId,
|
||||||
...paging,
|
...paging,
|
||||||
});
|
});
|
||||||
case "year":
|
|
||||||
return albums({
|
|
||||||
type: "byYear",
|
|
||||||
fromYear: typeId,
|
|
||||||
toYear: typeId,
|
|
||||||
...paging,
|
|
||||||
});
|
|
||||||
case "randomAlbums":
|
case "randomAlbums":
|
||||||
return albums({
|
return albums({
|
||||||
type: "random",
|
type: "random",
|
||||||
@@ -1023,19 +860,6 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
total,
|
total,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
case "years":
|
|
||||||
return musicLibrary
|
|
||||||
.years()
|
|
||||||
.then(slice2(paging))
|
|
||||||
.then(([page, total]) =>
|
|
||||||
getMetadataResult({
|
|
||||||
mediaCollection: page.map((it) =>
|
|
||||||
yyyy(bonobUrl, it)
|
|
||||||
),
|
|
||||||
index: paging._index,
|
|
||||||
total,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
case "genres":
|
case "genres":
|
||||||
return musicLibrary
|
return musicLibrary
|
||||||
.genres()
|
.genres()
|
||||||
@@ -1137,15 +961,13 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
default:
|
default:
|
||||||
throw `Unsupported getMetadata id=${id}`;
|
throw `Unsupported getMetadata id=${id}`;
|
||||||
}
|
}
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
createContainer: async (
|
createContainer: async (
|
||||||
{ title, seedId }: { title: string; seedId: string | undefined },
|
{ title, seedId }: { title: string; seedId: string | undefined },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
{ headers }: Pick<Request, "headers">
|
) =>
|
||||||
) => {
|
login(soapyHeaders?.credentials)
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(({ musicLibrary }) =>
|
.then(({ musicLibrary }) =>
|
||||||
musicLibrary
|
musicLibrary
|
||||||
.createPlaylist(title)
|
.createPlaylist(title)
|
||||||
@@ -1165,38 +987,32 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
id: `playlist:${it.id}`,
|
id: `playlist:${it.id}`,
|
||||||
updateId: "",
|
updateId: "",
|
||||||
},
|
},
|
||||||
}));
|
})),
|
||||||
},
|
|
||||||
deleteContainer: async (
|
deleteContainer: async (
|
||||||
{ id }: { id: string },
|
{ id }: { id: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
{ headers }: Pick<Request, "headers">
|
) =>
|
||||||
) => {
|
login(soapyHeaders?.credentials)
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(({ musicLibrary }) => musicLibrary.deletePlaylist(id))
|
.then(({ musicLibrary }) => musicLibrary.deletePlaylist(id))
|
||||||
.then((_) => ({ deleteContainerResult: {} }));
|
.then((_) => ({ deleteContainerResult: {} })),
|
||||||
},
|
|
||||||
addToContainer: async (
|
addToContainer: async (
|
||||||
{ id, parentId }: { id: string; parentId: string },
|
{ id, parentId }: { id: string; parentId: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
{ headers }: Pick<Request, "headers">
|
) =>
|
||||||
) => {
|
login(soapyHeaders?.credentials)
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
.then(({ musicLibrary, typeId }) =>
|
.then(({ musicLibrary, typeId }) =>
|
||||||
musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId)
|
musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId)
|
||||||
)
|
)
|
||||||
.then((_) => ({ addToContainerResult: { updateId: "" } }));
|
.then((_) => ({ addToContainerResult: { updateId: "" } })),
|
||||||
},
|
|
||||||
removeFromContainer: async (
|
removeFromContainer: async (
|
||||||
{ id, indices }: { id: string; indices: string },
|
{ id, indices }: { id: string; indices: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
{ headers }: Pick<Request, "headers">
|
) =>
|
||||||
) => {
|
login(soapyHeaders?.credentials)
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
.then((it) => ({
|
.then((it) => ({
|
||||||
...it,
|
...it,
|
||||||
@@ -1213,29 +1029,25 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
musicLibrary.removeFromPlaylist(typeId, indices);
|
musicLibrary.removeFromPlaylist(typeId, indices);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.then((_) => ({ removeFromContainerResult: { updateId: "" } }));
|
.then((_) => ({ removeFromContainerResult: { updateId: "" } })),
|
||||||
},
|
|
||||||
rateItem: async (
|
rateItem: async (
|
||||||
{ id, rating }: { id: string; rating: number },
|
{ id, rating }: { id: string; rating: number },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
{ headers }: Pick<Request, "headers">
|
) =>
|
||||||
) => {
|
login(soapyHeaders?.credentials)
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
.then(({ musicLibrary, typeId }) =>
|
.then(({ musicLibrary, typeId }) =>
|
||||||
musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating)))
|
musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating)))
|
||||||
)
|
)
|
||||||
.then((_) => ({ rateItemResult: { shouldSkip: false } }));
|
.then((_) => ({ rateItemResult: { shouldSkip: false } })),
|
||||||
},
|
|
||||||
|
|
||||||
setPlayedSeconds: async (
|
setPlayedSeconds: async (
|
||||||
{ id, seconds }: { id: string; seconds: string },
|
{ id, seconds }: { id: string; seconds: string },
|
||||||
_,
|
_,
|
||||||
soapyHeaders: SoapyHeaders,
|
soapyHeaders: SoapyHeaders
|
||||||
{ headers }: Pick<Request, "headers">
|
) =>
|
||||||
) => {
|
login(soapyHeaders?.credentials)
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(splitId(id))
|
.then(splitId(id))
|
||||||
.then(({ musicLibrary, type, typeId }) => {
|
.then(({ musicLibrary, type, typeId }) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@@ -1257,49 +1069,7 @@ function bindSmapiSoapServiceToExpress(
|
|||||||
})
|
})
|
||||||
.then((_) => ({
|
.then((_) => ({
|
||||||
setPlayedSecondsResult: {},
|
setPlayedSecondsResult: {},
|
||||||
}));
|
})),
|
||||||
},
|
|
||||||
|
|
||||||
reportPlaySeconds: async (
|
|
||||||
{ id, seconds }: { id: string; seconds: string },
|
|
||||||
_,
|
|
||||||
soapyHeaders: SoapyHeaders,
|
|
||||||
{ headers }: Pick<Request, "headers">
|
|
||||||
) => {
|
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(splitId(id))
|
|
||||||
.then(({ type, typeId }) => {
|
|
||||||
if (type === "track") {
|
|
||||||
logger.debug(`reportPlaySeconds called for track ${typeId}, seconds: ${seconds}`);
|
|
||||||
// Return interval of 30 seconds for next update
|
|
||||||
return Promise.resolve(true);
|
|
||||||
}
|
|
||||||
return Promise.resolve(true);
|
|
||||||
})
|
|
||||||
.then((_) => ({
|
|
||||||
reportPlaySecondsResult: { interval: 30 },
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
reportPlayStatus: async (
|
|
||||||
{ id, status }: { id: string; status: string },
|
|
||||||
_,
|
|
||||||
soapyHeaders: SoapyHeaders,
|
|
||||||
{ headers }: Pick<Request, "headers">
|
|
||||||
) => {
|
|
||||||
return login(soapyHeaders?.credentials, headers)
|
|
||||||
.then(splitId(id))
|
|
||||||
.then(({ musicLibrary, type, typeId }) => {
|
|
||||||
if (type === "track") {
|
|
||||||
logger.info(`reportPlayStatus called for track ${typeId}, status: ${status}`);
|
|
||||||
if (status === "PLAY_START" || status === "PAUSED_PLAYBACK") {
|
|
||||||
return musicLibrary.nowPlaying(typeId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Promise.resolve(true);
|
|
||||||
})
|
|
||||||
.then((_) => ({}));
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ import { v4 as uuid } from "uuid";
|
|||||||
import { b64Decode, b64Encode } from "./b64";
|
import { b64Decode, b64Encode } from "./b64";
|
||||||
import { Clock } from "./clock";
|
import { Clock } from "./clock";
|
||||||
|
|
||||||
import logger from "./logger";
|
|
||||||
|
|
||||||
export type SmapiFault = { Fault: { faultcode: string; faultstring: string } };
|
export type SmapiFault = { Fault: { faultcode: string; faultstring: string } };
|
||||||
export type SmapiRefreshTokenResultFault = SmapiFault & {
|
export type SmapiRefreshTokenResultFault = SmapiFault & {
|
||||||
Fault: {
|
Fault: {
|
||||||
@@ -16,7 +14,6 @@ export type SmapiRefreshTokenResultFault = SmapiFault & {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function isError(thing: any): thing is Error {
|
function isError(thing: any): thing is Error {
|
||||||
logger.debug("isError check", { thing });
|
|
||||||
return thing.name && thing.message;
|
return thing.name && thing.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,13 +151,6 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens {
|
|||||||
};
|
};
|
||||||
|
|
||||||
verify = (smapiToken: SmapiToken): E.Either<ToSmapiFault, string> => {
|
verify = (smapiToken: SmapiToken): E.Either<ToSmapiFault, string> => {
|
||||||
logger.debug("Verifying JWT", {
|
|
||||||
token: smapiToken.token,
|
|
||||||
key: smapiToken.key,
|
|
||||||
secret: this.secret,
|
|
||||||
version: this.version,
|
|
||||||
secretKey: this.secret + this.version + smapiToken.key,
|
|
||||||
});
|
|
||||||
try {
|
try {
|
||||||
return E.right(
|
return E.right(
|
||||||
(
|
(
|
||||||
@@ -171,9 +161,7 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens {
|
|||||||
).serviceToken
|
).serviceToken
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e as Error;
|
|
||||||
if (isTokenExpiredError(e)) {
|
if (isTokenExpiredError(e)) {
|
||||||
logger.debug("JWT token expired, will attempt refresh", { expiredAt: (e as TokenExpiredError).expiredAt });
|
|
||||||
const serviceToken = (
|
const serviceToken = (
|
||||||
jwt.verify(
|
jwt.verify(
|
||||||
smapiToken.token,
|
smapiToken.token,
|
||||||
@@ -182,11 +170,8 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens {
|
|||||||
) as any
|
) as any
|
||||||
).serviceToken;
|
).serviceToken;
|
||||||
return E.left(new ExpiredTokenError(serviceToken));
|
return E.left(new ExpiredTokenError(serviceToken));
|
||||||
} else {
|
} else if (isError(e)) return E.left(new InvalidTokenError(e.message));
|
||||||
logger.warn("JWT verification failed - token may be invalid or from different secret", { message: err.message });
|
else return E.left(new InvalidTokenError("Failed to verify token"));
|
||||||
if (isError(e)) return E.left(new InvalidTokenError(err.message));
|
|
||||||
else return E.left(new InvalidTokenError("Failed to verify token"));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,164 +0,0 @@
|
|||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import logger from "./logger";
|
|
||||||
import { SmapiToken, SmapiAuthTokens } from "./smapi_auth";
|
|
||||||
import { either as E } from "fp-ts";
|
|
||||||
|
|
||||||
export interface SmapiTokenStore {
|
|
||||||
get(token: string): SmapiToken | undefined;
|
|
||||||
set(token: string, fullSmapiToken: SmapiToken): void;
|
|
||||||
delete(token: string): void;
|
|
||||||
getAll(): { [tokenKey: string]: SmapiToken };
|
|
||||||
cleanupExpired(smapiAuthTokens: SmapiAuthTokens): number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InMemorySmapiTokenStore implements SmapiTokenStore {
|
|
||||||
private tokens: { [tokenKey: string]: SmapiToken } = {};
|
|
||||||
|
|
||||||
get(token: string): SmapiToken | undefined {
|
|
||||||
return this.tokens[token];
|
|
||||||
}
|
|
||||||
|
|
||||||
set(token: string, fullSmapiToken: SmapiToken): void {
|
|
||||||
this.tokens[token] = fullSmapiToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(token: string): void {
|
|
||||||
delete this.tokens[token];
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(): { [tokenKey: string]: SmapiToken } {
|
|
||||||
return this.tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupExpired(smapiAuthTokens: SmapiAuthTokens): number {
|
|
||||||
const tokenKeys = Object.keys(this.tokens);
|
|
||||||
let deletedCount = 0;
|
|
||||||
|
|
||||||
for (const tokenKey of tokenKeys) {
|
|
||||||
const smapiToken = this.tokens[tokenKey];
|
|
||||||
if (smapiToken) {
|
|
||||||
const verifyResult = smapiAuthTokens.verify(smapiToken);
|
|
||||||
// Only delete if token verification fails with InvalidTokenError
|
|
||||||
// Do NOT delete ExpiredTokenError as those can still be refreshed
|
|
||||||
if (E.isLeft(verifyResult)) {
|
|
||||||
const error = verifyResult.left;
|
|
||||||
// Only delete invalid tokens, not expired ones (which can be refreshed)
|
|
||||||
if (error._tag === 'InvalidTokenError') {
|
|
||||||
logger.debug(`Deleting invalid token from in-memory store`);
|
|
||||||
delete this.tokens[tokenKey];
|
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deletedCount > 0) {
|
|
||||||
logger.info(`Cleaned up ${deletedCount} invalid token(s) from in-memory store`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletedCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class FileSmapiTokenStore implements SmapiTokenStore {
|
|
||||||
private tokens: { [tokenKey: string]: SmapiToken } = {};
|
|
||||||
private readonly filePath: string;
|
|
||||||
|
|
||||||
constructor(filePath: string) {
|
|
||||||
this.filePath = filePath;
|
|
||||||
this.loadFromFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadFromFile(): void {
|
|
||||||
try {
|
|
||||||
// Ensure the directory exists
|
|
||||||
const dir = path.dirname(this.filePath);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
logger.info(`Created token storage directory: ${dir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load existing tokens if file exists
|
|
||||||
if (fs.existsSync(this.filePath)) {
|
|
||||||
const data = fs.readFileSync(this.filePath, "utf8");
|
|
||||||
this.tokens = JSON.parse(data);
|
|
||||||
logger.info(
|
|
||||||
`Loaded ${Object.keys(this.tokens).length} token(s) from ${this.filePath}`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.info(`No existing token file found at ${this.filePath}, starting fresh`);
|
|
||||||
this.tokens = {};
|
|
||||||
this.saveToFile();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to load tokens from ${this.filePath}`, { error });
|
|
||||||
this.tokens = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveToFile(): void {
|
|
||||||
try {
|
|
||||||
// Ensure the directory exists before writing
|
|
||||||
const dir = path.dirname(this.filePath);
|
|
||||||
if (!fs.existsSync(dir)) {
|
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
|
||||||
logger.info(`Created token storage directory: ${dir}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = JSON.stringify(this.tokens, null, 2);
|
|
||||||
fs.writeFileSync(this.filePath, data, "utf8");
|
|
||||||
logger.debug(`Saved ${Object.keys(this.tokens).length} token(s) to ${this.filePath}`);
|
|
||||||
} catch (error) {
|
|
||||||
logger.error(`Failed to save tokens to ${this.filePath}`, { error });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get(token: string): SmapiToken | undefined {
|
|
||||||
return this.tokens[token];
|
|
||||||
}
|
|
||||||
|
|
||||||
set(token: string, fullSmapiToken: SmapiToken): void {
|
|
||||||
this.tokens[token] = fullSmapiToken;
|
|
||||||
this.saveToFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(token: string): void {
|
|
||||||
delete this.tokens[token];
|
|
||||||
this.saveToFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(): { [tokenKey: string]: SmapiToken } {
|
|
||||||
return this.tokens;
|
|
||||||
}
|
|
||||||
|
|
||||||
cleanupExpired(smapiAuthTokens: SmapiAuthTokens): number {
|
|
||||||
const tokenKeys = Object.keys(this.tokens);
|
|
||||||
let deletedCount = 0;
|
|
||||||
|
|
||||||
for (const tokenKey of tokenKeys) {
|
|
||||||
const smapiToken = this.tokens[tokenKey];
|
|
||||||
if (smapiToken) {
|
|
||||||
const verifyResult = smapiAuthTokens.verify(smapiToken);
|
|
||||||
// Only delete if token verification fails with InvalidTokenError
|
|
||||||
// Do NOT delete ExpiredTokenError as those can still be refreshed
|
|
||||||
if (E.isLeft(verifyResult)) {
|
|
||||||
const error = verifyResult.left;
|
|
||||||
// Only delete invalid tokens, not expired ones (which can be refreshed)
|
|
||||||
if (error._tag === 'InvalidTokenError') {
|
|
||||||
logger.debug(`Deleting invalid token from file store`);
|
|
||||||
delete this.tokens[tokenKey];
|
|
||||||
deletedCount++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deletedCount > 0) {
|
|
||||||
logger.info(`Cleaned up ${deletedCount} invalid token(s) from file store`);
|
|
||||||
this.saveToFile();
|
|
||||||
}
|
|
||||||
|
|
||||||
return deletedCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
816
src/subsonic.ts
@@ -22,7 +22,6 @@ import {
|
|||||||
AuthFailure,
|
AuthFailure,
|
||||||
PlaylistSummary,
|
PlaylistSummary,
|
||||||
Encoding,
|
Encoding,
|
||||||
AuthSuccess,
|
|
||||||
} from "./music_service";
|
} from "./music_service";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
import _ from "underscore";
|
import _ from "underscore";
|
||||||
@@ -347,10 +346,6 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
|
|||||||
O.getOrElseW(() => undefined)
|
O.getOrElseW(() => undefined)
|
||||||
);
|
);
|
||||||
|
|
||||||
export const asYear = (year: string) => ({
|
|
||||||
year: year,
|
|
||||||
});
|
|
||||||
|
|
||||||
export interface CustomPlayers {
|
export interface CustomPlayers {
|
||||||
encodingFor({ mimeType }: { mimeType: string }): O.Option<Encoding>
|
encodingFor({ mimeType }: { mimeType: string }): O.Option<Encoding>
|
||||||
}
|
}
|
||||||
@@ -451,7 +446,6 @@ const AlbumQueryTypeToSubsonicType: Record<AlbumQueryType, string> = {
|
|||||||
alphabeticalByArtist: "alphabeticalByArtist",
|
alphabeticalByArtist: "alphabeticalByArtist",
|
||||||
alphabeticalByName: "alphabeticalByName",
|
alphabeticalByName: "alphabeticalByName",
|
||||||
byGenre: "byGenre",
|
byGenre: "byGenre",
|
||||||
byYear: "byYear",
|
|
||||||
random: "random",
|
random: "random",
|
||||||
recentlyPlayed: "recent",
|
recentlyPlayed: "recent",
|
||||||
mostPlayed: "frequent",
|
mostPlayed: "frequent",
|
||||||
@@ -470,453 +464,17 @@ type SubsonicCredentials = Credentials & {
|
|||||||
|
|
||||||
export const asToken = (credentials: SubsonicCredentials) =>
|
export const asToken = (credentials: SubsonicCredentials) =>
|
||||||
b64Encode(JSON.stringify(credentials));
|
b64Encode(JSON.stringify(credentials));
|
||||||
|
|
||||||
export const parseToken = (token: string): SubsonicCredentials =>
|
export const parseToken = (token: string): SubsonicCredentials =>
|
||||||
JSON.parse(b64Decode(token));
|
JSON.parse(b64Decode(token));
|
||||||
|
|
||||||
export class SubsonicMusicLibrary implements MusicLibrary {
|
interface SubsonicMusicLibrary extends MusicLibrary {
|
||||||
subsonic: Subsonic;
|
flavour(): string;
|
||||||
credentials: Credentials
|
bearerToken(
|
||||||
customPlayers: CustomPlayers
|
credentials: Credentials
|
||||||
|
): TE.TaskEither<Error, string | undefined>;
|
||||||
constructor(
|
|
||||||
subsonic: Subsonic,
|
|
||||||
credentials: Credentials,
|
|
||||||
customPlayers: CustomPlayers
|
|
||||||
) {
|
|
||||||
this.subsonic = subsonic
|
|
||||||
this.credentials = credentials
|
|
||||||
this.customPlayers = customPlayers
|
|
||||||
}
|
|
||||||
|
|
||||||
flavour = () => "subsonic"
|
|
||||||
|
|
||||||
bearerToken = (_: Credentials) => TE.right<AuthFailure, string | undefined>(undefined)
|
|
||||||
|
|
||||||
artists = (q: ArtistQuery): Promise<Result<ArtistSummary>> =>
|
|
||||||
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<Artist> =>
|
|
||||||
this.subsonic.getArtistWithInfo(this.credentials, id)
|
|
||||||
|
|
||||||
albums = async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
|
|
||||||
this.subsonic.getAlbumList2(this.credentials, q)
|
|
||||||
|
|
||||||
album = (id: string): Promise<Album> => this.subsonic.getAlbum(this.credentials, id)
|
|
||||||
|
|
||||||
genres = () =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetGenresResponse>(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<GetAlbumResponse>(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((_) => {
|
|
||||||
logger.debug(`Successfully scrobbled track ${id}`);
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
logger.error(`Failed to scrobble track ${id}`, { error: e });
|
|
||||||
return false;
|
|
||||||
})
|
|
||||||
|
|
||||||
nowPlaying = async (id: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON(this.credentials, `/rest/scrobble`, {
|
|
||||||
id,
|
|
||||||
submission: false,
|
|
||||||
})
|
|
||||||
.then((_) => {
|
|
||||||
logger.debug(`Successfully reported now playing for track ${id}`);
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.catch((e) => {
|
|
||||||
logger.error(`Failed to report now playing for track ${id}`, { error: e });
|
|
||||||
return 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<GetPlaylistsResponse>(this.credentials, "/rest/getPlaylists")
|
|
||||||
.then(({ playlists }) => (playlists?.playlist || []).map(asPlayListSummary))
|
|
||||||
|
|
||||||
playlist = async (id: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetPlaylistResponse>(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<GetPlaylistResponse>(this.credentials, "/rest/createPlaylist", {
|
|
||||||
name,
|
|
||||||
})
|
|
||||||
.then(({ playlist }) => ({
|
|
||||||
id: playlist.id,
|
|
||||||
name: playlist.name,
|
|
||||||
coverArt: coverArtURN(playlist.coverArt),
|
|
||||||
}))
|
|
||||||
|
|
||||||
deletePlaylist = async (id: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/deletePlaylist", {
|
|
||||||
id,
|
|
||||||
})
|
|
||||||
.then((_) => true)
|
|
||||||
|
|
||||||
addToPlaylist = async (playlistId: string, trackId: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
|
|
||||||
playlistId,
|
|
||||||
songIdToAdd: trackId,
|
|
||||||
})
|
|
||||||
.then((_) => true)
|
|
||||||
|
|
||||||
removeFromPlaylist = async (playlistId: string, indicies: number[]) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetPlaylistResponse>(this.credentials, "/rest/updatePlaylist", {
|
|
||||||
playlistId,
|
|
||||||
songIndexToRemove: indicies,
|
|
||||||
})
|
|
||||||
.then((_) => true)
|
|
||||||
|
|
||||||
similarSongs = async (id: string) =>
|
|
||||||
this.subsonic
|
|
||||||
.getJSON<GetSimilarSongsResponse>(
|
|
||||||
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<GetTopSongsResponse>(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<GetInternetRadioStationsResponse>(
|
|
||||||
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 {
|
export class Subsonic 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<AuthFailure, AuthSuccess> => {
|
|
||||||
const x: TE.TaskEither<AuthFailure, PingResponse> = TE.tryCatch(
|
|
||||||
() =>
|
|
||||||
this.subsonic.getJSON<PingResponse>(
|
|
||||||
_.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<SubsonicMusicLibrary> => {
|
|
||||||
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;
|
url: URLBuilder;
|
||||||
customPlayers: CustomPlayers;
|
customPlayers: CustomPlayers;
|
||||||
externalImageFetcher: ImageFetcher;
|
externalImageFetcher: ImageFetcher;
|
||||||
@@ -973,6 +531,41 @@ export class Subsonic {
|
|||||||
else return json as unknown as T;
|
else return json as unknown as T;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
generateToken = (credentials: Credentials) =>
|
||||||
|
pipe(
|
||||||
|
TE.tryCatch(
|
||||||
|
() =>
|
||||||
|
this.getJSON<PingResponse>(
|
||||||
|
_.pick(credentials, "username", "password"),
|
||||||
|
"/rest/ping.view"
|
||||||
|
),
|
||||||
|
(e) => new AuthFailure(e as string)
|
||||||
|
),
|
||||||
|
TE.chain(({ type }) =>
|
||||||
|
pipe(
|
||||||
|
TE.tryCatch(
|
||||||
|
() => this.libraryFor({ ...credentials, type }),
|
||||||
|
() => new AuthFailure("Failed to get library")
|
||||||
|
),
|
||||||
|
TE.map((library) => ({ type, library }))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
TE.chain(({ 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));
|
||||||
|
|
||||||
getArtists = (
|
getArtists = (
|
||||||
credentials: Credentials
|
credentials: Credentials
|
||||||
): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> =>
|
): Promise<(IdName & { albumCount: number; image: BUrn | undefined })[]> =>
|
||||||
@@ -1127,8 +720,6 @@ export class Subsonic {
|
|||||||
this.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
|
this.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
|
||||||
type: AlbumQueryTypeToSubsonicType[q.type],
|
type: AlbumQueryTypeToSubsonicType[q.type],
|
||||||
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
|
...(q.genre ? { genre: b64Decode(q.genre) } : {}),
|
||||||
...(q.fromYear ? { fromYear: q.fromYear} : {}),
|
|
||||||
...(q.toYear ? { toYear: q.toYear} : {}),
|
|
||||||
size: 500,
|
size: 500,
|
||||||
offset: q._index,
|
offset: q._index,
|
||||||
})
|
})
|
||||||
@@ -1146,4 +737,329 @@ export class Subsonic {
|
|||||||
// albums: it.album.map(asAlbum),
|
// albums: it.album.map(asAlbum),
|
||||||
// }));
|
// }));
|
||||||
|
|
||||||
|
login = async (token: string) => this.libraryFor(parseToken(token));
|
||||||
|
|
||||||
|
private libraryFor = (
|
||||||
|
credentials: Credentials & { type: string }
|
||||||
|
): Promise<SubsonicMusicLibrary> => {
|
||||||
|
const subsonic = this;
|
||||||
|
|
||||||
|
const genericSubsonic: SubsonicMusicLibrary = {
|
||||||
|
flavour: () => "subsonic",
|
||||||
|
bearerToken: (_: Credentials) => TE.right(undefined),
|
||||||
|
artists: (q: ArtistQuery): Promise<Result<ArtistSummary>> =>
|
||||||
|
subsonic
|
||||||
|
.getArtists(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<Artist> =>
|
||||||
|
subsonic.getArtistWithInfo(credentials, id),
|
||||||
|
albums: async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
|
||||||
|
subsonic.getAlbumList2(credentials, q),
|
||||||
|
album: (id: string): Promise<Album> => subsonic.getAlbum(credentials, id),
|
||||||
|
genres: () =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetGenresResponse>(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) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", {
|
||||||
|
id: albumId,
|
||||||
|
})
|
||||||
|
.then((it) => it.album)
|
||||||
|
.then((album) =>
|
||||||
|
(album.song || []).map((song) => asTrack(asAlbum(album), song, this.customPlayers))
|
||||||
|
),
|
||||||
|
track: (trackId: string) => subsonic.getTrack(credentials, trackId),
|
||||||
|
rate: (trackId: string, rating: Rating) =>
|
||||||
|
Promise.resolve(true)
|
||||||
|
.then(() => {
|
||||||
|
if (rating.stars >= 0 && rating.stars <= 5) {
|
||||||
|
return subsonic.getTrack(credentials, trackId);
|
||||||
|
} else {
|
||||||
|
throw `Invalid rating.stars value of ${rating.stars}`;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then((track) => {
|
||||||
|
const thingsToUpdate = [];
|
||||||
|
if (track.rating.love != rating.love) {
|
||||||
|
thingsToUpdate.push(
|
||||||
|
subsonic.getJSON(
|
||||||
|
credentials,
|
||||||
|
`/rest/${rating.love ? "star" : "unstar"}`,
|
||||||
|
{
|
||||||
|
id: trackId,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (track.rating.stars != rating.stars) {
|
||||||
|
thingsToUpdate.push(
|
||||||
|
subsonic.getJSON(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;
|
||||||
|
}) =>
|
||||||
|
subsonic.getTrack(credentials, trackId).then((track) =>
|
||||||
|
subsonic
|
||||||
|
.get(
|
||||||
|
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) => it.resource.split(":")[1]!)
|
||||||
|
.then((it) => subsonic.getCoverArt(credentials, it, 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) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON(credentials, `/rest/scrobble`, {
|
||||||
|
id,
|
||||||
|
submission: true,
|
||||||
|
})
|
||||||
|
.then((_) => true)
|
||||||
|
.catch(() => false),
|
||||||
|
nowPlaying: async (id: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON(credentials, `/rest/scrobble`, {
|
||||||
|
id,
|
||||||
|
submission: false,
|
||||||
|
})
|
||||||
|
.then((_) => true)
|
||||||
|
.catch(() => false),
|
||||||
|
searchArtists: async (query: string) =>
|
||||||
|
subsonic
|
||||||
|
.search3(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) =>
|
||||||
|
subsonic
|
||||||
|
.search3(credentials, { query, albumCount: 20 })
|
||||||
|
.then(({ albums }) => subsonic.toAlbumSummary(albums)),
|
||||||
|
searchTracks: async (query: string) =>
|
||||||
|
subsonic
|
||||||
|
.search3(credentials, { query, songCount: 20 })
|
||||||
|
.then(({ songs }) =>
|
||||||
|
Promise.all(
|
||||||
|
songs.map((it) => subsonic.getTrack(credentials, it.id))
|
||||||
|
)
|
||||||
|
),
|
||||||
|
playlists: async () =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
|
||||||
|
.then((it) => it.playlists.playlist || [])
|
||||||
|
.then((playlists) => playlists.map(asPlayListSummary)),
|
||||||
|
playlist: async (id: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", {
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
.then((it) => it.playlist)
|
||||||
|
.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) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(credentials, "/rest/createPlaylist", {
|
||||||
|
name,
|
||||||
|
})
|
||||||
|
.then((it) => it.playlist)
|
||||||
|
// todo: why is this line so similar to other playlist lines??
|
||||||
|
.then((it) => ({
|
||||||
|
id: it.id,
|
||||||
|
name: it.name,
|
||||||
|
coverArt: coverArtURN(it.coverArt),
|
||||||
|
})),
|
||||||
|
deletePlaylist: async (id: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", {
|
||||||
|
id,
|
||||||
|
})
|
||||||
|
.then((_) => true),
|
||||||
|
addToPlaylist: async (playlistId: string, trackId: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", {
|
||||||
|
playlistId,
|
||||||
|
songIdToAdd: trackId,
|
||||||
|
})
|
||||||
|
.then((_) => true),
|
||||||
|
removeFromPlaylist: async (playlistId: string, indicies: number[]) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", {
|
||||||
|
playlistId,
|
||||||
|
songIndexToRemove: indicies,
|
||||||
|
})
|
||||||
|
.then((_) => true),
|
||||||
|
similarSongs: async (id: string) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetSimilarSongsResponse>(
|
||||||
|
credentials,
|
||||||
|
"/rest/getSimilarSongs2",
|
||||||
|
{ id, count: 50 }
|
||||||
|
)
|
||||||
|
.then((it) => it.similarSongs2.song || [])
|
||||||
|
.then((songs) =>
|
||||||
|
Promise.all(
|
||||||
|
songs.map((song) =>
|
||||||
|
subsonic
|
||||||
|
.getAlbum(credentials, song.albumId!)
|
||||||
|
.then((album) => asTrack(album, song, this.customPlayers))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
topSongs: async (artistId: string) =>
|
||||||
|
subsonic.getArtist(credentials, artistId).then(({ name }) =>
|
||||||
|
subsonic
|
||||||
|
.getJSON<GetTopSongsResponse>(credentials, "/rest/getTopSongs", {
|
||||||
|
artist: name,
|
||||||
|
count: 50,
|
||||||
|
})
|
||||||
|
.then((it) => it.topSongs.song || [])
|
||||||
|
.then((songs) =>
|
||||||
|
Promise.all(
|
||||||
|
songs.map((song) =>
|
||||||
|
subsonic
|
||||||
|
.getAlbum(credentials, song.albumId!)
|
||||||
|
.then((album) => asTrack(album, song, this.customPlayers))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
),
|
||||||
|
radioStations: async () => subsonic
|
||||||
|
.getJSON<GetInternetRadioStationsResponse>(
|
||||||
|
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) => genericSubsonic
|
||||||
|
.radioStations()
|
||||||
|
.then(it =>
|
||||||
|
it.find(station => station.id === id)!
|
||||||
|
),
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
if (credentials.type == "navidrome") {
|
||||||
|
// todo: there does not seem to be a test for this??
|
||||||
|
return Promise.resolve({
|
||||||
|
...genericSubsonic,
|
||||||
|
flavour: () => "navidrome",
|
||||||
|
bearerToken: (credentials: Credentials) =>
|
||||||
|
pipe(
|
||||||
|
TE.tryCatch(
|
||||||
|
() =>
|
||||||
|
axios.post(
|
||||||
|
this.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)
|
||||||
|
),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
return Promise.resolve(genericSubsonic);
|
||||||
|
}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/utils.ts
@@ -1,34 +1,7 @@
|
|||||||
import { DOMParser, XMLSerializer, Node } from '@xmldom/xmldom';
|
|
||||||
|
|
||||||
export function takeWithRepeats<T>(things:T[], count: number) {
|
export function takeWithRepeats<T>(things:T[], count: number) {
|
||||||
const result = [];
|
const result = [];
|
||||||
for(let i = 0; i < count; i++) {
|
for(let i = 0; i < count; i++) {
|
||||||
result.push(things[i % things.length])
|
result.push(things[i % things.length])
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function xmlRemoveWhitespaceNodes(node: Node) {
|
|
||||||
let child = node.firstChild;
|
|
||||||
while (child) {
|
|
||||||
const nextSibling = child.nextSibling;
|
|
||||||
if (child.nodeType === 3 && !child.nodeValue?.trim()) {
|
|
||||||
// Remove empty text nodes
|
|
||||||
node.removeChild(child);
|
|
||||||
} else {
|
|
||||||
// Recursively process child nodes
|
|
||||||
xmlRemoveWhitespaceNodes(child);
|
|
||||||
}
|
|
||||||
child = nextSibling;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function xmlTidy(xml: string | Node) {
|
|
||||||
const xmlToString = new XMLSerializer().serializeToString
|
|
||||||
|
|
||||||
const xmlString = xml instanceof Node ? xmlToString(xml as any) : xml
|
|
||||||
const doc = new DOMParser().parseFromString(xmlString, 'text/xml') as unknown as Node;
|
|
||||||
xmlRemoveWhitespaceNodes(doc);
|
|
||||||
return xmlToString(doc as any);
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
Playlist,
|
Playlist,
|
||||||
SimilarArtist,
|
SimilarArtist,
|
||||||
AlbumSummary,
|
AlbumSummary,
|
||||||
RadioStation
|
RadioStation,
|
||||||
} from "../src/music_service";
|
} from "../src/music_service";
|
||||||
|
|
||||||
import { b64Encode } from "../src/b64";
|
import { b64Encode } from "../src/b64";
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { left, right } from 'fp-ts/Either'
|
|
||||||
|
|
||||||
import { cryptoEncryption, jwsEncryption } from '../src/encryption';
|
import { cryptoEncryption, jwsEncryption } from '../src/encryption';
|
||||||
|
|
||||||
describe("jwsEncryption", () => {
|
describe("jwsEncryption", () => {
|
||||||
@@ -9,7 +7,7 @@ describe("jwsEncryption", () => {
|
|||||||
const value = "bobs your uncle"
|
const value = "bobs your uncle"
|
||||||
const hash = e.encrypt(value)
|
const hash = e.encrypt(value)
|
||||||
expect(hash).not.toContain(value);
|
expect(hash).not.toContain(value);
|
||||||
expect(e.decrypt(hash)).toEqual(right(value));
|
expect(e.decrypt(hash)).toEqual(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns different values for different secrets", () => {
|
it("returns different values for different secrets", () => {
|
||||||
@@ -31,7 +29,7 @@ describe("cryptoEncryption", () => {
|
|||||||
const value = "bobs your uncle"
|
const value = "bobs your uncle"
|
||||||
const hash = e.encrypt(value)
|
const hash = e.encrypt(value)
|
||||||
expect(hash).not.toContain(value);
|
expect(hash).not.toContain(value);
|
||||||
expect(e.decrypt(hash)).toEqual(right(value));
|
expect(e.decrypt(hash)).toEqual(value);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns different values for different secrets", () => {
|
it("returns different values for different secrets", () => {
|
||||||
@@ -44,10 +42,4 @@ describe("cryptoEncryption", () => {
|
|||||||
|
|
||||||
expect(h1).not.toEqual(h2);
|
expect(h1).not.toEqual(h2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("should return left on invalid value", () => {
|
|
||||||
const e = cryptoEncryption("secret squirrel");
|
|
||||||
|
|
||||||
expect(e.decrypt("not-valid")).toEqual(left("Invalid value to decrypt"));
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
|
import libxmljs from "libxmljs2";
|
||||||
import { FixedClock } from "../src/clock";
|
import { FixedClock } from "../src/clock";
|
||||||
import { xmlTidy } from "../src/utils";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
contains,
|
contains,
|
||||||
@@ -20,17 +20,17 @@ import {
|
|||||||
allOf,
|
allOf,
|
||||||
features,
|
features,
|
||||||
STAR_WARS,
|
STAR_WARS,
|
||||||
NO_FEATURES,
|
|
||||||
} from "../src/icon";
|
} from "../src/icon";
|
||||||
|
|
||||||
describe("SvgIcon", () => {
|
describe("SvgIcon", () => {
|
||||||
|
const xmlTidy = (xml: string) =>
|
||||||
|
libxmljs.parseXmlString(xml, { noblanks: true, net: false }).toString();
|
||||||
|
|
||||||
const svgIcon24 = `<?xml version="1.0" encoding="UTF-8"?>
|
const svgIcon24 = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
<path d="path1"/>
|
<path d="path1"/>
|
||||||
<path d="path2" fill="none" stroke="#000"/>
|
<path d="path2" fill="none" stroke="#000"/>
|
||||||
<text font-size="25" fill="none">80's</text>
|
|
||||||
<path d="path3"/>
|
<path d="path3"/>
|
||||||
<text font-size="25">80's</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -61,9 +61,7 @@ describe("SvgIcon", () => {
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32">
|
||||||
<path d="path1"/>
|
<path d="path1"/>
|
||||||
<path d="path2" fill="none" stroke="#000"/>
|
<path d="path2" fill="none" stroke="#000"/>
|
||||||
<text font-size="25" fill="none">80's</text>
|
|
||||||
<path d="path3"/>
|
<path d="path3"/>
|
||||||
<text font-size="25">80's</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
@@ -112,9 +110,7 @@ describe("SvgIcon", () => {
|
|||||||
<rect x="0" y="0" width="24" height="24" fill="red"/>
|
<rect x="0" y="0" width="24" height="24" fill="red"/>
|
||||||
<path d="path1"/>
|
<path d="path1"/>
|
||||||
<path d="path2" fill="none" stroke="#000"/>
|
<path d="path2" fill="none" stroke="#000"/>
|
||||||
<text font-size="25" fill="none">80's</text>
|
|
||||||
<path d="path3"/>
|
<path d="path3"/>
|
||||||
<text font-size="25">80's</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
@@ -138,9 +134,7 @@ describe("SvgIcon", () => {
|
|||||||
<rect x="-4" y="-4" width="36" height="36" fill="pink"/>
|
<rect x="-4" y="-4" width="36" height="36" fill="pink"/>
|
||||||
<path d="path1"/>
|
<path d="path1"/>
|
||||||
<path d="path2" fill="none" stroke="#000"/>
|
<path d="path2" fill="none" stroke="#000"/>
|
||||||
<text font-size="25" fill="none">80's</text>
|
|
||||||
<path d="path3"/>
|
<path d="path3"/>
|
||||||
<text font-size="25">80's</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
@@ -158,9 +152,7 @@ describe("SvgIcon", () => {
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
<path d="path1"/>
|
<path d="path1"/>
|
||||||
<path d="path2" fill="none" stroke="#000"/>
|
<path d="path2" fill="none" stroke="#000"/>
|
||||||
<text font-size="25" fill="none">80's</text>
|
|
||||||
<path d="path3"/>
|
<path d="path3"/>
|
||||||
<text font-size="25">80's</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
@@ -180,9 +172,7 @@ describe("SvgIcon", () => {
|
|||||||
<rect x="0" y="0" width="24" height="24" fill="red"/>
|
<rect x="0" y="0" width="24" height="24" fill="red"/>
|
||||||
<path d="path1"/>
|
<path d="path1"/>
|
||||||
<path d="path2" fill="none" stroke="#000"/>
|
<path d="path2" fill="none" stroke="#000"/>
|
||||||
<text font-size="25" fill="none">80's</text>
|
|
||||||
<path d="path3"/>
|
<path d="path3"/>
|
||||||
<text font-size="25">80's</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
@@ -192,7 +182,7 @@ describe("SvgIcon", () => {
|
|||||||
|
|
||||||
describe("foreground color", () => {
|
describe("foreground color", () => {
|
||||||
describe("with no viewPort increase", () => {
|
describe("with no viewPort increase", () => {
|
||||||
it("should change the fill values", () => {
|
it("should add a rectangle the same size as the original viewPort", () => {
|
||||||
expect(
|
expect(
|
||||||
new SvgIcon(svgIcon24)
|
new SvgIcon(svgIcon24)
|
||||||
.with({ features: { foregroundColor: "red" } })
|
.with({ features: { foregroundColor: "red" } })
|
||||||
@@ -202,9 +192,7 @@ describe("SvgIcon", () => {
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
<path d="path1" fill="red"/>
|
<path d="path1" fill="red"/>
|
||||||
<path d="path2" fill="none" stroke="red"/>
|
<path d="path2" fill="none" stroke="red"/>
|
||||||
<text font-size="25" fill="none" stroke="red">80's</text>
|
|
||||||
<path d="path3" fill="red"/>
|
<path d="path3" fill="red"/>
|
||||||
<text font-size="25" fill="red">80's</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
@@ -212,7 +200,7 @@ describe("SvgIcon", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("with a viewPort increase", () => {
|
describe("with a viewPort increase", () => {
|
||||||
it("should change the fill values", () => {
|
it("should add a rectangle the same size as the original viewPort", () => {
|
||||||
expect(
|
expect(
|
||||||
new SvgIcon(svgIcon24)
|
new SvgIcon(svgIcon24)
|
||||||
.with({
|
.with({
|
||||||
@@ -227,9 +215,7 @@ describe("SvgIcon", () => {
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-4 -4 32 32">
|
||||||
<path d="path1" fill="pink"/>
|
<path d="path1" fill="pink"/>
|
||||||
<path d="path2" fill="none" stroke="pink"/>
|
<path d="path2" fill="none" stroke="pink"/>
|
||||||
<text font-size="25" fill="none" stroke="pink">80's</text>
|
|
||||||
<path d="path3" fill="pink"/>
|
<path d="path3" fill="pink"/>
|
||||||
<text font-size="25" fill="pink">80's</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
@@ -247,9 +233,7 @@ describe("SvgIcon", () => {
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
<path d="path1"/>
|
<path d="path1"/>
|
||||||
<path d="path2" fill="none" stroke="#000"/>
|
<path d="path2" fill="none" stroke="#000"/>
|
||||||
<text font-size="25" fill="none">80's</text>
|
|
||||||
<path d="path3"/>
|
<path d="path3"/>
|
||||||
<text font-size="25">80's</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
@@ -268,9 +252,7 @@ describe("SvgIcon", () => {
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
||||||
<path d="path1" fill="red"/>
|
<path d="path1" fill="red"/>
|
||||||
<path d="path2" fill="none" stroke="red"/>
|
<path d="path2" fill="none" stroke="red"/>
|
||||||
<text font-size="25" fill="none" stroke="red">80's</text>
|
|
||||||
<path d="path3" fill="red"/>
|
<path d="path3" fill="red"/>
|
||||||
<text font-size="25" fill="red">80's</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
`)
|
`)
|
||||||
);
|
);
|
||||||
@@ -278,48 +260,6 @@ describe("SvgIcon", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("text", () => {
|
|
||||||
describe("when text value specified", () => {
|
|
||||||
it("should change the text values", () => {
|
|
||||||
expect(
|
|
||||||
new SvgIcon(svgIcon24)
|
|
||||||
.with({ features: { text: "yipppeeee" } })
|
|
||||||
.toString()
|
|
||||||
).toEqual(
|
|
||||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path d="path1"/>
|
|
||||||
<path d="path2" fill="none" stroke="#000"/>
|
|
||||||
<text font-size="25" fill="none">yipppeeee</text>
|
|
||||||
<path d="path3"/>
|
|
||||||
<text font-size="25">yipppeeee</text>
|
|
||||||
</svg>
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("of undefined", () => {
|
|
||||||
it("should not do anything", () => {
|
|
||||||
expect(
|
|
||||||
new SvgIcon(svgIcon24)
|
|
||||||
.with({ features: { text: undefined } })
|
|
||||||
.toString()
|
|
||||||
).toEqual(
|
|
||||||
xmlTidy(`<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
|
|
||||||
<path d="path1"/>
|
|
||||||
<path d="path2" fill="none" stroke="#000"/>
|
|
||||||
<text font-size="25" fill="none">80's</text>
|
|
||||||
<path d="path3"/>
|
|
||||||
<text font-size="25">80's</text>
|
|
||||||
</svg>
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("swapping the svg", () => {
|
describe("swapping the svg", () => {
|
||||||
describe("with no other changes", () => {
|
describe("with no other changes", () => {
|
||||||
it("should swap out the svg, but maintain the IconFeatures", () => {
|
it("should swap out the svg, but maintain the IconFeatures", () => {
|
||||||
@@ -378,14 +318,10 @@ describe("SvgIcon", () => {
|
|||||||
|
|
||||||
class DummyIcon implements Icon {
|
class DummyIcon implements Icon {
|
||||||
svg: string;
|
svg: string;
|
||||||
features: IconFeatures;
|
features: Partial<IconFeatures>;
|
||||||
|
|
||||||
constructor(svg: string, features: Partial<IconFeatures>) {
|
constructor(svg: string, features: Partial<IconFeatures>) {
|
||||||
this.svg = svg;
|
this.svg = svg;
|
||||||
this.features = {
|
this.features = features;
|
||||||
...NO_FEATURES,
|
|
||||||
...features
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public apply = (transformer: Transformer): Icon => transformer(this);
|
public apply = (transformer: Transformer): Icon => transformer(this);
|
||||||
@@ -414,7 +350,6 @@ describe("transform", () => {
|
|||||||
viewPortIncreasePercent: 100,
|
viewPortIncreasePercent: 100,
|
||||||
foregroundColor: "blue",
|
foregroundColor: "blue",
|
||||||
backgroundColor: "blue",
|
backgroundColor: "blue",
|
||||||
text: "a",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.apply(
|
.apply(
|
||||||
@@ -422,7 +357,6 @@ describe("transform", () => {
|
|||||||
features: {
|
features: {
|
||||||
foregroundColor: "override1",
|
foregroundColor: "override1",
|
||||||
backgroundColor: "override2",
|
backgroundColor: "override2",
|
||||||
text: "b",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
) as DummyIcon;
|
) as DummyIcon;
|
||||||
@@ -432,7 +366,6 @@ describe("transform", () => {
|
|||||||
viewPortIncreasePercent: 100,
|
viewPortIncreasePercent: 100,
|
||||||
foregroundColor: "override1",
|
foregroundColor: "override1",
|
||||||
backgroundColor: "override2",
|
backgroundColor: "override2",
|
||||||
text: "b",
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -449,7 +382,6 @@ describe("transform", () => {
|
|||||||
viewPortIncreasePercent: 100,
|
viewPortIncreasePercent: 100,
|
||||||
foregroundColor: "blue",
|
foregroundColor: "blue",
|
||||||
backgroundColor: "blue",
|
backgroundColor: "blue",
|
||||||
text: "bob",
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
.apply(
|
.apply(
|
||||||
@@ -463,7 +395,6 @@ describe("transform", () => {
|
|||||||
viewPortIncreasePercent: 100,
|
viewPortIncreasePercent: 100,
|
||||||
foregroundColor: "blue",
|
foregroundColor: "blue",
|
||||||
backgroundColor: "blue",
|
backgroundColor: "blue",
|
||||||
text: "bob"
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -480,7 +411,6 @@ describe("features", () => {
|
|||||||
viewPortIncreasePercent: 100,
|
viewPortIncreasePercent: 100,
|
||||||
foregroundColor: "blue",
|
foregroundColor: "blue",
|
||||||
backgroundColor: "blue",
|
backgroundColor: "blue",
|
||||||
text: "foobar"
|
|
||||||
})
|
})
|
||||||
) as DummyIcon;
|
) as DummyIcon;
|
||||||
|
|
||||||
@@ -488,7 +418,6 @@ describe("features", () => {
|
|||||||
viewPortIncreasePercent: 100,
|
viewPortIncreasePercent: 100,
|
||||||
foregroundColor: "blue",
|
foregroundColor: "blue",
|
||||||
backgroundColor: "blue",
|
backgroundColor: "blue",
|
||||||
text: "foobar"
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ export class InMemoryMusicService implements MusicService {
|
|||||||
topSongs: async (_: string) => Promise.resolve([]),
|
topSongs: async (_: string) => Promise.resolve([]),
|
||||||
radioStations: async () => Promise.resolve([]),
|
radioStations: async () => Promise.resolve([]),
|
||||||
radioStation: async (_: string) => Promise.reject("Unsupported operation"),
|
radioStation: async (_: string) => Promise.reject("Unsupported operation"),
|
||||||
years: async () => Promise.resolve([]),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1366,25 +1366,11 @@ describe("server", () => {
|
|||||||
"..%2F..%2Ffoo",
|
"..%2F..%2Ffoo",
|
||||||
"%2Fetc%2Fpasswd",
|
"%2Fetc%2Fpasswd",
|
||||||
".%2Fbob.js",
|
".%2Fbob.js",
|
||||||
"%23%24",
|
".",
|
||||||
].forEach((type) => {
|
"..",
|
||||||
describe(`trying to retrieve an icon with name ${type}`, () => {
|
|
||||||
it(`should fail`, async () => {
|
|
||||||
const response = await request(server()).get(
|
|
||||||
`/icon/${type}/size/legacy`
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toEqual(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("missing icons", () => {
|
|
||||||
[
|
|
||||||
"1",
|
"1",
|
||||||
|
"%23%24",
|
||||||
"notAValidIcon",
|
"notAValidIcon",
|
||||||
"notAValidIcon:withSomeText"
|
|
||||||
].forEach((type) => {
|
].forEach((type) => {
|
||||||
describe(`trying to retrieve an icon with name ${type}`, () => {
|
describe(`trying to retrieve an icon with name ${type}`, () => {
|
||||||
it(`should fail`, async () => {
|
it(`should fail`, async () => {
|
||||||
@@ -1412,20 +1398,6 @@ describe("server", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("invalid text", () => {
|
|
||||||
["..", "foobar.123", "_dog_", "{ whoop }"].forEach((text) => {
|
|
||||||
describe(`trying to retrieve an icon with text ${text}`, () => {
|
|
||||||
it(`should fail`, async () => {
|
|
||||||
const response = await request(server()).get(
|
|
||||||
`/icon/yyyy:${text}/size/60`
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toEqual(400);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("fetching", () => {
|
describe("fetching", () => {
|
||||||
[
|
[
|
||||||
"artists",
|
"artists",
|
||||||
@@ -1555,41 +1527,6 @@ describe("server", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("specifing some text", () => {
|
|
||||||
const text = "somethingWicked"
|
|
||||||
|
|
||||||
describe(`legacy icon`, () => {
|
|
||||||
it("should return the png image", async () => {
|
|
||||||
const response = await request(server()).get(
|
|
||||||
`/icon/yyyy:${text}/size/legacy`
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
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`
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toEqual(200);
|
|
||||||
expect(response.header["content-type"]).toEqual(
|
|
||||||
"image/svg+xml; charset=utf-8"
|
|
||||||
);
|
|
||||||
const svg = Buffer.from(response.body).toString();
|
|
||||||
expect(svg).toContain(
|
|
||||||
`>${text}</text>`
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -559,24 +559,6 @@ describe("coverArtURI", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("iconArtURI", () => {
|
|
||||||
const bonobUrl = new URLBuilder(
|
|
||||||
"http://bonob.example.com:8080/context?search=yes"
|
|
||||||
);
|
|
||||||
|
|
||||||
describe("with no text", () => {
|
|
||||||
it("should return just the icon uri", () => {
|
|
||||||
expect(iconArtURI(bonobUrl, "mushroom").href()).toEqual("http://bonob.example.com:8080/context/icon/mushroom/size/legacy?search=yes")
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("with text", () => {
|
|
||||||
it("should return just the icon uri", () => {
|
|
||||||
expect(iconArtURI(bonobUrl, "yyyy", "foobar10000").href()).toEqual("http://bonob.example.com:8080/context/icon/yyyy:foobar10000/size/legacy?search=yes")
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("wsdl api", () => {
|
describe("wsdl api", () => {
|
||||||
const musicService = {
|
const musicService = {
|
||||||
generateToken: jest.fn(),
|
generateToken: jest.fn(),
|
||||||
@@ -593,8 +575,6 @@ describe("wsdl api", () => {
|
|||||||
artists: jest.fn(),
|
artists: jest.fn(),
|
||||||
artist: jest.fn(),
|
artist: jest.fn(),
|
||||||
genres: jest.fn(),
|
genres: jest.fn(),
|
||||||
years: jest.fn(),
|
|
||||||
year: jest.fn(),
|
|
||||||
playlists: jest.fn(),
|
playlists: jest.fn(),
|
||||||
playlist: jest.fn(),
|
playlist: jest.fn(),
|
||||||
album: jest.fn(),
|
album: jest.fn(),
|
||||||
@@ -984,8 +964,8 @@ describe("wsdl api", () => {
|
|||||||
});
|
});
|
||||||
expect(result[0]).toEqual(
|
expect(result[0]).toEqual(
|
||||||
searchResult({
|
searchResult({
|
||||||
mediaMetadata: tracks.map((it) =>
|
mediaCollection: tracks.map((it) =>
|
||||||
track(bonobUrlWithAccessToken, it)
|
album(bonobUrlWithAccessToken, it.album)
|
||||||
),
|
),
|
||||||
index: 0,
|
index: 0,
|
||||||
total: 2,
|
total: 2,
|
||||||
@@ -1173,12 +1153,6 @@ describe("wsdl api", () => {
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||||
itemType: "container",
|
itemType: "container",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "years",
|
|
||||||
title: "Years",
|
|
||||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
|
||||||
itemType: "container",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "recentlyAdded",
|
id: "recentlyAdded",
|
||||||
title: "Recently added",
|
title: "Recently added",
|
||||||
@@ -1273,12 +1247,6 @@ describe("wsdl api", () => {
|
|||||||
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
albumArtURI: iconArtURI(bonobUrl, "genres").href(),
|
||||||
itemType: "container",
|
itemType: "container",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "years",
|
|
||||||
title: "Jaren",
|
|
||||||
albumArtURI: iconArtURI(bonobUrl, "music").href(),
|
|
||||||
itemType: "container",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: "recentlyAdded",
|
id: "recentlyAdded",
|
||||||
title: "Onlangs toegevoegd",
|
title: "Onlangs toegevoegd",
|
||||||
@@ -1356,7 +1324,7 @@ describe("wsdl api", () => {
|
|||||||
expect(result[0]).toEqual(
|
expect(result[0]).toEqual(
|
||||||
getMetadataResult({
|
getMetadataResult({
|
||||||
mediaCollection: expectedGenres.map((genre) => ({
|
mediaCollection: expectedGenres.map((genre) => ({
|
||||||
itemType: "albumList",
|
itemType: "container",
|
||||||
id: `genre:${genre.id}`,
|
id: `genre:${genre.id}`,
|
||||||
title: genre.name,
|
title: genre.name,
|
||||||
albumArtURI: iconArtURI(
|
albumArtURI: iconArtURI(
|
||||||
@@ -1381,7 +1349,7 @@ describe("wsdl api", () => {
|
|||||||
expect(result[0]).toEqual(
|
expect(result[0]).toEqual(
|
||||||
getMetadataResult({
|
getMetadataResult({
|
||||||
mediaCollection: [PUNK, ROCK].map((genre) => ({
|
mediaCollection: [PUNK, ROCK].map((genre) => ({
|
||||||
itemType: "albumList",
|
itemType: "container",
|
||||||
id: `genre:${genre.id}`,
|
id: `genre:${genre.id}`,
|
||||||
title: genre.name,
|
title: genre.name,
|
||||||
albumArtURI: iconArtURI(
|
albumArtURI: iconArtURI(
|
||||||
@@ -1397,70 +1365,6 @@ describe("wsdl api", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("asking for a year", () => {
|
|
||||||
const expectedYears = [{ year: "?" }, { year: "1969" }, { year: "1980" }, { year: "2001" }, { year: "2010" }];
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
musicLibrary.years.mockResolvedValue(expectedYears);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("asking for all years", () => {
|
|
||||||
it("should return a collection of years", async () => {
|
|
||||||
const result = await ws.getMetadataAsync({
|
|
||||||
id: `years`,
|
|
||||||
index: 0,
|
|
||||||
count: 100,
|
|
||||||
});
|
|
||||||
const albumListForYear = (year: string, icon: URLBuilder) => ({
|
|
||||||
itemType: "albumList",
|
|
||||||
id: `year:${year}`,
|
|
||||||
title: year,
|
|
||||||
albumArtURI: icon.href(),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result[0]).toEqual(
|
|
||||||
getMetadataResult({
|
|
||||||
mediaCollection: [
|
|
||||||
albumListForYear("?", iconArtURI(bonobUrl, "music")),
|
|
||||||
albumListForYear("1969", iconArtURI(bonobUrl, "yyyy", "1969")),
|
|
||||||
albumListForYear("1980", iconArtURI(bonobUrl, "yyyy", "1980")),
|
|
||||||
albumListForYear("2001", iconArtURI(bonobUrl, "yyyy", "2001")),
|
|
||||||
albumListForYear("2010", iconArtURI(bonobUrl, "yyyy", "2010")),
|
|
||||||
],
|
|
||||||
index: 0,
|
|
||||||
total: expectedYears.length,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("asking for a page of years", () => {
|
|
||||||
it("should return just that page", async () => {
|
|
||||||
const result = await ws.getMetadataAsync({
|
|
||||||
id: `years`,
|
|
||||||
index: 2,
|
|
||||||
count: 2,
|
|
||||||
});
|
|
||||||
expect(result[0]).toEqual(
|
|
||||||
getMetadataResult({
|
|
||||||
mediaCollection: [{ year: "1980" }, { year: "2001" }].map((year) => ({
|
|
||||||
itemType: "albumList",
|
|
||||||
id: `year:${year.year}`,
|
|
||||||
title: year.year,
|
|
||||||
albumArtURI: iconArtURI(
|
|
||||||
bonobUrl,
|
|
||||||
"yyyy",
|
|
||||||
year.year
|
|
||||||
).href(),
|
|
||||||
})),
|
|
||||||
index: 2,
|
|
||||||
total: expectedYears.length,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("asking for playlists", () => {
|
describe("asking for playlists", () => {
|
||||||
const playlist1 = aPlaylist({ id: "1", name: "pl1", entries: []});
|
const playlist1 = aPlaylist({ id: "1", name: "pl1", entries: []});
|
||||||
const playlist2 = aPlaylist({ id: "2", name: "pl2", entries: []});
|
const playlist2 = aPlaylist({ id: "2", name: "pl2", entries: []});
|
||||||
|
|||||||
8
web/icons/bob.svg
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||||
|
<style>
|
||||||
|
.txt {
|
||||||
|
font: bold 13px helvetica;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<text y="20%" class="txt" textLength="80%" lengthAdjust="spacingAndGlyphs">80s</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 226 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<text x="50" y="75" font-size="65" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold">80s</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 189 B |
@@ -1,3 +0,0 @@
|
|||||||
<svg viewBox="0 0 100 100" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<text x="50" y="65" font-size="35" text-anchor="middle" font-family="Arial, sans-serif" font-weight="bold">1980</text>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 190 B |