mirror of
https://github.com/wkulhanek/bonob.git
synced 2025-12-21 17:33:29 +01:00
Migrate Navidrome support to generic subsonic clone support (#55)
Renaming BONOB_* env vars to BNB_*
This commit is contained in:
@@ -31,9 +31,9 @@ RUN apk add --no-cache --update --virtual .gyp \
|
||||
|
||||
FROM node:16.6-alpine
|
||||
|
||||
ENV BONOB_PORT=4534
|
||||
ENV BNB_PORT=4534
|
||||
|
||||
EXPOSE $BONOB_PORT
|
||||
EXPOSE $BNB_PORT
|
||||
|
||||
WORKDIR /bonob
|
||||
|
||||
|
||||
98
README.md
98
README.md
@@ -2,13 +2,13 @@
|
||||
|
||||
A sonos SMAPI implementation to allow registering sources of music with sonos.
|
||||
|
||||
Currently only a single integration allowing Navidrome to be registered with sonos. In theory as Navidrome implements the subsonic API, it *may* work with other subsonic api clones.
|
||||
Support for Subsonic API clones (tested against Navidrome and Gonic).
|
||||
|
||||

|
||||
|
||||
## Features
|
||||
|
||||
- Integrates with Navidrome
|
||||
- Integrates with Subsonic API clones (Navidrome, Gonic)
|
||||
- Browse by Artist, Albums, Genres, Playlist, Random Albums, Starred Albums, Recently Added Albums, Recently Played Albums, Most Played Albums
|
||||
- Artist Art
|
||||
- Album Art
|
||||
@@ -33,8 +33,8 @@ bonob is ditributed via docker and can be run in a number of ways
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-e BONOB_SONOS_AUTO_REGISTER=true \
|
||||
-e BONOB_SONOS_DEVICE_DISCOVERY=true \
|
||||
-e BNB_SONOS_AUTO_REGISTER=true \
|
||||
-e BNB_SONOS_DEVICE_DISCOVERY=true \
|
||||
-p 4534:4534 \
|
||||
--network host \
|
||||
simojenki/bonob
|
||||
@@ -46,10 +46,10 @@ Now open http://localhost:4534 in your browser, you should see sonos devices, an
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-e BONOB_PORT=3000 \
|
||||
-e BONOB_SONOS_SEED_HOST=192.168.1.123 \
|
||||
-e BONOB_SONOS_AUTO_REGISTER=true \
|
||||
-e BONOB_SONOS_DEVICE_DISCOVERY=true \
|
||||
-e BNB_PORT=3000 \
|
||||
-e BNB_SONOS_SEED_HOST=192.168.1.123 \
|
||||
-e BNB_SONOS_AUTO_REGISTER=true \
|
||||
-e BNB_SONOS_DEVICE_DISCOVERY=true \
|
||||
-p 3000:3000 \
|
||||
simojenki/bonob
|
||||
```
|
||||
@@ -66,13 +66,13 @@ Start bonob outside the LAN with sonos discovery & registration disabled as they
|
||||
|
||||
```bash
|
||||
docker run \
|
||||
-e BONOB_PORT=4534 \
|
||||
-e BONOB_SONOS_SERVICE_NAME=MyAwesomeMusic \
|
||||
-e BONOB_SECRET=changeme \
|
||||
-e BONOB_URL=https://my-server.example.com/bonob \
|
||||
-e BONOB_SONOS_AUTO_REGISTER=false \
|
||||
-e BONOB_SONOS_DEVICE_DISCOVERY=false \
|
||||
-e BONOB_NAVIDROME_URL=https://my-navidrome-service.com:4533 \
|
||||
-e BNB_PORT=4534 \
|
||||
-e BNB_SONOS_SERVICE_NAME=MyAwesomeMusic \
|
||||
-e BNB_SECRET=changeme \
|
||||
-e BNB_URL=https://my-server.example.com/bonob \
|
||||
-e BNB_SONOS_AUTO_REGISTER=false \
|
||||
-e BNB_SONOS_DEVICE_DISCOVERY=false \
|
||||
-e BNB_SUBSONIC_URL=https://my-navidrome-service.com:4533 \
|
||||
-p 4534:4534 \
|
||||
simojenki/bonob
|
||||
```
|
||||
@@ -93,7 +93,7 @@ docker run \
|
||||
```bash
|
||||
docker run \
|
||||
--rm \
|
||||
-e BONOB_SONOS_SEED_HOST=192.168.1.163 \
|
||||
-e BNB_SONOS_SEED_HOST=192.168.1.163 \
|
||||
simojenki/bonob register https://my-server.example.com/bonob
|
||||
```
|
||||
|
||||
@@ -124,52 +124,52 @@ services:
|
||||
- "4534:4534"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BONOB_PORT: 4534
|
||||
BNB_PORT: 4534
|
||||
# ip address of your machine running bonob
|
||||
BONOB_URL: http://192.168.1.111:4534
|
||||
BONOB_SECRET: changeme
|
||||
BONOB_SONOS_AUTO_REGISTER: true
|
||||
BONOB_SONOS_DEVICE_DISCOVERY: true
|
||||
BONOB_SONOS_SERVICE_ID: 246
|
||||
BNB_URL: http://192.168.1.111:4534
|
||||
BNB_SECRET: changeme
|
||||
BNB_SONOS_AUTO_REGISTER: true
|
||||
BNB_SONOS_DEVICE_DISCOVERY: true
|
||||
BNB_SONOS_SERVICE_ID: 246
|
||||
# ip address of one of your sonos devices
|
||||
BONOB_SONOS_SEED_HOST: 192.168.1.121
|
||||
BONOB_NAVIDROME_URL: http://navidrome:4533
|
||||
BNB_SONOS_SEED_HOST: 192.168.1.121
|
||||
BNB_SUBSONIC_URL: http://navidrome:4533
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
item | default value | description
|
||||
---- | ------------- | -----------
|
||||
BONOB_PORT | 4534 | Default http port for bonob to listen on
|
||||
BONOB_URL | http://$(hostname):4534 | URL (including path) for bonob so that sonos devices can communicate. **This must be either the public IP or DNS entry of the bonob instance so that the sonos devices can communicate with it.**
|
||||
BONOB_SECRET | bonob | secret used for encrypting credentials
|
||||
BONOB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register on startup
|
||||
BONOB_SONOS_DEVICE_DISCOVERY | true | Enable/Disable sonos device discovery entirely. Setting this to 'false' will disable sonos device search, regardless of whether a seed host is specified.
|
||||
BONOB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or ommitted for for auto-discovery
|
||||
BONOB_SONOS_SERVICE_NAME | bonob | service name for sonos
|
||||
BONOB_SONOS_SERVICE_ID | 246 | service id for sonos
|
||||
BONOB_NAVIDROME_URL | http://$(hostname):4533 | URL for navidrome
|
||||
BONOB_NAVIDROME_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom navidrome clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.
|
||||
BONOB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s
|
||||
BONOB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing
|
||||
BONOB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html)
|
||||
BONOB_ICON_BACKGROUND_COLOR | undefined | Icon background color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html)
|
||||
BNB_PORT | 4534 | Default http port for bonob to listen on
|
||||
BNB_URL | http://$(hostname):4534 | URL (including path) for bonob so that sonos devices can communicate. **This must be either the public IP or DNS entry of the bonob instance so that the sonos devices can communicate with it.**
|
||||
BNB_SECRET | bonob | secret used for encrypting credentials
|
||||
BNB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register on startup
|
||||
BNB_SONOS_DEVICE_DISCOVERY | true | Enable/Disable sonos device discovery entirely. Setting this to 'false' will disable sonos device search, regardless of whether a seed host is specified.
|
||||
BNB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or ommitted for for auto-discovery
|
||||
BNB_SONOS_SERVICE_NAME | bonob | service name for sonos
|
||||
BNB_SONOS_SERVICE_ID | 246 | service id for sonos
|
||||
BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone
|
||||
BNB_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. ie. "audio/flac,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs.
|
||||
BNB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s
|
||||
BNB_REPORT_NOW_PLAYING | true | Whether to report a track as now playing
|
||||
BNB_ICON_FOREGROUND_COLOR | undefined | Icon foreground color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html)
|
||||
BNB_ICON_BACKGROUND_COLOR | undefined | Icon background color in sonos app, must be a valid [svg color](https://www.december.com/html/spec/colorsvg.html)
|
||||
|
||||
## Initialising service within sonos app
|
||||
|
||||
- Configure bonob, make sure to set BONOB_URL. **bonob must be accessible from your sonos devices on BONOB_URL, otherwise it will fail to initialise within the sonos app, so make sure you test this in your browser by putting BONOB_URL in the address bar and seeing the bonob information page**
|
||||
- Configure bonob, make sure to set BNB_URL. **bonob must be accessible from your sonos devices on BNB_URL, otherwise it will fail to initialise within the sonos app, so make sure you test this in your browser by putting BNB_URL in the address bar and seeing the bonob information page**
|
||||
- Start bonob,
|
||||
- Open sonos app on your device
|
||||
- Settings -> Services & Voice -> + Add a Service
|
||||
- Select your Music Service, default name is 'bonob', can be overriden with configuration BONOB_SONOS_SERVICE_NAME
|
||||
- Select your Music Service, default name is 'bonob', can be overriden with configuration BNB_SONOS_SERVICE_NAME
|
||||
- Press 'Add to Sonos' -> 'Linking sonos with bonob' -> Authorize
|
||||
- Your device should open a browser and you should now see a login screen, enter your navidrome credentials
|
||||
- Your device should open a browser and you should now see a login screen, enter your subsonic clone credentials
|
||||
- You should get 'Login successful!'
|
||||
- Go back into the sonos app and complete the process
|
||||
- You should now be able to play music from navidrome
|
||||
- Within navidrome a new player will be created, 'bonob (username)', so you can configure transcoding specifically for sonos
|
||||
- You should now be able to play music on your sonos devices from you subsonic clone
|
||||
- Within the subsonic clone a new player will be created, 'bonob (username)', so you can configure transcoding specifically for sonos
|
||||
|
||||
## Implementing a different music source other than navidrome
|
||||
## Implementing a different music source other than a subsonic clone
|
||||
|
||||
- Implement the MusicService/MusicLibrary interface
|
||||
- Startup bonob with your new implementation.
|
||||
@@ -183,7 +183,7 @@ In some situations you may wish to have different 'Players' within Navidrome so
|
||||
In this case you could set;
|
||||
|
||||
```bash
|
||||
BONOB_NAVIDROME_CUSTOM_CLIENTS="audio/flac"
|
||||
BNB_SUBSONIC_CUSTOM_CLIENTS="audio/flac"
|
||||
```
|
||||
|
||||
This would result in 2 players in Navidrome, one called 'bonob', the other called 'bonob+audio/flac'. You could then configure a custom flac transcoder in Navidrome that re-samples the flacs to a sonos supported format, ie [Using something like this](https://stackoverflow.com/questions/41420391/ffmpeg-flac-24-bit-96khz-to-16-bit-48khz);
|
||||
@@ -195,15 +195,15 @@ ffmpeg -i %s -af aresample=resampler=soxr:out_sample_fmt=s16:out_sample_rate=480
|
||||
### Changing Icon colors
|
||||
|
||||
```bash
|
||||
-e BONOB_ICON_FOREGROUND_COLOR=white \
|
||||
-e BONOB_ICON_BACKGROUND_COLOR=darkgrey
|
||||
-e BNB_ICON_FOREGROUND_COLOR=white \
|
||||
-e BNB_ICON_BACKGROUND_COLOR=darkgrey
|
||||
```
|
||||
|
||||

|
||||
|
||||
```bash
|
||||
-e BONOB_ICON_FOREGROUND_COLOR=chartreuse \
|
||||
-e BONOB_ICON_BACKGROUND_COLOR=fuchsia
|
||||
-e BNB_ICON_FOREGROUND_COLOR=chartreuse \
|
||||
-e BNB_ICON_BACKGROUND_COLOR=fuchsia
|
||||
```
|
||||
|
||||

|
||||
|
||||
@@ -22,13 +22,13 @@ services:
|
||||
- "4534:4534"
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
BONOB_PORT: 4534
|
||||
BNB_PORT: 4534
|
||||
# ip address of your machine running bonob
|
||||
BONOB_URL: http://192.168.1.111:4534
|
||||
BONOB_SECRET: changeme
|
||||
BONOB_SONOS_SERVICE_ID: 246
|
||||
BONOB_SONOS_AUTO_REGISTER: "true"
|
||||
BONOB_SONOS_DEVICE_DISCOVERY: "true"
|
||||
BNB_URL: http://192.168.1.111:4534
|
||||
BNB_SECRET: changeme
|
||||
BNB_SONOS_SERVICE_ID: 246
|
||||
BNB_SONOS_AUTO_REGISTER: "true"
|
||||
BNB_SONOS_DEVICE_DISCOVERY: "true"
|
||||
# ip address of one of your sonos devices
|
||||
BONOB_SONOS_SEED_HOST: 192.168.1.121
|
||||
BONOB_NAVIDROME_URL: http://navidrome:4533
|
||||
BNB_SONOS_SEED_HOST: 192.168.1.121
|
||||
BNB_SUBSONIC_URL: http://navidrome:4533
|
||||
|
||||
@@ -50,8 +50,8 @@
|
||||
"scripts": {
|
||||
"clean": "rm -Rf build node_modules",
|
||||
"build": "tsc",
|
||||
"dev": "BONOB_ICON_FOREGROUND_COLOR=white BONOB_ICON_BACKGROUND_COLOR=darkgrey BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts",
|
||||
"devr": "BONOB_ICON_FOREGROUND_COLOR=white BONOB_ICON_BACKGROUND_COLOR=darkgrey BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts",
|
||||
"dev": "BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts",
|
||||
"devr": "BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts",
|
||||
"register-dev": "ts-node ./src/register.ts http://$(hostname):4534",
|
||||
"test": "jest",
|
||||
"gitinfo": "git describe --tags > .gitinfo"
|
||||
|
||||
14
src/app.ts
14
src/app.ts
@@ -2,7 +2,7 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import server from "./server";
|
||||
import logger from "./logger";
|
||||
import { appendMimeTypeToClientFor, DEFAULT, Navidrome } from "./navidrome";
|
||||
import { appendMimeTypeToClientFor, DEFAULT, Subsonic } from "./subsonic";
|
||||
import encryption from "./encryption";
|
||||
import { InMemoryAccessTokens, sha256 } from "./access_tokens";
|
||||
import { InMemoryLinkCodes } from "./link_codes";
|
||||
@@ -24,20 +24,20 @@ const bonob = bonobService(
|
||||
|
||||
const sonosSystem = sonos(config.sonos.discovery);
|
||||
|
||||
const streamUserAgent = config.navidrome.customClientsFor
|
||||
? appendMimeTypeToClientFor(config.navidrome.customClientsFor.split(","))
|
||||
const streamUserAgent = config.subsonic.customClientsFor
|
||||
? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(","))
|
||||
: DEFAULT;
|
||||
|
||||
const navidrome = new Navidrome(
|
||||
config.navidrome.url,
|
||||
const subsonic = new Subsonic(
|
||||
config.subsonic.url,
|
||||
encryption(config.secret),
|
||||
streamUserAgent
|
||||
);
|
||||
|
||||
const featureFlagAwareMusicService: MusicService = {
|
||||
generateToken: navidrome.generateToken,
|
||||
generateToken: subsonic.generateToken,
|
||||
login: (authToken: string) =>
|
||||
navidrome.login(authToken).then((library) => {
|
||||
subsonic.login(authToken).then((library) => {
|
||||
return {
|
||||
...library,
|
||||
scrobble: (id: string) => {
|
||||
|
||||
@@ -2,56 +2,91 @@ import { hostname } from "os";
|
||||
import logger from "./logger";
|
||||
import url from "./url_builder";
|
||||
|
||||
export const WORD = /^\w+$/;
|
||||
|
||||
type EnvVarOpts = {
|
||||
default: string | undefined;
|
||||
legacy: string[] | undefined;
|
||||
validationPattern: RegExp | undefined;
|
||||
};
|
||||
|
||||
export function envVar(
|
||||
name: string,
|
||||
opts: Partial<EnvVarOpts> = {
|
||||
default: undefined,
|
||||
legacy: undefined,
|
||||
validationPattern: undefined,
|
||||
}
|
||||
) {
|
||||
const result = [name, ...(opts.legacy || [])]
|
||||
.map((it) => ({ key: it, value: process.env[it] }))
|
||||
.find((it) => it.value);
|
||||
|
||||
if (
|
||||
result &&
|
||||
result.value &&
|
||||
opts.validationPattern &&
|
||||
!result.value.match(opts.validationPattern)
|
||||
) {
|
||||
throw `Invalid value specified for '${name}', must match ${opts.validationPattern}`;
|
||||
}
|
||||
|
||||
if(result && result.value && result.key != name) {
|
||||
logger.warn(`Configuration key '${result.key}' is deprecated, replace with '${name}'`)
|
||||
}
|
||||
|
||||
return result?.value || opts.default;
|
||||
}
|
||||
|
||||
export const bnbEnvVar = (key: string, opts: Partial<EnvVarOpts> = {}) =>
|
||||
envVar(`BNB_${key}`, {
|
||||
...opts,
|
||||
legacy: [`BONOB_${key}`, ...(opts.legacy || [])],
|
||||
});
|
||||
|
||||
export default function () {
|
||||
const port = +(process.env["BONOB_PORT"] || 4534);
|
||||
const bonobUrl =
|
||||
process.env["BONOB_URL"] ||
|
||||
process.env["BONOB_WEB_ADDRESS"] ||
|
||||
`http://${hostname()}:${port}`;
|
||||
const port = +bnbEnvVar("PORT", { default: "4534" })!;
|
||||
const bonobUrl = bnbEnvVar("URL", {
|
||||
legacy: ["BONOB_WEB_ADDRESS"],
|
||||
default: `http://${hostname()}:${port}`,
|
||||
})!;
|
||||
|
||||
if (bonobUrl.match("localhost")) {
|
||||
logger.error(
|
||||
"BONOB_URL containing localhost is almost certainly incorrect, sonos devices will not be able to communicate with bonob using localhost, please specify either public IP or DNS entry"
|
||||
"BNB_URL containing localhost is almost certainly incorrect, sonos devices will not be able to communicate with bonob using localhost, please specify either public IP or DNS entry"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const wordFrom = (envVar: string) => {
|
||||
const value = process.env[envVar];
|
||||
if (value && value != "") {
|
||||
if (value.match(/^\w+$/)) return value;
|
||||
else throw `Invalid color specified for ${envVar}`;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
port,
|
||||
bonobUrl: url(bonobUrl),
|
||||
secret: process.env["BONOB_SECRET"] || "bonob",
|
||||
secret: bnbEnvVar("SECRET", { default: "bonob" })!,
|
||||
icons: {
|
||||
foregroundColor: wordFrom("BONOB_ICON_FOREGROUND_COLOR"),
|
||||
backgroundColor: wordFrom("BONOB_ICON_BACKGROUND_COLOR"),
|
||||
foregroundColor: bnbEnvVar("ICON_FOREGROUND_COLOR", {
|
||||
validationPattern: WORD,
|
||||
}),
|
||||
backgroundColor: bnbEnvVar("ICON_BACKGROUND_COLOR", {
|
||||
validationPattern: WORD,
|
||||
}),
|
||||
},
|
||||
sonos: {
|
||||
serviceName: process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob",
|
||||
serviceName: bnbEnvVar("SONOS_SERVICE_NAME", { default: "bonob" })!,
|
||||
discovery: {
|
||||
enabled:
|
||||
(process.env["BONOB_SONOS_DEVICE_DISCOVERY"] || "true") == "true",
|
||||
seedHost: process.env["BONOB_SONOS_SEED_HOST"],
|
||||
bnbEnvVar("SONOS_DEVICE_DISCOVERY", { default: "true" }) == "true",
|
||||
seedHost: bnbEnvVar("SONOS_SEED_HOST"),
|
||||
},
|
||||
autoRegister:
|
||||
(process.env["BONOB_SONOS_AUTO_REGISTER"] || "false") == "true",
|
||||
sid: Number(process.env["BONOB_SONOS_SERVICE_ID"] || "246"),
|
||||
bnbEnvVar("SONOS_AUTO_REGISTER", { default: "false" }) == "true",
|
||||
sid: Number(bnbEnvVar("SONOS_SERVICE_ID", { default: "246" })),
|
||||
},
|
||||
navidrome: {
|
||||
url: process.env["BONOB_NAVIDROME_URL"] || `http://${hostname()}:4533`,
|
||||
customClientsFor:
|
||||
process.env["BONOB_NAVIDROME_CUSTOM_CLIENTS"] || undefined,
|
||||
subsonic: {
|
||||
url: bnbEnvVar("SUBSONIC_URL", { legacy: ["BONOB_NAVIDROME_URL"], default: `http://${hostname()}:4533` })!,
|
||||
customClientsFor: bnbEnvVar("SUBSONIC_CUSTOM_CLIENTS", { legacy: ["BONOB_NAVIDROME_CUSTOM_CLIENTS"] }),
|
||||
},
|
||||
scrobbleTracks: (process.env["BONOB_SCROBBLE_TRACKS"] || "true") == "true",
|
||||
scrobbleTracks: bnbEnvVar("SCROBBLE_TRACKS", { default: "true" }) == "true",
|
||||
reportNowPlaying:
|
||||
(process.env["BONOB_REPORT_NOW_PLAYING"] || "true") == "true",
|
||||
bnbEnvVar("REPORT_NOW_PLAYING", { default: "true" }) == "true",
|
||||
};
|
||||
}
|
||||
|
||||
10
src/i8n.ts
10
src/i8n.ts
@@ -41,7 +41,7 @@ export type KEY =
|
||||
|
||||
const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
"en-US": {
|
||||
AppLinkMessage: "Linking sonos with $BONOB_SONOS_SERVICE_NAME",
|
||||
AppLinkMessage: "Linking sonos with $BNB_SONOS_SERVICE_NAME",
|
||||
artists: "Artists",
|
||||
albums: "Albums",
|
||||
tracks: "Tracks",
|
||||
@@ -62,7 +62,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
devices: "Devices",
|
||||
services: "Services",
|
||||
login: "Login",
|
||||
logInToBonob: "Log in to $BONOB_SONOS_SERVICE_NAME",
|
||||
logInToBonob: "Log in to $BNB_SONOS_SERVICE_NAME",
|
||||
username: "Username",
|
||||
password: "Password",
|
||||
successfullyRegistered: "Successfully registered",
|
||||
@@ -75,7 +75,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
noSonosDevices: "No sonos devices",
|
||||
},
|
||||
"nl-NL": {
|
||||
AppLinkMessage: "Sonos koppelen aan $BONOB_SONOS_SERVICE_NAME",
|
||||
AppLinkMessage: "Sonos koppelen aan $BNB_SONOS_SERVICE_NAME",
|
||||
artists: "Artiesten",
|
||||
albums: "Albums",
|
||||
tracks: "Nummers",
|
||||
@@ -96,7 +96,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
|
||||
devices: "Apparaten",
|
||||
services: "Services",
|
||||
login: "Inloggen",
|
||||
logInToBonob: "Login op $BONOB_SONOS_SERVICE_NAME",
|
||||
logInToBonob: "Login op $BNB_SONOS_SERVICE_NAME",
|
||||
username: "Gebruikersnaam",
|
||||
password: "Wachtwoord",
|
||||
successfullyRegistered: "Registratie geslaagd",
|
||||
@@ -151,7 +151,7 @@ export default (serviceName: string): I8N =>
|
||||
translations["en-US"];
|
||||
return (key: KEY) => {
|
||||
const value = langToUse[key]?.replace(
|
||||
"$BONOB_SONOS_SERVICE_NAME",
|
||||
"$BNB_SONOS_SERVICE_NAME",
|
||||
serviceName
|
||||
);
|
||||
if (value) return value;
|
||||
|
||||
@@ -52,6 +52,7 @@ export type AlbumSummary = {
|
||||
name: string;
|
||||
year: string | undefined;
|
||||
genre: Genre | undefined;
|
||||
coverArt: string | undefined;
|
||||
|
||||
artistName: string;
|
||||
artistId: string;
|
||||
@@ -71,6 +72,7 @@ export type Track = {
|
||||
duration: number;
|
||||
number: number | undefined;
|
||||
genre: Genre | undefined;
|
||||
coverArt: string | undefined;
|
||||
album: AlbumSummary;
|
||||
artist: ArtistSummary;
|
||||
};
|
||||
@@ -118,6 +120,7 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
|
||||
genre: it.genre,
|
||||
artistName: it.artistName,
|
||||
artistId: it.artistId,
|
||||
coverArt: it.coverArt
|
||||
});
|
||||
|
||||
export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({
|
||||
@@ -174,7 +177,7 @@ export interface MusicLibrary {
|
||||
trackId: string;
|
||||
range: string | undefined;
|
||||
}): Promise<TrackStream>;
|
||||
coverArt(id: string, type: "album" | "artist", size?: number): Promise<CoverArt | undefined>;
|
||||
coverArt(id: string, size?: number): Promise<CoverArt | undefined>;
|
||||
nowPlaying(id: string): Promise<boolean>
|
||||
scrobble(id: string): Promise<boolean>
|
||||
searchArtists(query: string): Promise<ArtistSummary[]>;
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
LOGIN_ROUTE,
|
||||
CREATE_REGISTRATION_ROUTE,
|
||||
REMOVE_REGISTRATION_ROUTE,
|
||||
sonosifyMimeType,
|
||||
} from "./smapi";
|
||||
import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
|
||||
import { MusicService, isSuccess } from "./music_service";
|
||||
@@ -317,6 +318,13 @@ function server(
|
||||
}, headers=(${JSON.stringify(stream.headers)})`
|
||||
);
|
||||
|
||||
const sonosisfyContentType = (contentType: string) =>
|
||||
contentType
|
||||
.split(";")
|
||||
.map((it) => it.trim())
|
||||
.map((it) => sonosifyMimeType(it))
|
||||
.join("; ");
|
||||
|
||||
const respondWith = ({
|
||||
status,
|
||||
filter,
|
||||
@@ -326,7 +334,7 @@ function server(
|
||||
}: {
|
||||
status: number;
|
||||
filter: Transform;
|
||||
headers: Record<string, string | undefined>;
|
||||
headers: Record<string, string>;
|
||||
sendStream: boolean;
|
||||
nowPlaying: boolean;
|
||||
}) => {
|
||||
@@ -340,9 +348,11 @@ function server(
|
||||
: Promise.resolve(true)
|
||||
).then((_) => {
|
||||
res.status(status);
|
||||
Object.entries(stream.headers)
|
||||
Object.entries(headers)
|
||||
.filter(([_, v]) => v !== undefined)
|
||||
.forEach(([header, value]) => res.setHeader(header, value));
|
||||
.forEach(([header, value]) => {
|
||||
res.setHeader(header, value!);
|
||||
});
|
||||
if (sendStream) stream.stream.pipe(filter).pipe(res);
|
||||
else res.send();
|
||||
});
|
||||
@@ -353,7 +363,9 @@ function server(
|
||||
status: 200,
|
||||
filter: new PassThrough(),
|
||||
headers: {
|
||||
"content-type": stream.headers["content-type"],
|
||||
"content-type": sonosisfyContentType(
|
||||
stream.headers["content-type"]
|
||||
),
|
||||
"content-length": stream.headers["content-length"],
|
||||
"accept-ranges": stream.headers["accept-ranges"],
|
||||
},
|
||||
@@ -365,7 +377,9 @@ function server(
|
||||
status: 206,
|
||||
filter: new PassThrough(),
|
||||
headers: {
|
||||
"content-type": stream.headers["content-type"],
|
||||
"content-type": sonosisfyContentType(
|
||||
stream.headers["content-type"]
|
||||
),
|
||||
"content-length": stream.headers["content-length"],
|
||||
"content-range": stream.headers["content-range"],
|
||||
"accept-ranges": stream.headers["accept-ranges"],
|
||||
@@ -457,25 +471,22 @@ function server(
|
||||
"centre",
|
||||
];
|
||||
|
||||
app.get("/art/:type/:ids/size/:size", (req, res) => {
|
||||
app.get("/art/:ids/size/:size", (req, res) => {
|
||||
const authToken = accessTokens.authTokenFor(
|
||||
req.query[BONOB_ACCESS_TOKEN_HEADER] as string
|
||||
);
|
||||
const type = req.params["type"]!;
|
||||
const ids = req.params["ids"]!.split("&");
|
||||
const size = Number.parseInt(req.params["size"]!);
|
||||
|
||||
if (!authToken) {
|
||||
return res.status(401).send();
|
||||
} else if (type != "artist" && type != "album") {
|
||||
return res.status(400).send();
|
||||
} else if (!(size > 0)) {
|
||||
return res.status(400).send();
|
||||
}
|
||||
|
||||
return musicService
|
||||
.login(authToken)
|
||||
.then((it) => Promise.all(ids.map((id) => it.coverArt(id, type, size))))
|
||||
.then((it) => Promise.all(ids.map((id) => it.coverArt(id, size))))
|
||||
.then((coverArts) => coverArts.filter((it) => it))
|
||||
.then(shuffle)
|
||||
.then((coverArts) => {
|
||||
@@ -513,12 +524,9 @@ function server(
|
||||
}
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
logger.error(
|
||||
`Failed fetching image ${type}/${ids.join("&")}/size/${size}`,
|
||||
{
|
||||
logger.error(`Failed fetching image ${ids.join("&")}/size/${size}`, {
|
||||
cause: e,
|
||||
}
|
||||
);
|
||||
});
|
||||
return res.status(500).send();
|
||||
});
|
||||
});
|
||||
|
||||
59
src/smapi.ts
59
src/smapi.ts
@@ -215,10 +215,7 @@ const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
|
||||
itemType: "container",
|
||||
id: `genre:${genre.id}`,
|
||||
title: genre.name,
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
iconForGenre(genre.name)
|
||||
).href(),
|
||||
albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(),
|
||||
});
|
||||
|
||||
const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
|
||||
@@ -238,31 +235,37 @@ export const playlistAlbumArtURL = (
|
||||
bonobUrl: URLBuilder,
|
||||
playlist: Playlist
|
||||
) => {
|
||||
const ids = uniq(playlist.entries.map((it) => it.album?.id).filter((it) => it));
|
||||
const ids = uniq(
|
||||
playlist.entries.map((it) => it.coverArt).filter((it) => it)
|
||||
);
|
||||
if (ids.length == 0) {
|
||||
return iconArtURI(bonobUrl, "error");
|
||||
} else {
|
||||
return bonobUrl.append({
|
||||
pathname: `/art/album/${ids.slice(0, 9).join("&")}/size/180`
|
||||
pathname: `/art/${ids.slice(0, 9).join("&")}/size/180`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) =>
|
||||
bonobUrl.append({ pathname: `/art/album/${album.id}/size/180` });
|
||||
|
||||
export const iconArtURI = (
|
||||
export const defaultAlbumArtURI = (
|
||||
bonobUrl: URLBuilder,
|
||||
icon: ICON
|
||||
{ coverArt }: { coverArt: string | undefined }
|
||||
) =>
|
||||
coverArt
|
||||
? bonobUrl.append({ pathname: `/art/${coverArt}/size/180` })
|
||||
: iconArtURI(bonobUrl, "vinyl");
|
||||
|
||||
export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) =>
|
||||
bonobUrl.append({
|
||||
pathname: `/icon/${icon}/size/legacy`
|
||||
pathname: `/icon/${icon}/size/legacy`,
|
||||
});
|
||||
|
||||
export const defaultArtistArtURI = (
|
||||
bonobUrl: URLBuilder,
|
||||
artist: ArtistSummary
|
||||
) => bonobUrl.append({ pathname: `/art/artist/${artist.id}/size/180` });
|
||||
) => bonobUrl.append({ pathname: `/art/artist:${artist.id}/size/180` });
|
||||
|
||||
export const sonosifyMimeType = (mimeType: string) => mimeType == "audio/x-flac" ? "audio/flac" : mimeType;
|
||||
|
||||
export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
|
||||
itemType: "album",
|
||||
@@ -281,17 +284,17 @@ export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
|
||||
export const track = (bonobUrl: URLBuilder, track: Track) => ({
|
||||
itemType: "track",
|
||||
id: `track:${track.id}`,
|
||||
mimeType: track.mimeType,
|
||||
mimeType: sonosifyMimeType(track.mimeType),
|
||||
title: track.name,
|
||||
|
||||
trackMetadata: {
|
||||
album: track.album.name,
|
||||
albumId: track.album.id,
|
||||
albumId: `album:${track.album.id}`,
|
||||
albumArtist: track.artist.name,
|
||||
albumArtistId: track.artist.id,
|
||||
albumArtURI: defaultAlbumArtURI(bonobUrl, track.album).href(),
|
||||
albumArtistId: `artist:${track.artist.id}`,
|
||||
albumArtURI: defaultAlbumArtURI(bonobUrl, track).href(),
|
||||
artist: track.artist.name,
|
||||
artistId: track.artist.id,
|
||||
artistId: `artist:${track.artist.id}`,
|
||||
duration: track.duration,
|
||||
genre: track.album.genre?.name,
|
||||
genreId: track.album.genre?.id,
|
||||
@@ -368,7 +371,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
const urlWithToken = (accessToken: string) =>
|
||||
bonobUrl.append({
|
||||
searchParams: {
|
||||
"bat": accessToken,
|
||||
bat: accessToken,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -506,23 +509,7 @@ function bindSmapiSoapServiceToExpress(
|
||||
return musicLibrary.track(typeId).then((it) => ({
|
||||
getExtendedMetadataResult: {
|
||||
mediaMetadata: {
|
||||
id: `track:${it.id}`,
|
||||
itemType: "track",
|
||||
title: it.name,
|
||||
mimeType: it.mimeType,
|
||||
trackMetadata: {
|
||||
artistId: `artist:${it.artist.id}`,
|
||||
artist: it.artist.name,
|
||||
albumId: `album:${it.album.id}`,
|
||||
album: it.album.name,
|
||||
genre: it.genre?.name,
|
||||
genreId: it.genre?.id,
|
||||
duration: it.duration,
|
||||
albumArtURI: defaultAlbumArtURI(
|
||||
urlWithToken(accessToken),
|
||||
it.album
|
||||
).href(),
|
||||
},
|
||||
...track(urlWithToken(accessToken), it)
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -148,7 +148,7 @@ export type song = {
|
||||
_artist: string;
|
||||
_track: string | undefined;
|
||||
_genre: string;
|
||||
_coverArt: string;
|
||||
_coverArt: string | undefined;
|
||||
_created: "2004-11-08T23:36:11";
|
||||
_duration: string | undefined;
|
||||
_bitRate: "128";
|
||||
@@ -179,6 +179,7 @@ export type entry = {
|
||||
_track: string;
|
||||
_year: string;
|
||||
_genre: string;
|
||||
_coverArt: string;
|
||||
_contentType: string;
|
||||
_duration: string;
|
||||
_albumId: string;
|
||||
@@ -223,6 +224,12 @@ export function isError(
|
||||
return (subsonicResponse as SubsonicError).error !== undefined;
|
||||
}
|
||||
|
||||
export const splitCoverArtId = (coverArt: string): [string, string] => {
|
||||
const parts = coverArt.split(":").filter(it => it.length > 0);
|
||||
if(parts.length < 2) throw `'${coverArt}' is an invalid coverArt id'`
|
||||
return [parts[0]!, parts.slice(1).join(":")];
|
||||
};
|
||||
|
||||
export type IdName = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -239,6 +246,8 @@ export type getAlbumListParams = {
|
||||
|
||||
export const MAX_ALBUM_LIST = 500;
|
||||
|
||||
const maybeAsCoverArt = (coverArt: string | undefined) => coverArt ? `coverArt:${coverArt}` : undefined
|
||||
|
||||
const asTrack = (album: Album, song: song) => ({
|
||||
id: song._id,
|
||||
name: song._title,
|
||||
@@ -246,6 +255,7 @@ const asTrack = (album: Album, song: song) => ({
|
||||
duration: parseInt(song._duration || "0"),
|
||||
number: parseInt(song._track || "0"),
|
||||
genre: maybeAsGenre(song._genre),
|
||||
coverArt: maybeAsCoverArt(song._coverArt),
|
||||
album,
|
||||
artist: {
|
||||
id: song._artistId,
|
||||
@@ -260,6 +270,7 @@ const asAlbum = (album: album) => ({
|
||||
genre: maybeAsGenre(album._genre),
|
||||
artistId: album._artistId,
|
||||
artistName: album._artist,
|
||||
coverArt: maybeAsCoverArt(album._coverArt)
|
||||
});
|
||||
|
||||
export const asGenre = (genreName: string) => ({
|
||||
@@ -298,7 +309,7 @@ export const asURLSearchParams = (q: any) => {
|
||||
return urlSearchParams;
|
||||
};
|
||||
|
||||
export class Navidrome implements MusicService {
|
||||
export class Subsonic implements MusicService {
|
||||
url: string;
|
||||
encryption: Encryption;
|
||||
streamClientApplication: StreamClientApplication;
|
||||
@@ -335,7 +346,7 @@ export class Navidrome implements MusicService {
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status != 200 && response.status != 206) {
|
||||
throw `Navidrome failed with a ${response.status || "no!"} status`;
|
||||
throw `Subsonic failed with a ${response.status || "no!"} status`;
|
||||
} else return response;
|
||||
});
|
||||
|
||||
@@ -368,7 +379,7 @@ export class Navidrome implements MusicService {
|
||||
)
|
||||
.then((json) => json["subsonic-response"])
|
||||
.then((json) => {
|
||||
if (isError(json)) throw `Navidrome error:${json.error._message}`;
|
||||
if (isError(json)) throw `Subsonic error:${json.error._message}`;
|
||||
else return json as unknown as T;
|
||||
});
|
||||
|
||||
@@ -427,6 +438,7 @@ export class Navidrome implements MusicService {
|
||||
genre: maybeAsGenre(album._genre),
|
||||
artistId: album._artistId,
|
||||
artistName: album._artist,
|
||||
coverArt: maybeAsCoverArt(album._coverArt)
|
||||
}));
|
||||
|
||||
getArtist = (
|
||||
@@ -440,14 +452,7 @@ export class Navidrome implements MusicService {
|
||||
.then((it) => ({
|
||||
id: it._id,
|
||||
name: it._name,
|
||||
albums: (it.album || []).map((album) => ({
|
||||
id: album._id,
|
||||
name: album._name,
|
||||
year: album._year,
|
||||
genre: maybeAsGenre(album._genre),
|
||||
artistId: it._id,
|
||||
artistName: it._name,
|
||||
})),
|
||||
albums: this.toAlbumSummary(it.album || []),
|
||||
}));
|
||||
|
||||
getArtistWithInfo = (credentials: Credentials, id: string) =>
|
||||
@@ -487,6 +492,7 @@ export class Navidrome implements MusicService {
|
||||
genre: maybeAsGenre(album._genre),
|
||||
artistId: album._artistId,
|
||||
artistName: album._artist,
|
||||
coverArt: maybeAsCoverArt(album._coverArt)
|
||||
}));
|
||||
|
||||
search3 = (credentials: Credentials, q: any) =>
|
||||
@@ -602,14 +608,16 @@ export class Navidrome implements MusicService {
|
||||
stream: res.data,
|
||||
}))
|
||||
),
|
||||
coverArt: async (id: string, type: "album" | "artist", size?: number) => {
|
||||
if (type == "album") {
|
||||
coverArt: async (coverArt: string, size?: number) => {
|
||||
const [type, id] = splitCoverArtId(coverArt);
|
||||
if (type == "coverArt") {
|
||||
return navidrome.getCoverArt(credentials, id, size).then((res) => ({
|
||||
contentType: res.headers["content-type"],
|
||||
data: Buffer.from(res.data, "binary"),
|
||||
}));
|
||||
} else {
|
||||
return navidrome.getArtistWithInfo(credentials, id).then((artist) => {
|
||||
const albumsWithCoverArt = artist.albums.filter(it => it.coverArt);
|
||||
if (artist.image.large) {
|
||||
return axios
|
||||
.get(artist.image.large!, {
|
||||
@@ -633,9 +641,9 @@ export class Navidrome implements MusicService {
|
||||
};
|
||||
}
|
||||
});
|
||||
} else if (artist.albums.length > 0) {
|
||||
} else if (albumsWithCoverArt.length > 0) {
|
||||
return navidrome
|
||||
.getCoverArt(credentials, artist.albums[0]!.id, size)
|
||||
.getCoverArt(credentials, splitCoverArtId(albumsWithCoverArt[0]!.coverArt!)[1], size)
|
||||
.then((res) => ({
|
||||
contentType: res.headers["content-type"],
|
||||
data: Buffer.from(res.data, "binary"),
|
||||
@@ -708,6 +716,7 @@ export class Navidrome implements MusicService {
|
||||
duration: parseInt(entry._duration || "0"),
|
||||
number: trackNumber++,
|
||||
genre: maybeAsGenre(entry._genre),
|
||||
coverArt: maybeAsCoverArt(entry._coverArt),
|
||||
album: {
|
||||
id: entry._albumId,
|
||||
name: entry._album,
|
||||
@@ -715,6 +724,7 @@ export class Navidrome implements MusicService {
|
||||
genre: maybeAsGenre(entry._genre),
|
||||
artistName: entry._artist,
|
||||
artistId: entry._artistId,
|
||||
coverArt: maybeAsCoverArt(entry._coverArt)
|
||||
},
|
||||
artist: {
|
||||
id: entry._artistId,
|
||||
@@ -141,6 +141,7 @@ export function aTrack(fields: Partial<Track> = {}): Track {
|
||||
genre,
|
||||
artist: artistToArtistSummary(artist),
|
||||
album: albumToAlbumSummary(anAlbum({ artistId: artist.id, artistName: artist.name, genre })),
|
||||
coverArt: `coverArt:${uuid()}`,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
@@ -154,6 +155,7 @@ export function anAlbum(fields: Partial<Album> = {}): Album {
|
||||
year: `19${randomInt(99)}`,
|
||||
artistId: `Artist ${uuid()}`,
|
||||
artistName: `Artist ${randomString()}`,
|
||||
coverArt: `coverArt:${uuid()}`,
|
||||
...fields,
|
||||
};
|
||||
}
|
||||
@@ -170,7 +172,8 @@ export const BLONDIE: Artist = {
|
||||
year: "1976",
|
||||
genre: NEW_WAVE,
|
||||
artistId: BLONDIE_ID,
|
||||
artistName: BLONDIE_NAME
|
||||
artistName: BLONDIE_NAME,
|
||||
coverArt: `coverArt:${uuid()}`
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
@@ -178,7 +181,8 @@ export const BLONDIE: Artist = {
|
||||
year: "1978",
|
||||
genre: POP_ROCK,
|
||||
artistId: BLONDIE_ID,
|
||||
artistName: BLONDIE_NAME
|
||||
artistName: BLONDIE_NAME,
|
||||
coverArt: `coverArt:${uuid()}`
|
||||
},
|
||||
],
|
||||
image: {
|
||||
@@ -195,9 +199,9 @@ export const BOB_MARLEY: Artist = {
|
||||
id: BOB_MARLEY_ID,
|
||||
name: BOB_MARLEY_NAME,
|
||||
albums: [
|
||||
{ id: uuid(), name: "Burin'", year: "1973", genre: REGGAE, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME },
|
||||
{ id: uuid(), name: "Exodus", year: "1977", genre: REGGAE, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME },
|
||||
{ id: uuid(), name: "Kaya", year: "1978", genre: SKA, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME },
|
||||
{ id: uuid(), name: "Burin'", year: "1973", genre: REGGAE, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, coverArt: `coverArt:${uuid()}` },
|
||||
{ id: uuid(), name: "Exodus", year: "1977", genre: REGGAE, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, coverArt: `coverArt:${uuid()}` },
|
||||
{ id: uuid(), name: "Kaya", year: "1978", genre: SKA, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, coverArt: `coverArt:${uuid()}` },
|
||||
],
|
||||
image: {
|
||||
small: "http://localhost/BOB_MARLEY/sml",
|
||||
@@ -234,6 +238,7 @@ export const METALLICA: Artist = {
|
||||
genre: METAL,
|
||||
artistId: METALLICA_ID,
|
||||
artistName: METALLICA_NAME,
|
||||
coverArt: `coverArt:${uuid()}`
|
||||
},
|
||||
{
|
||||
id: uuid(),
|
||||
@@ -242,6 +247,7 @@ export const METALLICA: Artist = {
|
||||
genre: METAL,
|
||||
artistId: METALLICA_ID,
|
||||
artistName: METALLICA_NAME,
|
||||
coverArt: `coverArt:${uuid()}`
|
||||
},
|
||||
],
|
||||
image: {
|
||||
|
||||
@@ -1,5 +1,79 @@
|
||||
import { hostname } from "os";
|
||||
import config from "../src/config";
|
||||
import config, { envVar, WORD } from "../src/config";
|
||||
|
||||
describe("envVar", () => {
|
||||
const OLD_ENV = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.resetModules();
|
||||
process.env = { ...OLD_ENV };
|
||||
|
||||
process.env["bnb-var"] = "bnb-var-value";
|
||||
process.env["bnb-legacy2"] = "bnb-legacy2-value";
|
||||
process.env["bnb-legacy3"] = "bnb-legacy3-value";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = OLD_ENV;
|
||||
});
|
||||
|
||||
describe("when the env var exists", () => {
|
||||
describe("and there are no legacy env vars that match", () => {
|
||||
it("should return the env var", () => {
|
||||
expect(envVar("bnb-var")).toEqual("bnb-var-value");
|
||||
});
|
||||
});
|
||||
|
||||
describe("and there are legacy env vars that match", () => {
|
||||
it("should return the env var", () => {
|
||||
expect(
|
||||
envVar("bnb-var", {
|
||||
default: "not valid",
|
||||
legacy: ["bnb-legacy1", "bnb-legacy2", "bnb-legacy3"],
|
||||
})
|
||||
).toEqual("bnb-var-value");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the env var doesnt exist", () => {
|
||||
describe("and there are no legacy env vars specified", () => {
|
||||
describe("and there is no default value specified", () => {
|
||||
it("should be undefined", () => {
|
||||
expect(envVar("bnb-not-set")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("and there is a default value specified", () => {
|
||||
it("should return the default", () => {
|
||||
expect(envVar("bnb-not-set", { default: "widget" })).toEqual(
|
||||
"widget"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when there are legacy env vars specified", () => {
|
||||
it("should return the value from the first matched legacy env var", () => {
|
||||
expect(
|
||||
envVar("bnb-not-set", {
|
||||
legacy: ["bnb-legacy1", "bnb-legacy2", "bnb-legacy3"],
|
||||
})
|
||||
).toEqual("bnb-legacy2-value");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("validationPattern", () => {
|
||||
it("should fail when the value does not match the pattern", () => {
|
||||
expect(
|
||||
() => envVar("bnb-var", {
|
||||
validationPattern: /^foobar$/,
|
||||
})
|
||||
).toThrowError(`Invalid value specified for 'bnb-var', must match ${/^foobar$/}`)
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
const OLD_ENV = process.env;
|
||||
@@ -43,26 +117,22 @@ describe("config", () => {
|
||||
}
|
||||
|
||||
describe("bonobUrl", () => {
|
||||
describe("when BONOB_URL is specified", () => {
|
||||
["BNB_URL", "BONOB_URL", "BONOB_WEB_ADDRESS"].forEach(key => {
|
||||
describe(`when ${key} is specified`, () => {
|
||||
it("should be used", () => {
|
||||
const url = "http://bonob1.example.com:8877/";
|
||||
process.env["BONOB_URL"] = url;
|
||||
|
||||
expect(config().bonobUrl.href()).toEqual(url);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when BONOB_URL is not specified, however legacy BONOB_WEB_ADDRESS is specified", () => {
|
||||
it("should be used", () => {
|
||||
const url = "http://bonob2.example.com:9988/";
|
||||
process.env["BNB_URL"] = "";
|
||||
process.env["BONOB_URL"] = "";
|
||||
process.env["BONOB_WEB_ADDRESS"] = url;
|
||||
process.env["BONOB_WEB_ADDRESS"] = "";
|
||||
process.env[key] = url;
|
||||
|
||||
expect(config().bonobUrl.href()).toEqual(url);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when neither BONOB_URL nor BONOB_WEB_ADDRESS are specified", () => {
|
||||
describe("when none of BNB_URL, BONOB_URL, BONOB_WEB_ADDRESS are specified", () => {
|
||||
describe("when BONOB_PORT is not specified", () => {
|
||||
it(`should default to http://${hostname()}:4534`, () => {
|
||||
expect(config().bonobUrl.href()).toEqual(
|
||||
@@ -71,6 +141,15 @@ describe("config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("when BNB_PORT is specified as 3322", () => {
|
||||
it(`should default to http://${hostname()}:3322`, () => {
|
||||
process.env["BNB_PORT"] = "3322";
|
||||
expect(config().bonobUrl.href()).toEqual(
|
||||
`http://${hostname()}:3322/`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when BONOB_PORT is specified as 3322", () => {
|
||||
it(`should default to http://${hostname()}:3322`, () => {
|
||||
process.env["BONOB_PORT"] = "3322";
|
||||
@@ -82,105 +161,86 @@ describe("config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("navidrome", () => {
|
||||
describe("url", () => {
|
||||
describe("when BONOB_NAVIDROME_URL is not specified", () => {
|
||||
it(`should default to http://${hostname()}:4533`, () => {
|
||||
expect(config().navidrome.url).toEqual(`http://${hostname()}:4533`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when BONOB_NAVIDROME_URL is ''", () => {
|
||||
it(`should default to http://${hostname()}:4533`, () => {
|
||||
process.env["BONOB_NAVIDROME_URL"] = "";
|
||||
expect(config().navidrome.url).toEqual(`http://${hostname()}:4533`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when BONOB_NAVIDROME_URL is specified", () => {
|
||||
it(`should use it`, () => {
|
||||
const url = "http://navidrome.example.com:1234";
|
||||
process.env["BONOB_NAVIDROME_URL"] = url;
|
||||
expect(config().navidrome.url).toEqual(url);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("icons", () => {
|
||||
describe("foregroundColor", () => {
|
||||
describe("when BONOB_ICON_FOREGROUND_COLOR is not specified", () => {
|
||||
["BNB_ICON_FOREGROUND_COLOR", "BONOB_ICON_FOREGROUND_COLOR"].forEach(k => {
|
||||
describe(`when ${k} is not specified`, () => {
|
||||
it(`should default to undefined`, () => {
|
||||
expect(config().icons.foregroundColor).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when BONOB_ICON_FOREGROUND_COLOR is ''", () => {
|
||||
describe(`when ${k} is ''`, () => {
|
||||
it(`should default to undefined`, () => {
|
||||
process.env["BONOB_ICON_FOREGROUND_COLOR"] = "";
|
||||
process.env[k] = "";
|
||||
expect(config().icons.foregroundColor).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when BONOB_ICON_FOREGROUND_COLOR is specified", () => {
|
||||
describe(`when ${k} is specified`, () => {
|
||||
it(`should use it`, () => {
|
||||
process.env["BONOB_ICON_FOREGROUND_COLOR"] = "pink";
|
||||
process.env[k] = "pink";
|
||||
expect(config().icons.foregroundColor).toEqual("pink");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when BONOB_ICON_FOREGROUND_COLOR is an invalid string", () => {
|
||||
describe(`when ${k} is an invalid string`, () => {
|
||||
it(`should blow up`, () => {
|
||||
process.env["BONOB_ICON_FOREGROUND_COLOR"] = "#dfasd";
|
||||
process.env[k] = "#dfasd";
|
||||
expect(() => config()).toThrow(
|
||||
"Invalid color specified for BONOB_ICON_FOREGROUND_COLOR"
|
||||
`Invalid value specified for 'BNB_ICON_FOREGROUND_COLOR', must match ${WORD}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("backgroundColor", () => {
|
||||
describe("when BONOB_ICON_BACKGROUND_COLOR is not specified", () => {
|
||||
["BNB_ICON_BACKGROUND_COLOR", "BONOB_ICON_BACKGROUND_COLOR"].forEach(k => {
|
||||
describe(`when ${k} is not specified`, () => {
|
||||
it(`should default to undefined`, () => {
|
||||
expect(config().icons.backgroundColor).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when BONOB_ICON_BACKGROUND_COLOR is ''", () => {
|
||||
describe(`when ${k} is ''`, () => {
|
||||
it(`should default to undefined`, () => {
|
||||
process.env["BONOB_ICON_BACKGROUND_COLOR"] = "";
|
||||
process.env[k] = "";
|
||||
expect(config().icons.backgroundColor).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when BONOB_ICON_BACKGROUND_COLOR is specified", () => {
|
||||
describe(`when ${k} is specified`, () => {
|
||||
it(`should use it`, () => {
|
||||
process.env["BONOB_ICON_BACKGROUND_COLOR"] = "blue";
|
||||
process.env[k] = "blue";
|
||||
expect(config().icons.backgroundColor).toEqual("blue");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when BONOB_ICON_BACKGROUND_COLOR is an invalid string", () => {
|
||||
describe(`when ${k} is an invalid string`, () => {
|
||||
it(`should blow up`, () => {
|
||||
process.env["BONOB_ICON_BACKGROUND_COLOR"] = "#red";
|
||||
process.env[k] = "#red";
|
||||
expect(() => config()).toThrow(
|
||||
"Invalid color specified for BONOB_ICON_BACKGROUND_COLOR"
|
||||
`Invalid value specified for 'BNB_ICON_BACKGROUND_COLOR', must match ${WORD}`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("secret", () => {
|
||||
it("should default to bonob", () => {
|
||||
expect(config().secret).toEqual("bonob");
|
||||
});
|
||||
|
||||
it("should be overridable", () => {
|
||||
process.env["BONOB_SECRET"] = "new secret";
|
||||
["BNB_SECRET", "BONOB_SECRET"].forEach(key => {
|
||||
it(`should be overridable using ${key}`, () => {
|
||||
process.env[key] = "new secret";
|
||||
expect(config().secret).toEqual("new secret");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sonos", () => {
|
||||
describe("serviceName", () => {
|
||||
@@ -188,83 +248,116 @@ describe("config", () => {
|
||||
expect(config().sonos.serviceName).toEqual("bonob");
|
||||
});
|
||||
|
||||
["BNB_SONOS_SERVICE_NAME", "BONOB_SONOS_SERVICE_NAME"].forEach(k => {
|
||||
it("should be overridable", () => {
|
||||
process.env["BONOB_SONOS_SERVICE_NAME"] = "foobar1000";
|
||||
process.env[k] = "foobar1000";
|
||||
expect(config().sonos.serviceName).toEqual("foobar1000");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
["BNB_SONOS_DEVICE_DISCOVERY", "BONOB_SONOS_DEVICE_DISCOVERY"].forEach(k => {
|
||||
describeBooleanConfigValue(
|
||||
"deviceDiscovery",
|
||||
"BONOB_SONOS_DEVICE_DISCOVERY",
|
||||
k,
|
||||
true,
|
||||
(config) => config.sonos.discovery.enabled
|
||||
);
|
||||
});
|
||||
|
||||
describe("seedHost", () => {
|
||||
it("should default to undefined", () => {
|
||||
expect(config().sonos.discovery.seedHost).toBeUndefined();
|
||||
});
|
||||
|
||||
["BNB_SONOS_SEED_HOST", "BONOB_SONOS_SEED_HOST"].forEach(k => {
|
||||
it("should be overridable", () => {
|
||||
process.env["BONOB_SONOS_SEED_HOST"] = "123.456.789.0";
|
||||
process.env[k] = "123.456.789.0";
|
||||
expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
["BNB_SONOS_AUTO_REGISTER", "BONOB_SONOS_AUTO_REGISTER"].forEach(k => {
|
||||
describeBooleanConfigValue(
|
||||
"autoRegister",
|
||||
"BONOB_SONOS_AUTO_REGISTER",
|
||||
k,
|
||||
false,
|
||||
(config) => config.sonos.autoRegister
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
describe("sid", () => {
|
||||
it("should default to 246", () => {
|
||||
expect(config().sonos.sid).toEqual(246);
|
||||
});
|
||||
|
||||
["BNB_SONOS_SERVICE_ID", "BONOB_SONOS_SERVICE_ID"].forEach(k => {
|
||||
it("should be overridable", () => {
|
||||
process.env["BONOB_SONOS_SERVICE_ID"] = "786";
|
||||
process.env[k] = "786";
|
||||
expect(config().sonos.sid).toEqual(786);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("navidrome", () => {
|
||||
describe("url", () => {
|
||||
it("should default to http://${hostname()}:4533", () => {
|
||||
expect(config().navidrome.url).toEqual(`http://${hostname()}:4533`);
|
||||
});
|
||||
|
||||
it("should be overridable", () => {
|
||||
process.env["BONOB_NAVIDROME_URL"] = "http://farfaraway.com";
|
||||
expect(config().navidrome.url).toEqual("http://farfaraway.com");
|
||||
describe("subsonic", () => {
|
||||
describe("url", () => {
|
||||
["BNB_SUBSONIC_URL", "BONOB_SUBSONIC_URL", "BONOB_NAVIDROME_URL"].forEach(k => {
|
||||
describe(`when ${k} is not specified`, () => {
|
||||
it(`should default to http://${hostname()}:4533`, () => {
|
||||
expect(config().subsonic.url).toEqual(`http://${hostname()}:4533`);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`when ${k} is ''`, () => {
|
||||
it(`should default to http://${hostname()}:4533`, () => {
|
||||
process.env[k] = "";
|
||||
expect(config().subsonic.url).toEqual(`http://${hostname()}:4533`);
|
||||
});
|
||||
});
|
||||
|
||||
describe(`when ${k} is specified`, () => {
|
||||
it(`should use it for ${k}`, () => {
|
||||
const url = "http://navidrome.example.com:1234";
|
||||
process.env[k] = url;
|
||||
expect(config().subsonic.url).toEqual(url);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("customClientsFor", () => {
|
||||
it("should default to undefined", () => {
|
||||
expect(config().navidrome.customClientsFor).toBeUndefined();
|
||||
expect(config().subsonic.customClientsFor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should be overridable", () => {
|
||||
process.env["BONOB_NAVIDROME_CUSTOM_CLIENTS"] = "whoop/whoop";
|
||||
expect(config().navidrome.customClientsFor).toEqual("whoop/whoop");
|
||||
["BNB_SUBSONIC_CUSTOM_CLIENTS", "BONOB_SUBSONIC_CUSTOM_CLIENTS", "BONOB_NAVIDROME_CUSTOM_CLIENTS"].forEach(k => {
|
||||
it(`should be overridable for ${k}`, () => {
|
||||
process.env[k] = "whoop/whoop";
|
||||
expect(config().subsonic.customClientsFor).toEqual("whoop/whoop");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
["BNB_SCROBBLE_TRACKS", "BONOB_SCROBBLE_TRACKS"].forEach(k => {
|
||||
describeBooleanConfigValue(
|
||||
"scrobbleTracks",
|
||||
"BONOB_SCROBBLE_TRACKS",
|
||||
k,
|
||||
true,
|
||||
(config) => config.scrobbleTracks
|
||||
);
|
||||
});
|
||||
|
||||
["BNB_REPORT_NOW_PLAYING", "BONOB_REPORT_NOW_PLAYING"].forEach(k => {
|
||||
describeBooleanConfigValue(
|
||||
"reportNowPlaying",
|
||||
"BONOB_REPORT_NOW_PLAYING",
|
||||
k,
|
||||
true,
|
||||
(config) => config.reportNowPlaying
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,7 +125,7 @@ export class InMemoryMusicService implements MusicService {
|
||||
),
|
||||
stream: (_: { trackId: string; range: string | undefined }) =>
|
||||
Promise.reject("unsupported operation"),
|
||||
coverArt: (id: string, _: "album" | "artist", size?: number) =>
|
||||
coverArt: (id: string, size?: number) =>
|
||||
Promise.reject(`Cannot retrieve coverArt for ${id}, size ${size}`),
|
||||
scrobble: async (_: string) => {
|
||||
return Promise.resolve(true);
|
||||
|
||||
@@ -774,7 +774,8 @@ describe("server", () => {
|
||||
const trackStream = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mp3; charset=utf-8",
|
||||
// audio/x-flac should be mapped to x-flac
|
||||
"content-type": "audio/x-flac; whoop; foo-bar",
|
||||
"content-length": "123",
|
||||
},
|
||||
stream: streamContent(""),
|
||||
@@ -793,7 +794,7 @@ describe("server", () => {
|
||||
|
||||
expect(res.status).toEqual(trackStream.status);
|
||||
expect(res.headers["content-type"]).toEqual(
|
||||
"audio/mp3; charset=utf-8"
|
||||
"audio/flac; whoop; foo-bar"
|
||||
);
|
||||
expect(res.headers["content-length"]).toEqual("123");
|
||||
expect(res.body).toEqual({});
|
||||
@@ -883,7 +884,8 @@ describe("server", () => {
|
||||
const stream = {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "audio/mp3",
|
||||
// audio/x-flac should be mapped to audio/flac
|
||||
"content-type": "audio/x-flac; charset=utf-8",
|
||||
},
|
||||
stream: streamContent(content),
|
||||
};
|
||||
@@ -902,7 +904,7 @@ describe("server", () => {
|
||||
|
||||
expect(res.status).toEqual(stream.status);
|
||||
expect(res.headers["content-type"]).toEqual(
|
||||
"audio/mp3; charset=utf-8"
|
||||
"audio/flac; charset=utf-8"
|
||||
);
|
||||
expect(res.header["accept-ranges"]).toBeUndefined();
|
||||
expect(res.headers["content-length"]).toEqual(
|
||||
@@ -1173,7 +1175,7 @@ describe("server", () => {
|
||||
|
||||
describe("when there is no access-token", () => {
|
||||
it("should return a 401", async () => {
|
||||
const res = await request(server).get(`/art/album/123/size/180`);
|
||||
const res = await request(server).get(`/art/coverArt:123/size/180`);
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
});
|
||||
@@ -1184,7 +1186,7 @@ describe("server", () => {
|
||||
now = now.add(1, "day");
|
||||
|
||||
const res = await request(server).get(
|
||||
`/art/album/123/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
`/art/coverArt:123/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
);
|
||||
|
||||
expect(res.status).toEqual(401);
|
||||
@@ -1192,18 +1194,6 @@ describe("server", () => {
|
||||
});
|
||||
|
||||
describe("when there is a valid access token", () => {
|
||||
describe("some invalid art type", () => {
|
||||
it("should return a 400", async () => {
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/foo/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("artist art", () => {
|
||||
["0", "-1", "foo"].forEach((size) => {
|
||||
describe(`invalid size of ${size}`, () => {
|
||||
@@ -1211,7 +1201,7 @@ describe("server", () => {
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/artist/${albumId}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
`/art/artist:${albumId}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
@@ -1231,7 +1221,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/artist/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
`/art/artist:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
@@ -1242,8 +1232,7 @@ describe("server", () => {
|
||||
|
||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||
albumId,
|
||||
"artist",
|
||||
`artist:${albumId}`,
|
||||
180
|
||||
);
|
||||
});
|
||||
@@ -1257,7 +1246,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/artist/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
`/art/artist:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
@@ -1271,7 +1260,7 @@ describe("server", () => {
|
||||
|
||||
describe("fetching a collage of 4 when all are available", () => {
|
||||
it("should return the image and a 200", async () => {
|
||||
const ids = ["1", "2", "3", "4"];
|
||||
const ids = ["artist:1", "artist:2", "coverArt:3", "coverArt:4"];
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
|
||||
@@ -1283,11 +1272,10 @@ describe("server", () => {
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/artist/${ids.join(
|
||||
"&"
|
||||
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
`/art/${ids.join("&")}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
@@ -1298,7 +1286,6 @@ describe("server", () => {
|
||||
ids.forEach((id) => {
|
||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||
id,
|
||||
"artist",
|
||||
200
|
||||
);
|
||||
});
|
||||
@@ -1311,7 +1298,7 @@ describe("server", () => {
|
||||
|
||||
describe("fetching a collage of 4, however only 1 is available", () => {
|
||||
it("should return the single image", async () => {
|
||||
const ids = ["1", "2", "3", "4"];
|
||||
const ids = ["artist:1", "artist:2", "artist:3", "artist:4"];
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
|
||||
@@ -1327,7 +1314,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/artist/${ids.join(
|
||||
`/art/${ids.join(
|
||||
"&"
|
||||
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
@@ -1340,7 +1327,7 @@ describe("server", () => {
|
||||
|
||||
describe("fetching a collage of 4 and all are missing", () => {
|
||||
it("should return a 404", async () => {
|
||||
const ids = ["1", "2", "3", "4"];
|
||||
const ids = ["artist:1", "artist:2", "artist:3", "artist:4"];
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
|
||||
@@ -1350,7 +1337,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/artist/${ids.join(
|
||||
`/art/${ids.join(
|
||||
"&"
|
||||
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
@@ -1362,7 +1349,7 @@ describe("server", () => {
|
||||
|
||||
describe("fetching a collage of 9 when all are available", () => {
|
||||
it("should return the image and a 200", async () => {
|
||||
const ids = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
||||
const ids = ["artist:1", "artist:2", "coverArt:3", "artist:4", "artist:5", "artist:6", "artist:7", "artist:8", "artist:9"];
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
|
||||
@@ -1376,7 +1363,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/artist/${ids.join(
|
||||
`/art/${ids.join(
|
||||
"&"
|
||||
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
@@ -1389,7 +1376,6 @@ describe("server", () => {
|
||||
ids.forEach((id) => {
|
||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||
id,
|
||||
"artist",
|
||||
180
|
||||
);
|
||||
});
|
||||
@@ -1402,7 +1388,7 @@ describe("server", () => {
|
||||
|
||||
describe("fetching a collage of 9 when only 2 are available", () => {
|
||||
it("should still return an image and a 200", async () => {
|
||||
const ids = ["1", "2", "3", "4", "5", "6", "7", "8", "9"];
|
||||
const ids = ["artist:1", "artist:2", "artist:3", "artist:4", "artist:5", "artist:6", "artist:7", "artist:8", "artist:9"];
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
|
||||
@@ -1426,7 +1412,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/artist/${ids.join(
|
||||
`/art/${ids.join(
|
||||
"&"
|
||||
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
@@ -1439,7 +1425,6 @@ describe("server", () => {
|
||||
ids.forEach((id) => {
|
||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||
id,
|
||||
"artist",
|
||||
180
|
||||
);
|
||||
});
|
||||
@@ -1452,7 +1437,7 @@ describe("server", () => {
|
||||
|
||||
describe("fetching a collage of 11", () => {
|
||||
it("should still return an image and a 200, though will only display 9", async () => {
|
||||
const ids = ["1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"];
|
||||
const ids = ["artist:1", "artist:2", "artist:3", "artist:4", "artist:5", "artist:6", "artist:7", "artist:8", "artist:9", "artist:10", "artist:11"];
|
||||
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
|
||||
@@ -1466,7 +1451,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/artist/${ids.join(
|
||||
`/art/${ids.join(
|
||||
"&"
|
||||
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
@@ -1479,7 +1464,6 @@ describe("server", () => {
|
||||
ids.forEach((id) => {
|
||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||
id,
|
||||
"artist",
|
||||
180
|
||||
);
|
||||
});
|
||||
@@ -1498,7 +1482,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/artist/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
`/art/coverArt:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
@@ -1515,7 +1499,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/artist/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
`/art/artist:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
@@ -1531,7 +1515,7 @@ describe("server", () => {
|
||||
musicService.login.mockResolvedValue(musicLibrary);
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/album/${albumId}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
`/art/coverArt:${albumId}/size/${size}?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
@@ -1553,7 +1537,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/album/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
`/art/coverArt:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
@@ -1564,8 +1548,7 @@ describe("server", () => {
|
||||
|
||||
expect(musicService.login).toHaveBeenCalledWith(authToken);
|
||||
expect(musicLibrary.coverArt).toHaveBeenCalledWith(
|
||||
albumId,
|
||||
"album",
|
||||
`coverArt:${albumId}`,
|
||||
180
|
||||
);
|
||||
});
|
||||
@@ -1578,7 +1561,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/album/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
`/art/album:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
@@ -1593,7 +1576,7 @@ describe("server", () => {
|
||||
|
||||
const res = await request(server)
|
||||
.get(
|
||||
`/art/album/${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
`/art/album:${albumId}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
|
||||
)
|
||||
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
searchResult,
|
||||
iconArtURI,
|
||||
playlistAlbumArtURL,
|
||||
sonosifyMimeType,
|
||||
} from "../src/smapi";
|
||||
|
||||
import {
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
} from "../src/music_service";
|
||||
import { AccessTokens } from "../src/access_tokens";
|
||||
import dayjs from "dayjs";
|
||||
import url from "../src/url_builder";
|
||||
import url, { URLBuilder } from "../src/url_builder";
|
||||
import { iconForGenre } from "../src/icon";
|
||||
|
||||
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value);
|
||||
@@ -252,7 +253,8 @@ describe("track", () => {
|
||||
const bonobUrl = url("http://localhost:4567/foo?access-token=1234");
|
||||
const someTrack = aTrack({
|
||||
id: uuid(),
|
||||
mimeType: "audio/something",
|
||||
// audio/x-flac should be mapped to audio/flac
|
||||
mimeType: "audio/x-flac",
|
||||
name: "great song",
|
||||
duration: randomInt(1000),
|
||||
number: randomInt(100),
|
||||
@@ -262,22 +264,23 @@ describe("track", () => {
|
||||
genre: { id: "genre101", name: "some genre" },
|
||||
}),
|
||||
artist: anArtist({ name: "great artist", id: uuid() }),
|
||||
coverArt:"coverArt:887766"
|
||||
});
|
||||
|
||||
expect(track(bonobUrl, someTrack)).toEqual({
|
||||
itemType: "track",
|
||||
id: `track:${someTrack.id}`,
|
||||
mimeType: someTrack.mimeType,
|
||||
mimeType: 'audio/flac',
|
||||
title: someTrack.name,
|
||||
|
||||
trackMetadata: {
|
||||
album: someTrack.album.name,
|
||||
albumId: someTrack.album.id,
|
||||
albumId: `album:${someTrack.album.id}`,
|
||||
albumArtist: someTrack.artist.name,
|
||||
albumArtistId: someTrack.artist.id,
|
||||
albumArtURI: `http://localhost:4567/foo/art/album/${someTrack.album.id}/size/180?access-token=1234`,
|
||||
albumArtistId: `artist:${someTrack.artist.id}`,
|
||||
albumArtURI: `http://localhost:4567/foo/art/${someTrack.coverArt}/size/180?access-token=1234`,
|
||||
artist: someTrack.artist.name,
|
||||
artistId: someTrack.artist.id,
|
||||
artistId: `artist:${someTrack.artist.id}`,
|
||||
duration: someTrack.duration,
|
||||
genre: someTrack.album.genre?.name,
|
||||
genreId: someTrack.album.genre?.id,
|
||||
@@ -304,12 +307,28 @@ describe("album", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("sonosifyMimeType", () => {
|
||||
describe("when is audio/x-flac", () => {
|
||||
it("should be mapped to audio/flac", () => {
|
||||
expect(sonosifyMimeType("audio/x-flac")).toEqual("audio/flac");
|
||||
});
|
||||
});
|
||||
|
||||
describe("when it is not audio/x-flac", () => {
|
||||
it("should be returned as is", () => {
|
||||
expect(sonosifyMimeType("audio/flac")).toEqual("audio/flac");
|
||||
expect(sonosifyMimeType("audio/mpeg")).toEqual("audio/mpeg");
|
||||
expect(sonosifyMimeType("audio/whoop")).toEqual("audio/whoop");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("playlistAlbumArtURL", () => {
|
||||
describe("when the playlist has no albumIds", () => {
|
||||
describe("when the playlist has no coverArt ids", () => {
|
||||
it("should return question mark icon", () => {
|
||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||
const playlist = aPlaylist({
|
||||
entries: [aTrack({ album: undefined }), aTrack({ album: undefined })],
|
||||
entries: [aTrack({ coverArt: undefined }), aTrack({ coverArt: undefined })],
|
||||
});
|
||||
|
||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||
@@ -318,20 +337,20 @@ describe("playlistAlbumArtURL", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the playlist has 2 distinct albumIds", () => {
|
||||
describe("when the playlist has 2 distinct coverArt ids", () => {
|
||||
it("should return them on the url to the image", () => {
|
||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||
const playlist = aPlaylist({
|
||||
entries: [
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||
aTrack({ coverArt: "1" }),
|
||||
aTrack({ coverArt: "2" }),
|
||||
aTrack({ coverArt: "1" }),
|
||||
aTrack({ coverArt: "2" }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||
`http://localhost:1234/context-path/art/album/1&2/size/180?search=yes`
|
||||
`http://localhost:1234/context-path/art/1&2/size/180?search=yes`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -341,62 +360,85 @@ describe("playlistAlbumArtURL", () => {
|
||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||
const playlist = aPlaylist({
|
||||
entries: [
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "3" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "4" })) }),
|
||||
aTrack({ coverArt: "1" }),
|
||||
aTrack({ coverArt: "2" }),
|
||||
aTrack({ coverArt: "2" }),
|
||||
aTrack({ coverArt: "3" }),
|
||||
aTrack({ coverArt: "4" }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||
`http://localhost:1234/context-path/art/album/1&2&3&4/size/180?search=yes`
|
||||
`http://localhost:1234/context-path/art/1&2&3&4/size/180?search=yes`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when the playlist has 9 distinct albumIds", () => {
|
||||
it("should return 9 of the ids on the url", () => {
|
||||
describe("when the playlist has at least 9 distinct albumIds", () => {
|
||||
it("should return the first 9 of the ids on the url", () => {
|
||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||
const playlist = aPlaylist({
|
||||
entries: [
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "3" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "4" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "5" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "6" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "7" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "8" })) }),
|
||||
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "9" })) }),
|
||||
aTrack({ coverArt: "1" }),
|
||||
aTrack({ coverArt: "2" }),
|
||||
aTrack({ coverArt: "2" }),
|
||||
aTrack({ coverArt: "2" }),
|
||||
aTrack({ coverArt: "3" }),
|
||||
aTrack({ coverArt: "4" }),
|
||||
aTrack({ coverArt: "5" }),
|
||||
aTrack({ coverArt: "6" }),
|
||||
aTrack({ coverArt: "7" }),
|
||||
aTrack({ coverArt: "8" }),
|
||||
aTrack({ coverArt: "9" }),
|
||||
aTrack({ coverArt: "10" }),
|
||||
aTrack({ coverArt: "11" }),
|
||||
],
|
||||
});
|
||||
|
||||
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
|
||||
`http://localhost:1234/context-path/art/album/1&2&3&4&5&6&7&8&9/size/180?search=yes`
|
||||
`http://localhost:1234/context-path/art/1&2&3&4&5&6&7&8&9/size/180?search=yes`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultAlbumArtURI", () => {
|
||||
it("should create the correct URI", () => {
|
||||
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
|
||||
const album = anAlbum();
|
||||
const bonobUrl = new URLBuilder("http://bonob.example.com:8080/context?search=yes");
|
||||
|
||||
expect(defaultAlbumArtURI(bonobUrl, album).href()).toEqual(
|
||||
`http://localhost:1234/context-path/art/album/${album.id}/size/180?search=yes`
|
||||
describe("when there is an album coverArt", () => {
|
||||
it("should use it in the image url", () => {
|
||||
expect(
|
||||
defaultAlbumArtURI(
|
||||
bonobUrl,
|
||||
anAlbum({ coverArt: "coverArt:123" })
|
||||
).href()
|
||||
).toEqual(
|
||||
"http://bonob.example.com:8080/context/art/coverArt:123/size/180?search=yes"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when there is no album coverArt", () => {
|
||||
it("should return a vinly icon image", () => {
|
||||
expect(
|
||||
defaultAlbumArtURI(
|
||||
bonobUrl,
|
||||
anAlbum({ coverArt: undefined })
|
||||
).href()
|
||||
).toEqual(
|
||||
"http://bonob.example.com:8080/context/icon/vinyl/size/legacy?search=yes"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultArtistArtURI", () => {
|
||||
it("should create the correct URI", () => {
|
||||
const bonobUrl = url("http://localhost:1234/something?s=123");
|
||||
const artist = anArtist();
|
||||
|
||||
expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual(
|
||||
`http://localhost:1234/something/art/artist/${artist.id}/size/180?s=123`
|
||||
`http://localhost:1234/something/art/artist:${artist.id}/size/180?s=123`
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -448,7 +490,7 @@ describe("api", () => {
|
||||
const accessToken = `accessToken-${uuid()}`;
|
||||
|
||||
const bonobUrlWithAccessToken = bonobUrl.append({
|
||||
searchParams: { "bat": accessToken },
|
||||
searchParams: { bat: accessToken },
|
||||
});
|
||||
|
||||
const service = bonobService("test-api", 133, bonobUrl, "AppLink");
|
||||
@@ -1020,7 +1062,7 @@ describe("api", () => {
|
||||
title: genre.name,
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
iconForGenre(genre.name),
|
||||
iconForGenre(genre.name)
|
||||
).href(),
|
||||
})),
|
||||
index: 0,
|
||||
@@ -1045,7 +1087,7 @@ describe("api", () => {
|
||||
title: genre.name,
|
||||
albumArtURI: iconArtURI(
|
||||
bonobUrl,
|
||||
iconForGenre(genre.name),
|
||||
iconForGenre(genre.name)
|
||||
).href(),
|
||||
})),
|
||||
index: 1,
|
||||
@@ -2302,14 +2344,17 @@ describe("api", () => {
|
||||
artistId: `artist:${track.artist.id}`,
|
||||
artist: track.artist.name,
|
||||
albumId: `album:${track.album.id}`,
|
||||
albumArtist: track.artist.name,
|
||||
albumArtistId: `artist:${track.artist.id}`,
|
||||
album: track.album.name,
|
||||
genre: track.genre?.name,
|
||||
genreId: track.genre?.id,
|
||||
duration: track.duration,
|
||||
albumArtURI: defaultAlbumArtURI(
|
||||
bonobUrlWithAccessToken,
|
||||
track.album
|
||||
track
|
||||
).href(),
|
||||
trackNumber: track.number,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -2510,7 +2555,7 @@ describe("api", () => {
|
||||
expect(root[0]).toEqual({
|
||||
getMediaMetadataResult: track(
|
||||
bonobUrl.with({
|
||||
searchParams: { "bat": accessToken },
|
||||
searchParams: { bat: accessToken },
|
||||
}),
|
||||
someTrack
|
||||
),
|
||||
|
||||
@@ -3,14 +3,15 @@ import { v4 as uuid } from "uuid";
|
||||
|
||||
import {
|
||||
isDodgyImage,
|
||||
Navidrome,
|
||||
Subsonic,
|
||||
t,
|
||||
BROWSER_HEADERS,
|
||||
DODGY_IMAGE_NAME,
|
||||
asGenre,
|
||||
appendMimeTypeToClientFor,
|
||||
asURLSearchParams,
|
||||
} from "../src/navidrome";
|
||||
splitCoverArtId,
|
||||
} from "../src/subsonic";
|
||||
import encryption from "../src/encryption";
|
||||
|
||||
import axios from "axios";
|
||||
@@ -44,6 +45,8 @@ import {
|
||||
aPlaylist,
|
||||
aPlaylistSummary,
|
||||
aTrack,
|
||||
POP,
|
||||
ROCK,
|
||||
} from "./builders";
|
||||
import { b64Encode } from "../src/b64";
|
||||
|
||||
@@ -181,6 +184,8 @@ const getArtistInfoXml = (
|
||||
</artistInfo2>
|
||||
</subsonic-response>`;
|
||||
|
||||
const maybeIdFromCoverArtId = (coverArt: string | undefined) => coverArt ? splitCoverArtId(coverArt)[1] : "";
|
||||
|
||||
const albumXml = (
|
||||
artist: Artist,
|
||||
album: AlbumSummary,
|
||||
@@ -191,7 +196,7 @@ const albumXml = (
|
||||
title="${album.name}" name="${album.name}" album="${album.name}"
|
||||
artist="${artist.name}"
|
||||
genre="${album.genre?.name}"
|
||||
coverArt="foo"
|
||||
coverArt="${maybeIdFromCoverArtId(album.coverArt)}"
|
||||
duration="123"
|
||||
playCount="4"
|
||||
year="${album.year}"
|
||||
@@ -209,7 +214,7 @@ const songXml = (track: Track) => `<song
|
||||
track="${track.number}"
|
||||
genre="${track.genre?.name}"
|
||||
isDir="false"
|
||||
coverArt="71381"
|
||||
coverArt="${maybeIdFromCoverArtId(track.coverArt)}"
|
||||
created="2004-11-08T23:36:11"
|
||||
duration="${track.duration}"
|
||||
bitRate="128"
|
||||
@@ -341,7 +346,7 @@ const getPlayList = (
|
||||
track="${it.number}"
|
||||
year="${it.album.year}"
|
||||
genre="${it.album.genre?.name}"
|
||||
coverArt="..."
|
||||
coverArt="${splitCoverArtId(it.coverArt!)[1]}"
|
||||
size="123"
|
||||
contentType="${it.mimeType}"
|
||||
suffix="mp3"
|
||||
@@ -430,14 +435,28 @@ const EMPTY = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok
|
||||
|
||||
const PING_OK = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
|
||||
|
||||
describe("Navidrome", () => {
|
||||
describe("splitCoverArtId", () => {
|
||||
it("should split correctly", () => {
|
||||
expect(splitCoverArtId("foo:bar")).toEqual(["foo", "bar"])
|
||||
expect(splitCoverArtId("foo:bar:car:jar")).toEqual(["foo", "bar:car:jar"])
|
||||
});
|
||||
|
||||
it("should blow up when the id is invalid", () => {
|
||||
expect(() => splitCoverArtId("")).toThrow(`'' is an invalid coverArt id`)
|
||||
expect(() => splitCoverArtId("foo:")).toThrow(`'foo:' is an invalid coverArt id`)
|
||||
expect(() => splitCoverArtId("foo:")).toThrow(`'foo:' is an invalid coverArt id`)
|
||||
expect(() => splitCoverArtId(":dog")).toThrow(`':dog' is an invalid coverArt id`)
|
||||
});
|
||||
});
|
||||
|
||||
describe("Subsonic", () => {
|
||||
const url = "http://127.0.0.22:4567";
|
||||
const username = "user1";
|
||||
const password = "pass1";
|
||||
const salt = "saltysalty";
|
||||
|
||||
const streamClientApplication = jest.fn();
|
||||
const navidrome = new Navidrome(
|
||||
const navidrome = new Subsonic(
|
||||
url,
|
||||
encryption("secret"),
|
||||
streamClientApplication
|
||||
@@ -500,7 +519,7 @@ describe("Navidrome", () => {
|
||||
|
||||
const token = await navidrome.generateToken({ username, password });
|
||||
expect(token).toEqual({
|
||||
message: "Navidrome error:Wrong username or password",
|
||||
message: "Subsonic error:Wrong username or password",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2483,7 +2502,7 @@ describe("Navidrome", () => {
|
||||
|
||||
return expect(
|
||||
musicLibrary.stream({ trackId, range: undefined })
|
||||
).rejects.toEqual(`Navidrome failed with a 400 status`);
|
||||
).rejects.toEqual(`Subsonic failed with a 400 status`);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2660,7 +2679,7 @@ describe("Navidrome", () => {
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(coverArtId, "album"));
|
||||
.then((it) => it.coverArt(`coverArt:${coverArtId}`));
|
||||
|
||||
expect(result).toEqual({
|
||||
contentType: streamResponse.headers["content-type"],
|
||||
@@ -2698,7 +2717,7 @@ describe("Navidrome", () => {
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(coverArtId, "album", size));
|
||||
.then((it) => it.coverArt(`coverArt:${coverArtId}`, size));
|
||||
|
||||
expect(result).toEqual({
|
||||
contentType: streamResponse.headers["content-type"],
|
||||
@@ -2754,7 +2773,7 @@ describe("Navidrome", () => {
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(artistId, "artist"));
|
||||
.then((it) => it.coverArt(`artist:${artistId}`));
|
||||
|
||||
expect(result).toEqual({
|
||||
contentType: streamResponse.headers["content-type"],
|
||||
@@ -2783,7 +2802,6 @@ describe("Navidrome", () => {
|
||||
|
||||
describe("when the artist doest not have a valid artist uri", () => {
|
||||
describe("however has some albums", () => {
|
||||
it("should fetch the artists first album image", async () => {
|
||||
const artistId = "someArtist123";
|
||||
|
||||
const images: Images = {
|
||||
@@ -2800,8 +2818,10 @@ describe("Navidrome", () => {
|
||||
data: Buffer.from("the image", "ascii"),
|
||||
};
|
||||
|
||||
const album1 = anAlbum();
|
||||
const album2 = anAlbum();
|
||||
describe("all albums have coverArt", () => {
|
||||
it("should fetch the coverArt from the first album", async () => {
|
||||
const album1 = anAlbum({ coverArt: `coverArt:album1CoverArt` });
|
||||
const album2 = anAlbum({ coverArt: `coverArt:album2CoverArt` });
|
||||
|
||||
const artist = anArtist({
|
||||
id: artistId,
|
||||
@@ -2823,7 +2843,7 @@ describe("Navidrome", () => {
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(artistId, "artist"));
|
||||
.then((it) => it.coverArt(`artist:${artistId}`));
|
||||
|
||||
expect(result).toEqual({
|
||||
contentType: streamResponse.headers["content-type"],
|
||||
@@ -2856,7 +2876,7 @@ describe("Navidrome", () => {
|
||||
{
|
||||
params: asURLSearchParams({
|
||||
...authParams,
|
||||
id: album1.id,
|
||||
id: splitCoverArtId(album1.coverArt!)[1],
|
||||
}),
|
||||
headers,
|
||||
responseType: "arraybuffer",
|
||||
@@ -2865,6 +2885,126 @@ describe("Navidrome", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the first album does not have coverArt", () => {
|
||||
it("should fetch the coverArt from the first album with coverArt", async () => {
|
||||
const album1 = anAlbum({ coverArt: undefined });
|
||||
const album2 = anAlbum({ coverArt: `coverArt:album2CoverArt` });
|
||||
|
||||
const artist = anArtist({
|
||||
id: artistId,
|
||||
albums: [album1, album2],
|
||||
image: images,
|
||||
});
|
||||
|
||||
mockGET
|
||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve(ok(getArtistXml(artist)))
|
||||
)
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||
)
|
||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||
|
||||
const result = await navidrome
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(`artist:${artistId}`));
|
||||
|
||||
expect(result).toEqual({
|
||||
contentType: streamResponse.headers["content-type"],
|
||||
data: streamResponse.data,
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
|
||||
params: asURLSearchParams({
|
||||
...authParams,
|
||||
id: artistId,
|
||||
}),
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
`${url}/rest/getArtistInfo2`,
|
||||
{
|
||||
params: asURLSearchParams({
|
||||
...authParams,
|
||||
id: artistId,
|
||||
count: 50,
|
||||
includeNotPresent: true,
|
||||
}),
|
||||
headers,
|
||||
}
|
||||
);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
`${url}/rest/getCoverArt`,
|
||||
{
|
||||
params: asURLSearchParams({
|
||||
...authParams,
|
||||
id: splitCoverArtId(album2.coverArt!)[1],
|
||||
}),
|
||||
headers,
|
||||
responseType: "arraybuffer",
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("no albums have coverArt", () => {
|
||||
it("should return undefined", async () => {
|
||||
const album1 = anAlbum({ coverArt: undefined });
|
||||
const album2 = anAlbum({ coverArt: undefined });
|
||||
|
||||
const artist = anArtist({
|
||||
id: artistId,
|
||||
albums: [album1, album2],
|
||||
image: images,
|
||||
});
|
||||
|
||||
mockGET
|
||||
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve(ok(getArtistXml(artist)))
|
||||
)
|
||||
.mockImplementationOnce(() =>
|
||||
Promise.resolve(ok(getArtistInfoXml(artist)))
|
||||
)
|
||||
.mockImplementationOnce(() => Promise.resolve(streamResponse));
|
||||
|
||||
const result = await navidrome
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(`artist:${artistId}`));
|
||||
|
||||
expect(result).toEqual(undefined);
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
|
||||
params: asURLSearchParams({
|
||||
...authParams,
|
||||
id: artistId,
|
||||
}),
|
||||
headers,
|
||||
});
|
||||
|
||||
expect(axios.get).toHaveBeenCalledWith(
|
||||
`${url}/rest/getArtistInfo2`,
|
||||
{
|
||||
params: asURLSearchParams({
|
||||
...authParams,
|
||||
id: artistId,
|
||||
count: 50,
|
||||
includeNotPresent: true,
|
||||
}),
|
||||
headers,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("and has no albums", () => {
|
||||
it("should return undefined", async () => {
|
||||
const artistId = "someArtist123";
|
||||
@@ -2903,7 +3043,7 @@ describe("Navidrome", () => {
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(artistId, "artist"));
|
||||
.then((it) => it.coverArt(`artist:${artistId}`));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
@@ -2978,7 +3118,7 @@ describe("Navidrome", () => {
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(artistId, "artist", size));
|
||||
.then((it) => it.coverArt(`artist:${artistId}`, size));
|
||||
|
||||
expect(result).toEqual({
|
||||
contentType: streamResponse.headers["content-type"],
|
||||
@@ -3050,7 +3190,7 @@ describe("Navidrome", () => {
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(artistId, "artist", size));
|
||||
.then((it) => it.coverArt(`artist:${artistId}`, size));
|
||||
|
||||
expect(result).toEqual({
|
||||
contentType: streamResponse.headers["content-type"],
|
||||
@@ -3083,7 +3223,7 @@ describe("Navidrome", () => {
|
||||
{
|
||||
params: asURLSearchParams({
|
||||
...authParams,
|
||||
id: album1.id,
|
||||
id: splitCoverArtId(album1.coverArt!)[1],
|
||||
size,
|
||||
}),
|
||||
headers,
|
||||
@@ -3131,7 +3271,7 @@ describe("Navidrome", () => {
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(artistId, "artist"));
|
||||
.then((it) => it.coverArt(`artist:${artistId}`));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
@@ -3178,8 +3318,8 @@ describe("Navidrome", () => {
|
||||
data: Buffer.from("the image", "ascii"),
|
||||
};
|
||||
|
||||
const album1 = anAlbum({ id: "album1Id" });
|
||||
const album2 = anAlbum({ id: "album2Id" });
|
||||
const album1 = anAlbum({ id: "album1Id", coverArt: "coverArt:album1CoverArt" });
|
||||
const album2 = anAlbum({ id: "album2Id", coverArt: "coverArt:album2CoverArt" });
|
||||
|
||||
const artist = anArtist({
|
||||
id: artistId,
|
||||
@@ -3201,7 +3341,7 @@ describe("Navidrome", () => {
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(artistId, "artist", size));
|
||||
.then((it) => it.coverArt(`artist:${artistId}`, size));
|
||||
|
||||
expect(result).toEqual({
|
||||
contentType: streamResponse.headers["content-type"],
|
||||
@@ -3234,7 +3374,7 @@ describe("Navidrome", () => {
|
||||
{
|
||||
params: asURLSearchParams({
|
||||
...authParams,
|
||||
id: album1.id,
|
||||
id: splitCoverArtId(album1.coverArt!)[1],
|
||||
size,
|
||||
}),
|
||||
headers,
|
||||
@@ -3282,7 +3422,7 @@ describe("Navidrome", () => {
|
||||
.generateToken({ username, password })
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.coverArt(artistId, "artist"));
|
||||
.then((it) => it.coverArt(`artist:${artistId}`));
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
@@ -3896,7 +4036,7 @@ describe("Navidrome", () => {
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.playlist(id))
|
||||
).rejects.toEqual("Navidrome error:data not found");
|
||||
).rejects.toEqual("Subsonic error:data not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3905,13 +4045,24 @@ describe("Navidrome", () => {
|
||||
it("should return the playlist with entries", async () => {
|
||||
const id = uuid();
|
||||
const name = "Great Playlist";
|
||||
const artist1 = anArtist();
|
||||
const album1 = anAlbum({ artistId: artist1.id, artistName: artist1.name, genre: POP });
|
||||
const track1 = aTrack({
|
||||
genre: { id: b64Encode("pop"), name: "pop" },
|
||||
genre: POP,
|
||||
number: 66,
|
||||
coverArt: album1.coverArt,
|
||||
artist: artistToArtistSummary(artist1),
|
||||
album: albumToAlbumSummary(album1)
|
||||
});
|
||||
|
||||
const artist2 = anArtist();
|
||||
const album2 = anAlbum({ artistId: artist2.id, artistName: artist2.name, genre: ROCK });
|
||||
const track2 = aTrack({
|
||||
genre: { id: b64Encode("rock"), name: "rock" },
|
||||
genre: ROCK,
|
||||
number: 77,
|
||||
coverArt: album2.coverArt,
|
||||
artist: artistToArtistSummary(artist2),
|
||||
album: albumToAlbumSummary(album2)
|
||||
});
|
||||
|
||||
mockGET
|
||||
@@ -4263,7 +4414,7 @@ describe("Navidrome", () => {
|
||||
.then((it) => it as AuthSuccess)
|
||||
.then((it) => navidrome.login(it.authToken))
|
||||
.then((it) => it.similarSongs(id))
|
||||
).rejects.toEqual("Navidrome error:data not found");
|
||||
).rejects.toEqual("Subsonic error:data not found");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4323,10 +4474,9 @@ describe("Navidrome", () => {
|
||||
it("should return them", async () => {
|
||||
const artistId = "bobMarleyId";
|
||||
const artistName = "Bob Marley";
|
||||
const pop = asGenre("Pop");
|
||||
|
||||
const album1 = anAlbum({ name: "Burnin", genre: pop });
|
||||
const album2 = anAlbum({ name: "Churning", genre: pop });
|
||||
const album1 = anAlbum({ name: "Burnin", genre: POP });
|
||||
const album2 = anAlbum({ name: "Churning", genre: POP });
|
||||
|
||||
const artist = anArtist({
|
||||
id: artistId,
|
||||
@@ -4337,19 +4487,19 @@ describe("Navidrome", () => {
|
||||
const track1 = aTrack({
|
||||
artist: artistToArtistSummary(artist),
|
||||
album: albumToAlbumSummary(album1),
|
||||
genre: pop,
|
||||
genre: POP
|
||||
});
|
||||
|
||||
const track2 = aTrack({
|
||||
artist: artistToArtistSummary(artist),
|
||||
album: albumToAlbumSummary(album2),
|
||||
genre: pop,
|
||||
genre: POP,
|
||||
});
|
||||
|
||||
const track3 = aTrack({
|
||||
artist: artistToArtistSummary(artist),
|
||||
album: albumToAlbumSummary(album1),
|
||||
genre: pop,
|
||||
genre: POP,
|
||||
});
|
||||
|
||||
mockGET
|
||||
Reference in New Issue
Block a user