Compare commits
14 Commits
c122b9ac90
...
feature/no
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6897397c28 | ||
|
|
3a14b62de4 | ||
|
|
9e5df22701 | ||
|
|
e29d5c5d24 | ||
|
|
b97590dd36 | ||
|
|
b0dc11abcb | ||
|
|
5009732da2 | ||
|
|
ddde55d02b | ||
|
|
0602e1f077 | ||
|
|
7eeedff040 | ||
|
|
0451c3a931 | ||
|
|
cc0dc3704d | ||
|
|
dabb7d0f12 | ||
|
|
a38ca831df |
@@ -1,4 +1,4 @@
|
||||
FROM node:22-bullseye
|
||||
FROM node:23-bullseye
|
||||
|
||||
LABEL maintainer=simojenki
|
||||
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
@@ -47,19 +47,21 @@ jobs:
|
||||
-
|
||||
name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
simojenki/bonob
|
||||
ghcr.io/simojenki/bonob
|
||||
-
|
||||
name: Login to DockerHub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
-
|
||||
name: Log in to GitHub Container registry
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ghcr.io
|
||||
@@ -67,10 +69,10 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
-
|
||||
name: Push image
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm/v7,linux/arm64
|
||||
push: true
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
|
||||
5
.gitignore
vendored
@@ -10,7 +10,4 @@ node_modules
|
||||
!.yarn/plugins
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
.pnp.*
|
||||
log.txt
|
||||
navidrome.txt
|
||||
bonob.txt
|
||||
.pnp.*
|
||||
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
|
||||
12
Dockerfile
@@ -1,4 +1,4 @@
|
||||
FROM node:22-trixie-slim AS build
|
||||
FROM node:23-bullseye-slim AS build
|
||||
|
||||
WORKDIR /bonob
|
||||
|
||||
@@ -36,12 +36,12 @@ RUN apt-get update && \
|
||||
NODE_ENV=production npm install --omit=dev
|
||||
|
||||
|
||||
FROM node:22-trixie-slim
|
||||
FROM node:23-bullseye-slim
|
||||
|
||||
LABEL maintainer="simojenki" \
|
||||
org.opencontainers.image.source="https://github.com/simojenki/bonob" \
|
||||
org.opencontainers.image.description="bonob SONOS SMAPI implementation" \
|
||||
org.opencontainers.image.licenses="GPLv3"
|
||||
LABEL maintainer="simojenki" \
|
||||
org.opencontainers.image.source="https://github.com/simojenki/bonob" \
|
||||
org.opencontainers.image.description="bonob SONOS SMAPI implementation" \
|
||||
org.opencontainers.image.licenses="GPLv3"
|
||||
|
||||
ENV BNB_PORT=4534
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
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
|
||||
```
|
||||
25
package-lock.json
generated
@@ -3024,6 +3024,31 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
|
||||
@@ -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 |
@@ -6,19 +6,18 @@ import logger from "./logger";
|
||||
import {
|
||||
axiosImageFetcher,
|
||||
cachingImageFetcher,
|
||||
SubsonicMusicService,
|
||||
TranscodingCustomPlayers,
|
||||
NO_CUSTOM_PLAYERS,
|
||||
Subsonic
|
||||
} from "./subsonic";
|
||||
import { SubsonicMusicService} from "./subsonic_music_library";
|
||||
import { InMemoryAPITokens, sha256 } from "./api_tokens";
|
||||
import { InMemoryLinkCodes } from "./link_codes";
|
||||
import readConfig from "./config";
|
||||
import sonos, { bonobService } from "./sonos";
|
||||
import { MusicService } from "./music_service";
|
||||
import { MusicService } from "./music_library";
|
||||
import { SystemClock } from "./clock";
|
||||
import { JWTSmapiLoginTokens } from "./smapi_auth";
|
||||
import { FileSmapiTokenStore } from "./smapi_token_store";
|
||||
|
||||
const config = readConfig();
|
||||
const clock = SystemClock;
|
||||
@@ -96,8 +95,7 @@ const app = server(
|
||||
logRequests: config.logRequests,
|
||||
version,
|
||||
smapiAuthTokens: new JWTSmapiLoginTokens(clock, config.secret, config.authTimeout),
|
||||
externalImageResolver: artistImageFetcher,
|
||||
smapiTokenStore: new FileSmapiTokenStore("/config/tokens.json")
|
||||
externalImageResolver: artistImageFetcher
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -23,7 +23,8 @@ export type ArtistSummary = {
|
||||
|
||||
export type SimilarArtist = ArtistSummary & { inLibrary: boolean };
|
||||
|
||||
export type Artist = ArtistSummary & {
|
||||
// todo: maybe is should be artist.summary rather than an artist also being a summary?
|
||||
export type Artist = Pick<ArtistSummary, "id" | "name" | "image"> & {
|
||||
albums: AlbumSummary[];
|
||||
similarArtists: SimilarArtist[]
|
||||
};
|
||||
@@ -34,12 +35,11 @@ export type AlbumSummary = {
|
||||
year: string | undefined;
|
||||
genre: Genre | undefined;
|
||||
coverArt: BUrn | undefined;
|
||||
|
||||
artistName: string | undefined;
|
||||
artistId: string | undefined;
|
||||
};
|
||||
|
||||
export type Album = AlbumSummary & {};
|
||||
export type Album = Pick<AlbumSummary, "id" | "name" | "year" | "genre" | "coverArt" | "artistName" | "artistId"> & { tracks: Track[] };
|
||||
|
||||
export type Genre = {
|
||||
name: string;
|
||||
@@ -60,7 +60,7 @@ export type Encoding = {
|
||||
mimeType: string
|
||||
}
|
||||
|
||||
export type Track = {
|
||||
export type TrackSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
encoding: Encoding,
|
||||
@@ -68,9 +68,12 @@ export type Track = {
|
||||
number: number | undefined;
|
||||
genre: Genre | undefined;
|
||||
coverArt: BUrn | undefined;
|
||||
album: AlbumSummary;
|
||||
artist: ArtistSummary;
|
||||
rating: Rating;
|
||||
}
|
||||
|
||||
export type Track = TrackSummary & {
|
||||
album: AlbumSummary;
|
||||
};
|
||||
|
||||
export type RadioStation = {
|
||||
@@ -129,6 +132,18 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
|
||||
coverArt: it.coverArt
|
||||
});
|
||||
|
||||
export const trackToTrackSummary = (it: Track): TrackSummary => ({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
encoding: it.encoding,
|
||||
duration: it.duration,
|
||||
number: it.number,
|
||||
genre: it.genre,
|
||||
coverArt: it.coverArt,
|
||||
artist: it.artist,
|
||||
rating: it.rating
|
||||
});
|
||||
|
||||
export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
@@ -176,7 +191,6 @@ export interface MusicLibrary {
|
||||
artist(id: string): Promise<Artist>;
|
||||
albums(q: AlbumQuery): Promise<Result<AlbumSummary>>;
|
||||
album(id: string): Promise<Album>;
|
||||
tracks(albumId: string): Promise<Track[]>;
|
||||
track(trackId: string): Promise<Track>;
|
||||
genres(): Promise<Genre[]>;
|
||||
years(): Promise<Year[]>;
|
||||
@@ -200,8 +214,8 @@ export interface MusicLibrary {
|
||||
deletePlaylist(id: string): Promise<boolean>
|
||||
addToPlaylist(playlistId: string, trackId: string): Promise<boolean>
|
||||
removeFromPlaylist(playlistId: string, indicies: number[]): Promise<boolean>
|
||||
similarSongs(id: string): Promise<Track[]>;
|
||||
topSongs(artistId: string): Promise<Track[]>;
|
||||
similarSongs(id: string): Promise<TrackSummary[]>;
|
||||
topSongs(artistId: string): Promise<TrackSummary[]>;
|
||||
radioStation(id: string): Promise<RadioStation>
|
||||
radioStations(): Promise<RadioStation[]>
|
||||
}
|
||||
144
src/server.ts
@@ -22,7 +22,7 @@ import {
|
||||
ratingAsInt,
|
||||
} from "./smapi";
|
||||
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
||||
import { MusicService, AuthFailure, AuthSuccess } from "./music_service";
|
||||
import { MusicService, AuthFailure, AuthSuccess } from "./music_library";
|
||||
import bindSmapiSoapServiceToExpress from "./smapi";
|
||||
import { APITokens, InMemoryAPITokens } from "./api_tokens";
|
||||
import logger from "./logger";
|
||||
@@ -39,29 +39,9 @@ import {
|
||||
JWTSmapiLoginTokens,
|
||||
SmapiAuthTokens,
|
||||
} from "./smapi_auth";
|
||||
import { SmapiTokenStore, InMemorySmapiTokenStore } from "./smapi_token_store";
|
||||
|
||||
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 {
|
||||
range: (length: number) => string;
|
||||
}
|
||||
@@ -112,7 +92,6 @@ export type ServerOpts = {
|
||||
version: string;
|
||||
smapiAuthTokens: SmapiAuthTokens;
|
||||
externalImageResolver: ImageFetcher;
|
||||
smapiTokenStore: SmapiTokenStore;
|
||||
};
|
||||
|
||||
const DEFAULT_SERVER_OPTS: ServerOpts = {
|
||||
@@ -129,7 +108,6 @@ const DEFAULT_SERVER_OPTS: ServerOpts = {
|
||||
"1m"
|
||||
),
|
||||
externalImageResolver: axiosImageFetcher,
|
||||
smapiTokenStore: new InMemorySmapiTokenStore(),
|
||||
};
|
||||
|
||||
function server(
|
||||
@@ -155,7 +133,6 @@ function server(
|
||||
app.use(morgan("combined"));
|
||||
}
|
||||
app.use(express.urlencoded({ extended: false }));
|
||||
app.use(express.json());
|
||||
|
||||
app.use(express.static(path.resolve(__dirname, "..", "web", "public")));
|
||||
app.engine("eta", Eta.renderFile);
|
||||
@@ -420,14 +397,6 @@ function server(
|
||||
if (!serviceToken) {
|
||||
return res.status(401).send();
|
||||
} 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
|
||||
.login(serviceToken)
|
||||
.then((it) =>
|
||||
@@ -629,113 +598,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(
|
||||
app,
|
||||
SOAP_PATH,
|
||||
@@ -745,9 +607,7 @@ function server(
|
||||
apiTokens,
|
||||
clock,
|
||||
i8n,
|
||||
serverOpts.smapiAuthTokens,
|
||||
serverOpts.smapiTokenStore,
|
||||
serverOpts.logRequests
|
||||
serverOpts.smapiAuthTokens
|
||||
);
|
||||
|
||||
if (serverOpts.applyContextPath) {
|
||||
|
||||
327
src/smapi.ts
@@ -10,7 +10,6 @@ import logger from "./logger";
|
||||
|
||||
import { LinkCodes } from "./link_codes";
|
||||
import {
|
||||
Album,
|
||||
AlbumQuery,
|
||||
AlbumSummary,
|
||||
ArtistSummary,
|
||||
@@ -22,7 +21,7 @@ import {
|
||||
Rating,
|
||||
slice2,
|
||||
Track,
|
||||
} from "./music_service";
|
||||
} from "./music_library";
|
||||
import { APITokens } from "./api_tokens";
|
||||
import { Clock } from "./clock";
|
||||
import { URLBuilder } from "./url_builder";
|
||||
@@ -36,11 +35,7 @@ import {
|
||||
SmapiAuthTokens,
|
||||
SMAPI_FAULT_LOGIN_UNAUTHORIZED,
|
||||
ToSmapiFault,
|
||||
SmapiToken,
|
||||
} 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 CREATE_REGISTRATION_ROUTE = "/registration/add";
|
||||
@@ -165,20 +160,17 @@ class SonosSoap {
|
||||
bonobUrl: URLBuilder;
|
||||
smapiAuthTokens: SmapiAuthTokens;
|
||||
clock: Clock;
|
||||
tokenStore: SmapiTokenStore;
|
||||
|
||||
constructor(
|
||||
bonobUrl: URLBuilder,
|
||||
linkCodes: LinkCodes,
|
||||
smapiAuthTokens: SmapiAuthTokens,
|
||||
clock: Clock,
|
||||
tokenStore: SmapiTokenStore
|
||||
clock: Clock
|
||||
) {
|
||||
this.bonobUrl = bonobUrl;
|
||||
this.linkCodes = linkCodes;
|
||||
this.smapiAuthTokens = smapiAuthTokens;
|
||||
this.clock = clock;
|
||||
this.tokenStore = tokenStore;
|
||||
}
|
||||
|
||||
getAppLink(): GetAppLinkResult {
|
||||
@@ -200,11 +192,6 @@ class SonosSoap {
|
||||
};
|
||||
}
|
||||
|
||||
reportAccountAction = (args: any, _headers: any) => {
|
||||
logger.info('Sonos reportAccountAction: ' + JSON.stringify(args));
|
||||
return {};
|
||||
}
|
||||
|
||||
getDeviceAuthToken({
|
||||
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";
|
||||
@@ -404,30 +379,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
apiKeys: APITokens,
|
||||
clock: Clock,
|
||||
i8n: I8N,
|
||||
smapiAuthTokens: SmapiAuthTokens,
|
||||
tokenStore: SmapiTokenStore,
|
||||
_logRequests: boolean
|
||||
smapiAuthTokens: SmapiAuthTokens
|
||||
) {
|
||||
const sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock, tokenStore);
|
||||
|
||||
// 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 sonosSoap = new SonosSoap(bonobUrl, linkCodes, smapiAuthTokens, clock);
|
||||
|
||||
const urlWithToken = (accessToken: string) =>
|
||||
bonobUrl.append({
|
||||
@@ -440,39 +394,18 @@ function bindSmapiSoapServiceToExpress(
|
||||
const credentialsFrom = E.fromNullable(new MissingLoginTokenError());
|
||||
return pipe(
|
||||
credentialsFrom(credentials),
|
||||
E.chain((credentials) => {
|
||||
// Check if token/key is associated with a user
|
||||
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(
|
||||
E.chain((credentials) =>
|
||||
pipe(
|
||||
smapiAuthTokens.verify({
|
||||
token: credentials.loginToken.token,
|
||||
key: effectiveKey,
|
||||
key: credentials.loginToken.key,
|
||||
}),
|
||||
E.map((serviceToken) => ({
|
||||
serviceToken,
|
||||
credentials: {
|
||||
...credentials,
|
||||
loginToken: {
|
||||
...credentials.loginToken,
|
||||
key: effectiveKey,
|
||||
},
|
||||
},
|
||||
credentials,
|
||||
}))
|
||||
);
|
||||
}),
|
||||
)
|
||||
),
|
||||
E.map(({ serviceToken, credentials }) => ({
|
||||
serviceToken,
|
||||
credentials,
|
||||
@@ -481,49 +414,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
);
|
||||
};
|
||||
|
||||
const swapToken = (expiredToken: string | undefined) => (newToken: SmapiToken) => {
|
||||
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 login = async (credentials?: Credentials) => {
|
||||
const authOrFail = pipe(
|
||||
auth(credentials),
|
||||
E.getOrElseW((fault) => fault)
|
||||
@@ -536,16 +427,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
throw SMAPI_FAULT_LOGIN_UNAUTHORIZED;
|
||||
});
|
||||
} 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(
|
||||
musicService.refreshToken(authOrFail.expiredToken),
|
||||
TE.map((it) => {
|
||||
logger.info("Token refresh successful, issuing new SMAPI token");
|
||||
return smapiAuthTokens.issue(it.serviceToken);
|
||||
}),
|
||||
TE.tap(swapToken(undefined)),
|
||||
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
|
||||
TE.map((newToken) => ({
|
||||
Fault: {
|
||||
faultcode: "Client.TokenRefreshRequired",
|
||||
@@ -558,10 +442,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
},
|
||||
},
|
||||
})),
|
||||
TE.getOrElse((err) => {
|
||||
logger.error("Token refresh failed", { error: err });
|
||||
return T.of(SMAPI_FAULT_LOGIN_UNAUTHORIZED);
|
||||
})
|
||||
TE.getOrElse(() => T.of(SMAPI_FAULT_LOGIN_UNAUTHORIZED))
|
||||
)();
|
||||
} else {
|
||||
throw authOrFail.toSmapiFault();
|
||||
@@ -575,18 +456,8 @@ function bindSmapiSoapServiceToExpress(
|
||||
Sonos: {
|
||||
SonosSoap: {
|
||||
getAppLink: () => sonosSoap.getAppLink(),
|
||||
reportAccountAction: (args: any) =>
|
||||
sonosSoap.reportAccountAction(args, undefined),
|
||||
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;
|
||||
},
|
||||
getDeviceAuthToken: ({ linkCode }: { linkCode: string }) =>
|
||||
sonosSoap.getDeviceAuthToken({ linkCode }),
|
||||
getLastUpdate: () => ({
|
||||
getLastUpdateResult: {
|
||||
autoRefreshEnabled: true,
|
||||
@@ -595,11 +466,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
pollInterval: 60,
|
||||
},
|
||||
}),
|
||||
refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">) => {
|
||||
const creds = useHeaderIfPresent(soapyHeaders?.credentials, headers);
|
||||
refreshAuthToken: async (_, _2, soapyHeaders: SoapyHeaders) => {
|
||||
const serviceToken = pipe(
|
||||
auth(creds),
|
||||
auth(soapyHeaders?.credentials),
|
||||
E.fold(
|
||||
(fault) =>
|
||||
isExpiredTokenError(fault)
|
||||
@@ -611,12 +480,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
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(
|
||||
musicService.refreshToken(serviceToken),
|
||||
TE.map((it) => smapiAuthTokens.issue(it.serviceToken)),
|
||||
TE.tap(swapToken(undefined)), // ignores the return value, like a tee or peek
|
||||
TE.map((it) => ({
|
||||
refreshAuthTokenResult: {
|
||||
authToken: it.token,
|
||||
@@ -631,10 +497,9 @@ function bindSmapiSoapServiceToExpress(
|
||||
getMediaURI: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) => {
|
||||
return login(soapyHeaders?.credentials, headers)
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ musicLibrary, credentials, type, typeId }) => {
|
||||
switch (type) {
|
||||
@@ -667,15 +532,13 @@ function bindSmapiSoapServiceToExpress(
|
||||
default:
|
||||
throw `Unsupported type:${type}`;
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
getMediaMetadata: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) => {
|
||||
return login(soapyHeaders?.credentials, headers)
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(async ({ musicLibrary, apiKey, type, typeId }) => {
|
||||
switch (type) {
|
||||
@@ -690,15 +553,13 @@ function bindSmapiSoapServiceToExpress(
|
||||
default:
|
||||
throw `Unsupported type:${type}`;
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
search: async (
|
||||
{ id, term }: { id: string; term: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) => {
|
||||
return login(soapyHeaders?.credentials, headers)
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(async ({ musicLibrary, apiKey }) => {
|
||||
switch (id) {
|
||||
@@ -724,16 +585,15 @@ function bindSmapiSoapServiceToExpress(
|
||||
return musicLibrary.searchTracks(term).then((it) =>
|
||||
searchResult({
|
||||
count: it.length,
|
||||
mediaMetadata: it.map((aTrack) =>
|
||||
track(urlWithToken(apiKey), aTrack)
|
||||
mediaCollection: it.map((aTrack) =>
|
||||
album(urlWithToken(apiKey), aTrack.album)
|
||||
),
|
||||
})
|
||||
);
|
||||
default:
|
||||
throw `Unsupported search by:${id}`;
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
getExtendedMetadata: async (
|
||||
{
|
||||
id,
|
||||
@@ -742,17 +602,16 @@ function bindSmapiSoapServiceToExpress(
|
||||
}: // recursive,
|
||||
{ id: string; index: number; count: number; recursive: boolean },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) => {
|
||||
return login(soapyHeaders?.credentials, headers)
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(async ({ musicLibrary, apiKey, type, typeId }) => {
|
||||
const paging = { _index: index, _count: count };
|
||||
switch (type) {
|
||||
case "artist":
|
||||
return musicLibrary.artist(typeId).then((artist) => {
|
||||
const [page, total] = slice2<Album>(paging)(
|
||||
const [page, total] = slice2<AlbumSummary>(paging)(
|
||||
artist.albums
|
||||
);
|
||||
return {
|
||||
@@ -805,8 +664,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
default:
|
||||
throw `Unsupported getExtendedMetadata id=${id}`;
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
getMetadata: async (
|
||||
{
|
||||
id,
|
||||
@@ -817,12 +675,12 @@ function bindSmapiSoapServiceToExpress(
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) => {
|
||||
const acceptLanguage = headers["accept-language"];
|
||||
return login(soapyHeaders?.credentials, headers)
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ musicLibrary, apiKey, type, typeId }) => {
|
||||
const paging = { _index: index, _count: count };
|
||||
const acceptLanguage = headers["accept-language"];
|
||||
logger.debug(
|
||||
`Fetching metadata type=${type}, typeId=${typeId}, acceptLanguage=${acceptLanguage}`
|
||||
);
|
||||
@@ -1123,7 +981,8 @@ function bindSmapiSoapServiceToExpress(
|
||||
});
|
||||
case "album":
|
||||
return musicLibrary
|
||||
.tracks(typeId!)
|
||||
.album(typeId!)
|
||||
.then(it => it.tracks)
|
||||
.then(slice2(paging))
|
||||
.then(([page, total]) => {
|
||||
return getMetadataResult({
|
||||
@@ -1137,15 +996,13 @@ function bindSmapiSoapServiceToExpress(
|
||||
default:
|
||||
throw `Unsupported getMetadata id=${id}`;
|
||||
}
|
||||
});
|
||||
},
|
||||
}),
|
||||
createContainer: async (
|
||||
{ title, seedId }: { title: string; seedId: string | undefined },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) => {
|
||||
return login(soapyHeaders?.credentials, headers)
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(({ musicLibrary }) =>
|
||||
musicLibrary
|
||||
.createPlaylist(title)
|
||||
@@ -1165,38 +1022,32 @@ function bindSmapiSoapServiceToExpress(
|
||||
id: `playlist:${it.id}`,
|
||||
updateId: "",
|
||||
},
|
||||
}));
|
||||
},
|
||||
})),
|
||||
deleteContainer: async (
|
||||
{ id }: { id: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) => {
|
||||
return login(soapyHeaders?.credentials, headers)
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(({ musicLibrary }) => musicLibrary.deletePlaylist(id))
|
||||
.then((_) => ({ deleteContainerResult: {} }));
|
||||
},
|
||||
.then((_) => ({ deleteContainerResult: {} })),
|
||||
addToContainer: async (
|
||||
{ id, parentId }: { id: string; parentId: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) => {
|
||||
return login(soapyHeaders?.credentials, headers)
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ musicLibrary, typeId }) =>
|
||||
musicLibrary.addToPlaylist(parentId.split(":")[1]!, typeId)
|
||||
)
|
||||
.then((_) => ({ addToContainerResult: { updateId: "" } }));
|
||||
},
|
||||
.then((_) => ({ addToContainerResult: { updateId: "" } })),
|
||||
removeFromContainer: async (
|
||||
{ id, indices }: { id: string; indices: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) => {
|
||||
return login(soapyHeaders?.credentials, headers)
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then((it) => ({
|
||||
...it,
|
||||
@@ -1213,29 +1064,25 @@ function bindSmapiSoapServiceToExpress(
|
||||
musicLibrary.removeFromPlaylist(typeId, indices);
|
||||
}
|
||||
})
|
||||
.then((_) => ({ removeFromContainerResult: { updateId: "" } }));
|
||||
},
|
||||
.then((_) => ({ removeFromContainerResult: { updateId: "" } })),
|
||||
rateItem: async (
|
||||
{ id, rating }: { id: string; rating: number },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) => {
|
||||
return login(soapyHeaders?.credentials, headers)
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ musicLibrary, typeId }) =>
|
||||
musicLibrary.rate(typeId, ratingFromInt(Math.abs(rating)))
|
||||
)
|
||||
.then((_) => ({ rateItemResult: { shouldSkip: false } }));
|
||||
},
|
||||
.then((_) => ({ rateItemResult: { shouldSkip: false } })),
|
||||
|
||||
setPlayedSeconds: async (
|
||||
{ id, seconds }: { id: string; seconds: string },
|
||||
_,
|
||||
soapyHeaders: SoapyHeaders,
|
||||
{ headers }: Pick<Request, "headers">
|
||||
) => {
|
||||
return login(soapyHeaders?.credentials, headers)
|
||||
soapyHeaders: SoapyHeaders
|
||||
) =>
|
||||
login(soapyHeaders?.credentials)
|
||||
.then(splitId(id))
|
||||
.then(({ musicLibrary, type, typeId }) => {
|
||||
switch (type) {
|
||||
@@ -1257,49 +1104,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
})
|
||||
.then((_) => ({
|
||||
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 { Clock } from "./clock";
|
||||
|
||||
import logger from "./logger";
|
||||
|
||||
export type SmapiFault = { Fault: { faultcode: string; faultstring: string } };
|
||||
export type SmapiRefreshTokenResultFault = SmapiFault & {
|
||||
Fault: {
|
||||
@@ -16,7 +14,6 @@ export type SmapiRefreshTokenResultFault = SmapiFault & {
|
||||
};
|
||||
|
||||
function isError(thing: any): thing is Error {
|
||||
logger.debug("isError check", { thing });
|
||||
return thing.name && thing.message;
|
||||
}
|
||||
|
||||
@@ -154,13 +151,6 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens {
|
||||
};
|
||||
|
||||
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 {
|
||||
return E.right(
|
||||
(
|
||||
@@ -171,9 +161,7 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens {
|
||||
).serviceToken
|
||||
);
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
if (isTokenExpiredError(e)) {
|
||||
logger.debug("JWT token expired, will attempt refresh", { expiredAt: (e as TokenExpiredError).expiredAt });
|
||||
const serviceToken = (
|
||||
jwt.verify(
|
||||
smapiToken.token,
|
||||
@@ -182,11 +170,8 @@ export class JWTSmapiLoginTokens implements SmapiAuthTokens {
|
||||
) as any
|
||||
).serviceToken;
|
||||
return E.left(new ExpiredTokenError(serviceToken));
|
||||
} else {
|
||||
logger.warn("JWT verification failed - token may be invalid or from different secret", { message: err.message });
|
||||
if (isError(e)) return E.left(new InvalidTokenError(err.message));
|
||||
else return E.left(new InvalidTokenError("Failed to verify token"));
|
||||
}
|
||||
} else if (isError(e)) return E.left(new InvalidTokenError(e.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;
|
||||
}
|
||||
}
|
||||
856
src/subsonic.ts
320
src/subsonic_music_library.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
import { taskEither as TE } from "fp-ts";
|
||||
import { pipe } from "fp-ts/lib/function";
|
||||
import {
|
||||
Credentials,
|
||||
MusicService,
|
||||
ArtistSummary,
|
||||
Result,
|
||||
slice2,
|
||||
AlbumQuery,
|
||||
ArtistQuery,
|
||||
MusicLibrary,
|
||||
Album,
|
||||
AlbumSummary,
|
||||
Rating,
|
||||
Artist,
|
||||
AuthFailure,
|
||||
AuthSuccess,
|
||||
} from "./music_library";
|
||||
import {
|
||||
Subsonic,
|
||||
CustomPlayers,
|
||||
NO_CUSTOM_PLAYERS,
|
||||
asToken,
|
||||
parseToken,
|
||||
artistImageURN,
|
||||
asYear,
|
||||
isValidImage
|
||||
} from "./subsonic";
|
||||
import _ from "underscore";
|
||||
|
||||
import axios from "axios";
|
||||
import logger from "./logger";
|
||||
import { assertSystem, BUrn } from "./burn";
|
||||
|
||||
export class SubsonicMusicService implements MusicService {
|
||||
subsonic: Subsonic;
|
||||
customPlayers: CustomPlayers;
|
||||
|
||||
constructor(
|
||||
subsonic: Subsonic,
|
||||
customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS
|
||||
) {
|
||||
this.subsonic = subsonic;
|
||||
this.customPlayers = customPlayers;
|
||||
}
|
||||
|
||||
generateToken = (
|
||||
credentials: Credentials
|
||||
): TE.TaskEither<AuthFailure, AuthSuccess> =>
|
||||
pipe(
|
||||
this.subsonic.ping(credentials),
|
||||
TE.flatMap(({ type }) => TE.tryCatch(
|
||||
() => this.libraryFor({ ...credentials, type }).then(library => ({ type, library })),
|
||||
() => new AuthFailure("Failed to get 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 SubsonicMusicLibrary implements MusicLibrary {
|
||||
subsonic: Subsonic;
|
||||
credentials: Credentials;
|
||||
customPlayers: CustomPlayers;
|
||||
|
||||
constructor(
|
||||
subsonic: Subsonic,
|
||||
credentials: Credentials,
|
||||
customPlayers: CustomPlayers
|
||||
) {
|
||||
this.subsonic = subsonic;
|
||||
this.credentials = credentials;
|
||||
this.customPlayers = customPlayers;
|
||||
}
|
||||
|
||||
flavour = () => "subsonic";
|
||||
|
||||
bearerToken = (_: Credentials) =>
|
||||
TE.right<AuthFailure, string | undefined>(undefined);
|
||||
|
||||
// todo: q needs to support greater than the max page size supported by subsonic
|
||||
// maybe subsonic should error?
|
||||
artists = (q: ArtistQuery): Promise<Result<ArtistSummary>> =>
|
||||
this.subsonic
|
||||
.getArtists(this.credentials)
|
||||
.then(slice2(q))
|
||||
.then(([page, total]) => ({
|
||||
total,
|
||||
results: page,
|
||||
}));
|
||||
|
||||
artist = async (id: string): Promise<Artist> =>
|
||||
Promise.all([
|
||||
this.subsonic.getArtist(this.credentials, id),
|
||||
this.subsonic.getArtistInfo(this.credentials, id),
|
||||
]).then(([artist, artistInfo]) => ({
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
image: artistImageURN({
|
||||
artistId: artist.id,
|
||||
artistImageURL: [
|
||||
artist.artistImageUrl,
|
||||
// todo: subsonic.artistInfo should just return a valid image or undefined, then the music lib just chooses first undefined
|
||||
// out of artist.image and artistInfo.image
|
||||
artistInfo.images.l,
|
||||
artistInfo.images.m,
|
||||
artistInfo.images.s,
|
||||
// todo: do we still need this isValidImage?
|
||||
].find(isValidImage),
|
||||
}),
|
||||
albums: artist.albums,
|
||||
similarArtists: artistInfo.similarArtist,
|
||||
}));
|
||||
|
||||
albums = async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
|
||||
this.subsonic.getAlbumList2(this.credentials, q);
|
||||
|
||||
album = (id: string): Promise<Album> =>
|
||||
this.subsonic.getAlbum(this.credentials, id);
|
||||
|
||||
genres = () =>
|
||||
this.subsonic.getGenres(this.credentials);
|
||||
|
||||
track = (trackId: string) =>
|
||||
this.subsonic.getTrack(this.credentials, trackId);
|
||||
|
||||
rate = (trackId: string, rating: Rating) =>
|
||||
// todo: this is a bit odd
|
||||
Promise.resolve(true)
|
||||
.then(() => {
|
||||
if (rating.stars >= 0 && rating.stars <= 5) {
|
||||
return this.subsonic.getTrack(this.credentials, trackId);
|
||||
} else {
|
||||
throw `Invalid rating.stars value of ${rating.stars}`;
|
||||
}
|
||||
})
|
||||
.then((track) => {
|
||||
const thingsToUpdate = [];
|
||||
if (track.rating.love != rating.love) {
|
||||
thingsToUpdate.push(
|
||||
(rating.love ? this.subsonic.star : this.subsonic.unstar)(this.credentials,{ id: trackId })
|
||||
);
|
||||
}
|
||||
if (track.rating.stars != rating.stars) {
|
||||
thingsToUpdate.push(
|
||||
this.subsonic.setRating(this.credentials, trackId, rating.stars)
|
||||
);
|
||||
}
|
||||
return Promise.all(thingsToUpdate);
|
||||
})
|
||||
.then(() => true)
|
||||
.catch(() => false);
|
||||
|
||||
stream = async ({
|
||||
trackId,
|
||||
range,
|
||||
}: {
|
||||
trackId: string;
|
||||
range: string | undefined;
|
||||
}) =>
|
||||
this.subsonic
|
||||
.getTrack(this.credentials, trackId)
|
||||
.then((track) =>
|
||||
this.subsonic.stream(this.credentials, trackId, track.encoding.player, range)
|
||||
);
|
||||
|
||||
coverArt = async (coverArtURN: BUrn, size?: number) =>
|
||||
Promise.resolve(coverArtURN)
|
||||
.then((it) => assertSystem(it, "subsonic"))
|
||||
.then((it) =>
|
||||
this.subsonic.getCoverArt(
|
||||
this.credentials,
|
||||
it.resource.split(":")[1]!,
|
||||
size
|
||||
)
|
||||
)
|
||||
.then((res) => ({
|
||||
contentType: res.headers["content-type"],
|
||||
data: Buffer.from(res.data, "binary"),
|
||||
}))
|
||||
.catch((e) => {
|
||||
logger.error(`Failed getting coverArt for urn:'${coverArtURN}': ${e}`);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
// todo: unit test the difference between scrobble and nowPlaying
|
||||
scrobble = async (id: string) =>
|
||||
this.subsonic.scrobble(this.credentials, id, true);
|
||||
|
||||
nowPlaying = async (id: string) =>
|
||||
this.subsonic.scrobble(this.credentials, id, false);
|
||||
|
||||
searchArtists = async (query: string) =>
|
||||
this.subsonic
|
||||
.search3(this.credentials, { query, artistCount: 20 })
|
||||
.then(({ artists }) =>
|
||||
artists.map((artist) => ({
|
||||
id: artist.id,
|
||||
name: artist.name,
|
||||
image: artistImageURN({
|
||||
artistId: artist.id,
|
||||
artistImageURL: artist.artistImageUrl,
|
||||
}),
|
||||
}))
|
||||
);
|
||||
|
||||
searchAlbums = async (query: string) =>
|
||||
this.subsonic
|
||||
.search3(this.credentials, { query, albumCount: 20 })
|
||||
.then(({ albums }) => this.subsonic.toAlbumSummary(albums));
|
||||
|
||||
searchTracks = async (query: string) =>
|
||||
this.subsonic
|
||||
.search3(this.credentials, { query, songCount: 20 })
|
||||
.then(({ songs }) =>
|
||||
Promise.all(
|
||||
songs.map((it) => this.subsonic.getTrack(this.credentials, it.id))
|
||||
)
|
||||
);
|
||||
|
||||
playlists = async () =>
|
||||
this.subsonic.playlists(this.credentials);
|
||||
|
||||
playlist = async (id: string) =>
|
||||
this.subsonic.playlist(this.credentials, id);
|
||||
|
||||
createPlaylist = async (name: string) =>
|
||||
this.subsonic.createPlayList(this.credentials, name);
|
||||
|
||||
deletePlaylist = async (id: string) =>
|
||||
this.subsonic.deletePlayList(this.credentials, id);
|
||||
|
||||
addToPlaylist = async (playlistId: string, trackId: string) =>
|
||||
this.subsonic.updatePlaylist(this.credentials, playlistId, { songIdToAdd: trackId });
|
||||
|
||||
removeFromPlaylist = async (playlistId: string, indicies: number[]) =>
|
||||
this.subsonic.updatePlaylist(this.credentials, playlistId, { songIndexToRemove: indicies });
|
||||
|
||||
similarSongs = async (id: string) =>
|
||||
this.subsonic.getSimilarSongs2(this.credentials, id)
|
||||
|
||||
topSongs = async (artistId: string) =>
|
||||
this.subsonic.getArtist(this.credentials, artistId)
|
||||
.then(({ name }) =>
|
||||
this.subsonic.getTopSongs(this.credentials, name)
|
||||
);
|
||||
|
||||
radioStations = async () =>
|
||||
this.subsonic.getInternetRadioStations(this.credentials);
|
||||
|
||||
radioStation = async (id: string) =>
|
||||
this.radioStations().then((it) => it.find((station) => station.id === id)!);
|
||||
|
||||
years = async () => {
|
||||
const q: AlbumQuery = {
|
||||
_index: 0,
|
||||
_count: 100000, // FIXME: better than this, probably doesnt work anyway as max _count is 500 or something
|
||||
type: "alphabeticalByArtist",
|
||||
};
|
||||
const years = this.subsonic
|
||||
.getAlbumList2(this.credentials, q)
|
||||
.then(({ results }) =>
|
||||
results
|
||||
.map((album) => album.year || "?")
|
||||
.filter((item, i, ar) => ar.indexOf(item) === i)
|
||||
.sort()
|
||||
.map((year) => ({
|
||||
...asYear(year),
|
||||
}))
|
||||
.reverse()
|
||||
);
|
||||
return years;
|
||||
};
|
||||
}
|
||||
@@ -8,14 +8,14 @@ import {
|
||||
Album,
|
||||
Artist,
|
||||
Track,
|
||||
albumToAlbumSummary,
|
||||
artistToArtistSummary,
|
||||
PlaylistSummary,
|
||||
Playlist,
|
||||
SimilarArtist,
|
||||
AlbumSummary,
|
||||
RadioStation
|
||||
} from "../src/music_service";
|
||||
RadioStation,
|
||||
ArtistSummary,
|
||||
TrackSummary
|
||||
} from "../src/music_library";
|
||||
|
||||
import { b64Encode } from "../src/b64";
|
||||
import { artistImageURN } from "../src/subsonic";
|
||||
@@ -116,13 +116,26 @@ export function aSimilarArtist(
|
||||
};
|
||||
}
|
||||
|
||||
export function anArtist(fields: Partial<Artist> = {}): Artist {
|
||||
export function anArtistSummary(fields: Partial<ArtistSummary> = {}): ArtistSummary {
|
||||
const id = fields.id || uuid();
|
||||
const artist = {
|
||||
return {
|
||||
id,
|
||||
name: `Artist ${id}`,
|
||||
albums: [anAlbum(), anAlbum(), anAlbum()],
|
||||
image: { system: "subsonic", resource: `art:${id}` },
|
||||
}
|
||||
}
|
||||
|
||||
export function anArtist(fields: Partial<Artist> = {}): Artist {
|
||||
const id = fields.id || uuid();
|
||||
const name = `Artist ${randomstring.generate()}`
|
||||
const albums = fields.albums || [
|
||||
anAlbumSummary({ artistId: id, artistName: name }),
|
||||
anAlbumSummary({ artistId: id, artistName: name }),
|
||||
anAlbumSummary({ artistId: id, artistName: name })
|
||||
];
|
||||
const artist = {
|
||||
...anArtistSummary({ id, name }),
|
||||
albums,
|
||||
similarArtists: [
|
||||
aSimilarArtist({ id: uuid(), name: "Similar artist1", inLibrary: true }),
|
||||
aSimilarArtist({ id: uuid(), name: "Similar artist2", inLibrary: true }),
|
||||
@@ -166,9 +179,9 @@ export const SAMPLE_GENRES = [
|
||||
];
|
||||
export const randomGenre = () => SAMPLE_GENRES[randomInt(SAMPLE_GENRES.length)];
|
||||
|
||||
export function aTrack(fields: Partial<Track> = {}): Track {
|
||||
export function aTrackSummary(fields: Partial<TrackSummary> = {}): TrackSummary {
|
||||
const id = uuid();
|
||||
const artist = anArtist();
|
||||
const artist = fields.artist || anArtistSummary();
|
||||
const genre = fields.genre || randomGenre();
|
||||
const rating = { love: false, stars: Math.floor(Math.random() * 5) };
|
||||
return {
|
||||
@@ -181,28 +194,53 @@ export function aTrack(fields: Partial<Track> = {}): Track {
|
||||
duration: randomInt(500),
|
||||
number: randomInt(100),
|
||||
genre,
|
||||
artist: artistToArtistSummary(artist),
|
||||
album: albumToAlbumSummary(
|
||||
anAlbum({ artistId: artist.id, artistName: artist.name, genre })
|
||||
),
|
||||
artist,
|
||||
coverArt: { system: "subsonic", resource: `art:${uuid()}`},
|
||||
rating,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export function anAlbum(fields: Partial<Album> = {}): Album {
|
||||
export function aTrack(fields: Partial<Track> = {}): Track {
|
||||
const summary = aTrackSummary(fields);
|
||||
const album = fields.album || anAlbumSummary({ artistId: summary.artist.id, artistName: summary.artist.name, genre: summary.genre })
|
||||
return {
|
||||
...summary,
|
||||
album,
|
||||
...fields
|
||||
};
|
||||
};
|
||||
|
||||
export function anAlbumSummary(fields: Partial<AlbumSummary> = {}): AlbumSummary {
|
||||
const id = uuid();
|
||||
return {
|
||||
id,
|
||||
name: `Album ${id}`,
|
||||
genre: randomGenre(),
|
||||
year: `19${randomInt(99)}`,
|
||||
genre: randomGenre(),
|
||||
coverArt: { system: "subsonic", resource: `art:${uuid()}` },
|
||||
artistId: `Artist ${uuid()}`,
|
||||
artistName: `Artist ${randomstring.generate()}`,
|
||||
coverArt: { system: "subsonic", resource: `art:${uuid()}` },
|
||||
...fields
|
||||
};
|
||||
};
|
||||
|
||||
export function anAlbum(fields: Partial<Album> = {}): Album {
|
||||
const albumSummary = anAlbumSummary()
|
||||
const album = {
|
||||
...albumSummary,
|
||||
tracks: [],
|
||||
...fields,
|
||||
};
|
||||
const artistSummary = anArtistSummary({ id: album.artistId, name: album.artistName })
|
||||
const tracks = fields.tracks || [
|
||||
aTrack({ album: albumSummary, artist: artistSummary }),
|
||||
aTrack({ album: albumSummary, artist: artistSummary })
|
||||
]
|
||||
return {
|
||||
...album,
|
||||
tracks
|
||||
};
|
||||
};
|
||||
|
||||
export function aRadioStation(fields: Partial<RadioStation> = {}): RadioStation {
|
||||
@@ -216,20 +254,6 @@ export function aRadioStation(fields: Partial<RadioStation> = {}): RadioStation
|
||||
}
|
||||
}
|
||||
|
||||
export function anAlbumSummary(fields: Partial<AlbumSummary> = {}): AlbumSummary {
|
||||
const id = uuid();
|
||||
return {
|
||||
id,
|
||||
name: `Album ${id}`,
|
||||
year: `19${randomInt(99)}`,
|
||||
genre: randomGenre(),
|
||||
coverArt: { system: "subsonic", resource: `art:${uuid()}` },
|
||||
artistId: `Artist ${uuid()}`,
|
||||
artistName: `Artist ${randomstring.generate()}`,
|
||||
...fields
|
||||
}
|
||||
};
|
||||
|
||||
export const BLONDIE_ID = uuid();
|
||||
export const BLONDIE_NAME = "Blondie";
|
||||
export const BLONDIE: Artist = {
|
||||
|
||||
@@ -5,8 +5,7 @@ import { InMemoryMusicService } from "./in_memory_music_service";
|
||||
import {
|
||||
MusicLibrary,
|
||||
artistToArtistSummary,
|
||||
albumToAlbumSummary,
|
||||
} from "../src/music_service";
|
||||
} from "../src/music_library";
|
||||
import { v4 as uuid } from "uuid";
|
||||
import {
|
||||
anArtist,
|
||||
@@ -17,6 +16,7 @@ import {
|
||||
METAL,
|
||||
HIP_HOP,
|
||||
SKA,
|
||||
anAlbumSummary,
|
||||
} from "./builders";
|
||||
import _ from "underscore";
|
||||
|
||||
@@ -167,23 +167,6 @@ describe("InMemoryMusicService", () => {
|
||||
service.hasTracks(track1, track2, track3, track4);
|
||||
});
|
||||
|
||||
describe("fetching tracks for an album", () => {
|
||||
it("should return only tracks on that album", async () => {
|
||||
expect(await musicLibrary.tracks(artist1Album1.id)).toEqual([
|
||||
{ ...track1, rating: { love: false, stars: 0 } },
|
||||
{ ...track2, rating: { love: false, stars: 0 } },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetching tracks for an album that doesnt exist", () => {
|
||||
it("should return empty array", async () => {
|
||||
expect(await musicLibrary.tracks("non existant album id")).toEqual(
|
||||
[]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fetching a single track", () => {
|
||||
describe("when it exists", () => {
|
||||
it("should return the track", async () => {
|
||||
@@ -194,16 +177,16 @@ describe("InMemoryMusicService", () => {
|
||||
});
|
||||
|
||||
describe("albums", () => {
|
||||
const artist1_album1 = anAlbum({ genre: POP });
|
||||
const artist1_album2 = anAlbum({ genre: ROCK });
|
||||
const artist1_album3 = anAlbum({ genre: METAL });
|
||||
const artist1_album4 = anAlbum({ genre: POP });
|
||||
const artist1_album5 = anAlbum({ genre: POP });
|
||||
const artist1_album1 = anAlbumSummary({ genre: POP });
|
||||
const artist1_album2 = anAlbumSummary({ genre: ROCK });
|
||||
const artist1_album3 = anAlbumSummary({ genre: METAL });
|
||||
const artist1_album4 = anAlbumSummary({ genre: POP });
|
||||
const artist1_album5 = anAlbumSummary({ genre: POP });
|
||||
|
||||
const artist2_album1 = anAlbum({ genre: METAL });
|
||||
const artist2_album1 = anAlbumSummary({ genre: METAL });
|
||||
|
||||
const artist3_album1 = anAlbum({ genre: HIP_HOP });
|
||||
const artist3_album2 = anAlbum({ genre: POP });
|
||||
const artist3_album1 = anAlbumSummary({ genre: HIP_HOP });
|
||||
const artist3_album2 = anAlbumSummary({ genre: POP });
|
||||
|
||||
const artist1 = anArtist({
|
||||
name: "artist1",
|
||||
@@ -212,8 +195,8 @@ describe("InMemoryMusicService", () => {
|
||||
artist1_album2,
|
||||
artist1_album3,
|
||||
artist1_album4,
|
||||
artist1_album5,
|
||||
],
|
||||
artist1_album5
|
||||
]
|
||||
});
|
||||
const artist2 = anArtist({ name: "artist2", albums: [artist2_album1] });
|
||||
const artist3 = anArtist({
|
||||
@@ -275,16 +258,16 @@ describe("InMemoryMusicService", () => {
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album1),
|
||||
albumToAlbumSummary(artist1_album2),
|
||||
albumToAlbumSummary(artist1_album3),
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
artist1_album1,
|
||||
artist1_album2,
|
||||
artist1_album3,
|
||||
artist1_album4,
|
||||
artist1_album5,
|
||||
|
||||
albumToAlbumSummary(artist2_album1),
|
||||
artist2_album1,
|
||||
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
artist3_album1,
|
||||
artist3_album2,
|
||||
],
|
||||
total: totalAlbumCount,
|
||||
});
|
||||
@@ -300,7 +283,7 @@ describe("InMemoryMusicService", () => {
|
||||
type: "alphabeticalByName",
|
||||
})
|
||||
).toEqual({
|
||||
results: _.sortBy(allAlbums, "name").map(albumToAlbumSummary),
|
||||
results: _.sortBy(allAlbums, "name"),
|
||||
total: totalAlbumCount,
|
||||
});
|
||||
});
|
||||
@@ -317,9 +300,9 @@ describe("InMemoryMusicService", () => {
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
albumToAlbumSummary(artist2_album1),
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
artist1_album5,
|
||||
artist2_album1,
|
||||
artist3_album1,
|
||||
],
|
||||
total: totalAlbumCount,
|
||||
});
|
||||
@@ -336,8 +319,8 @@ describe("InMemoryMusicService", () => {
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist3_album1),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
artist3_album1,
|
||||
artist3_album2,
|
||||
],
|
||||
total: totalAlbumCount,
|
||||
});
|
||||
@@ -357,10 +340,10 @@ describe("InMemoryMusicService", () => {
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album1),
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
albumToAlbumSummary(artist3_album2),
|
||||
artist1_album1,
|
||||
artist1_album4,
|
||||
artist1_album5,
|
||||
artist3_album2,
|
||||
],
|
||||
total: 4,
|
||||
});
|
||||
@@ -379,8 +362,8 @@ describe("InMemoryMusicService", () => {
|
||||
})
|
||||
).toEqual({
|
||||
results: [
|
||||
albumToAlbumSummary(artist1_album4),
|
||||
albumToAlbumSummary(artist1_album5),
|
||||
artist1_album4,
|
||||
artist1_album5,
|
||||
],
|
||||
total: 4,
|
||||
});
|
||||
@@ -397,7 +380,7 @@ describe("InMemoryMusicService", () => {
|
||||
_count: 100,
|
||||
})
|
||||
).toEqual({
|
||||
results: [albumToAlbumSummary(artist3_album2)],
|
||||
results: [artist3_album2],
|
||||
total: 4,
|
||||
});
|
||||
});
|
||||
@@ -424,7 +407,10 @@ describe("InMemoryMusicService", () => {
|
||||
describe("when it exists", () => {
|
||||
it("should provide an album", async () => {
|
||||
expect(await musicLibrary.album(artist1_album5.id)).toEqual(
|
||||
artist1_album5
|
||||
{
|
||||
...artist1_album5,
|
||||
tracks: []
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,11 +19,10 @@ import {
|
||||
slice2,
|
||||
asResult,
|
||||
artistToArtistSummary,
|
||||
albumToAlbumSummary,
|
||||
Track,
|
||||
Genre,
|
||||
Rating,
|
||||
} from "../src/music_service";
|
||||
} from "../src/music_library";
|
||||
import { BUrn } from "../src/burn";
|
||||
|
||||
export class InMemoryMusicService implements MusicService {
|
||||
@@ -97,14 +96,13 @@ export class InMemoryMusicService implements MusicService {
|
||||
}
|
||||
})
|
||||
.then((matches) => matches.map((it) => it.album))
|
||||
.then((it) => it.map(albumToAlbumSummary))
|
||||
.then(slice2(q))
|
||||
.then(asResult),
|
||||
album: (id: string) =>
|
||||
pipe(
|
||||
this.artists.flatMap((it) => it.albums).find((it) => it.id === id),
|
||||
O.fromNullable,
|
||||
O.map((it) => Promise.resolve(it)),
|
||||
O.map((it) => Promise.resolve({ ...it, tracks: [] })),
|
||||
O.getOrElse(() => Promise.reject(`No album with id '${id}'`))
|
||||
),
|
||||
genres: () =>
|
||||
@@ -119,12 +117,6 @@ export class InMemoryMusicService implements MusicService {
|
||||
A.sort(fromCompare<Genre>((x, y) => ordString.compare(x.id, y.id)))
|
||||
)
|
||||
),
|
||||
tracks: (albumId: string) =>
|
||||
Promise.resolve(
|
||||
this.tracks
|
||||
.filter((it) => it.album.id === albumId)
|
||||
.map((it) => ({ ...it, rating: { love: false, stars: 0 } }))
|
||||
),
|
||||
rate: (_: string, _2: Rating) => Promise.resolve(false),
|
||||
track: (trackId: string) =>
|
||||
pipe(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { v4 as uuid } from "uuid";
|
||||
|
||||
import { anArtist } from "./builders";
|
||||
import { artistToArtistSummary } from "../src/music_service";
|
||||
import { artistToArtistSummary } from "../src/music_library";
|
||||
|
||||
describe("artistToArtistSummary", () => {
|
||||
it("should map fields correctly", () => {
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
} from "./builders";
|
||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||
import { InMemoryLinkCodes } from "../src/link_codes";
|
||||
import { Credentials } from "../src/music_service";
|
||||
import { Credentials } from "../src/music_library";
|
||||
import makeServer from "../src/server";
|
||||
import { Service, bonobService, Sonos } from "../src/sonos";
|
||||
import supersoap from "./supersoap";
|
||||
|
||||
@@ -4,7 +4,7 @@ import request from "supertest";
|
||||
import Image from "image-js";
|
||||
import { either as E, taskEither as TE } from "fp-ts";
|
||||
|
||||
import { AuthFailure, MusicService } from "../src/music_service";
|
||||
import { AuthFailure, MusicService } from "../src/music_library";
|
||||
import makeServer, {
|
||||
BONOB_ACCESS_TOKEN_HEADER,
|
||||
RangeBytesFromFilter,
|
||||
|
||||
@@ -41,6 +41,8 @@ import {
|
||||
PUNK,
|
||||
aPlaylist,
|
||||
aRadioStation,
|
||||
anArtistSummary,
|
||||
anAlbumSummary,
|
||||
} from "./builders";
|
||||
import { InMemoryMusicService } from "./in_memory_music_service";
|
||||
import supersoap from "./supersoap";
|
||||
@@ -49,7 +51,7 @@ import {
|
||||
artistToArtistSummary,
|
||||
MusicService,
|
||||
playlistToPlaylistSummary,
|
||||
} from "../src/music_service";
|
||||
} from "../src/music_library";
|
||||
import { APITokens } from "../src/api_tokens";
|
||||
import dayjs from "dayjs";
|
||||
import url, { URLBuilder } from "../src/url_builder";
|
||||
@@ -984,8 +986,8 @@ describe("wsdl api", () => {
|
||||
});
|
||||
expect(result[0]).toEqual(
|
||||
searchResult({
|
||||
mediaMetadata: tracks.map((it) =>
|
||||
track(bonobUrlWithAccessToken, it)
|
||||
mediaCollection: tracks.map((it) =>
|
||||
album(bonobUrlWithAccessToken, it.album)
|
||||
),
|
||||
index: 0,
|
||||
total: 2,
|
||||
@@ -2356,10 +2358,8 @@ describe("wsdl api", () => {
|
||||
});
|
||||
|
||||
describe("asking for an album", () => {
|
||||
const album = anAlbum();
|
||||
const artist = anArtist({
|
||||
albums: [album],
|
||||
});
|
||||
const album = anAlbumSummary();
|
||||
const artist = anArtistSummary();
|
||||
|
||||
const track1 = aTrack({ artist, album, number: 1 });
|
||||
const track2 = aTrack({ artist, album, number: 2 });
|
||||
@@ -2370,7 +2370,12 @@ describe("wsdl api", () => {
|
||||
const tracks = [track1, track2, track3, track4, track5];
|
||||
|
||||
beforeEach(() => {
|
||||
musicLibrary.tracks.mockResolvedValue(tracks);
|
||||
musicLibrary.album.mockResolvedValue(anAlbum({
|
||||
...album,
|
||||
artistName: artist.name,
|
||||
artistId: artist.id,
|
||||
tracks
|
||||
}));
|
||||
});
|
||||
|
||||
describe("asking for all for an album", () => {
|
||||
@@ -2394,7 +2399,7 @@ describe("wsdl api", () => {
|
||||
total: tracks.length,
|
||||
})
|
||||
);
|
||||
expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id);
|
||||
expect(musicLibrary.album).toHaveBeenCalledWith(album.id);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2421,7 +2426,7 @@ describe("wsdl api", () => {
|
||||
total: tracks.length,
|
||||
})
|
||||
);
|
||||
expect(musicLibrary.tracks).toHaveBeenCalledWith(album.id);
|
||||
expect(musicLibrary.album).toHaveBeenCalledWith(album.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||