Migrate Navidrome support to generic subsonic clone support (#55)

Renaming BONOB_* env vars to BNB_*
This commit is contained in:
Simon J
2021-09-27 14:03:14 +10:00
committed by GitHub
parent c60d2e7745
commit 36d0023a1e
17 changed files with 826 additions and 506 deletions

View File

@@ -31,9 +31,9 @@ RUN apk add --no-cache --update --virtual .gyp \
FROM node:16.6-alpine FROM node:16.6-alpine
ENV BONOB_PORT=4534 ENV BNB_PORT=4534
EXPOSE $BONOB_PORT EXPOSE $BNB_PORT
WORKDIR /bonob WORKDIR /bonob

View File

@@ -2,13 +2,13 @@
A sonos SMAPI implementation to allow registering sources of music with sonos. 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).
![Build](https://github.com/simojenki/bonob/workflows/Build/badge.svg) ![Build](https://github.com/simojenki/bonob/workflows/Build/badge.svg)
## Features ## 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 - Browse by Artist, Albums, Genres, Playlist, Random Albums, Starred Albums, Recently Added Albums, Recently Played Albums, Most Played Albums
- Artist Art - Artist Art
- Album Art - Album Art
@@ -33,8 +33,8 @@ bonob is ditributed via docker and can be run in a number of ways
```bash ```bash
docker run \ docker run \
-e BONOB_SONOS_AUTO_REGISTER=true \ -e BNB_SONOS_AUTO_REGISTER=true \
-e BONOB_SONOS_DEVICE_DISCOVERY=true \ -e BNB_SONOS_DEVICE_DISCOVERY=true \
-p 4534:4534 \ -p 4534:4534 \
--network host \ --network host \
simojenki/bonob simojenki/bonob
@@ -46,10 +46,10 @@ Now open http://localhost:4534 in your browser, you should see sonos devices, an
```bash ```bash
docker run \ docker run \
-e BONOB_PORT=3000 \ -e BNB_PORT=3000 \
-e BONOB_SONOS_SEED_HOST=192.168.1.123 \ -e BNB_SONOS_SEED_HOST=192.168.1.123 \
-e BONOB_SONOS_AUTO_REGISTER=true \ -e BNB_SONOS_AUTO_REGISTER=true \
-e BONOB_SONOS_DEVICE_DISCOVERY=true \ -e BNB_SONOS_DEVICE_DISCOVERY=true \
-p 3000:3000 \ -p 3000:3000 \
simojenki/bonob simojenki/bonob
``` ```
@@ -66,13 +66,13 @@ Start bonob outside the LAN with sonos discovery & registration disabled as they
```bash ```bash
docker run \ docker run \
-e BONOB_PORT=4534 \ -e BNB_PORT=4534 \
-e BONOB_SONOS_SERVICE_NAME=MyAwesomeMusic \ -e BNB_SONOS_SERVICE_NAME=MyAwesomeMusic \
-e BONOB_SECRET=changeme \ -e BNB_SECRET=changeme \
-e BONOB_URL=https://my-server.example.com/bonob \ -e BNB_URL=https://my-server.example.com/bonob \
-e BONOB_SONOS_AUTO_REGISTER=false \ -e BNB_SONOS_AUTO_REGISTER=false \
-e BONOB_SONOS_DEVICE_DISCOVERY=false \ -e BNB_SONOS_DEVICE_DISCOVERY=false \
-e BONOB_NAVIDROME_URL=https://my-navidrome-service.com:4533 \ -e BNB_SUBSONIC_URL=https://my-navidrome-service.com:4533 \
-p 4534:4534 \ -p 4534:4534 \
simojenki/bonob simojenki/bonob
``` ```
@@ -93,7 +93,7 @@ docker run \
```bash ```bash
docker run \ docker run \
--rm \ --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 simojenki/bonob register https://my-server.example.com/bonob
``` ```
@@ -124,52 +124,52 @@ services:
- "4534:4534" - "4534:4534"
restart: unless-stopped restart: unless-stopped
environment: environment:
BONOB_PORT: 4534 BNB_PORT: 4534
# ip address of your machine running bonob # ip address of your machine running bonob
BONOB_URL: http://192.168.1.111:4534 BNB_URL: http://192.168.1.111:4534
BONOB_SECRET: changeme BNB_SECRET: changeme
BONOB_SONOS_AUTO_REGISTER: true BNB_SONOS_AUTO_REGISTER: true
BONOB_SONOS_DEVICE_DISCOVERY: true BNB_SONOS_DEVICE_DISCOVERY: true
BONOB_SONOS_SERVICE_ID: 246 BNB_SONOS_SERVICE_ID: 246
# ip address of one of your sonos devices # ip address of one of your sonos devices
BONOB_SONOS_SEED_HOST: 192.168.1.121 BNB_SONOS_SEED_HOST: 192.168.1.121
BONOB_NAVIDROME_URL: http://navidrome:4533 BNB_SUBSONIC_URL: http://navidrome:4533
``` ```
## Configuration ## Configuration
item | default value | description item | default value | description
---- | ------------- | ----------- ---- | ------------- | -----------
BONOB_PORT | 4534 | Default http port for bonob to listen on BNB_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.** 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.**
BONOB_SECRET | bonob | secret used for encrypting credentials BNB_SECRET | bonob | secret used for encrypting credentials
BONOB_SONOS_AUTO_REGISTER | false | Whether or not to try and auto-register on startup BNB_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. 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.
BONOB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or ommitted for for auto-discovery BNB_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 BNB_SONOS_SERVICE_NAME | bonob | service name for sonos
BONOB_SONOS_SERVICE_ID | 246 | service id for sonos BNB_SONOS_SERVICE_ID | 246 | service id for sonos
BONOB_NAVIDROME_URL | http://$(hostname):4533 | URL for navidrome BNB_SUBSONIC_URL | http://$(hostname):4533 | URL for subsonic clone
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. 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.
BONOB_SCROBBLE_TRACKS | true | Whether to scrobble the playing of a track if it has been played for >30s BNB_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 BNB_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) 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)
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_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 ## 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, - Start bonob,
- Open sonos app on your device - Open sonos app on your device
- Settings -> Services & Voice -> + Add a Service - 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 - 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!' - You should get 'Login successful!'
- Go back into the sonos app and complete the process - Go back into the sonos app and complete the process
- You should now be able to play music from navidrome - You should now be able to play music on your sonos devices from you subsonic clone
- Within navidrome a new player will be created, 'bonob (username)', so you can configure transcoding specifically for sonos - 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 - Implement the MusicService/MusicLibrary interface
- Startup bonob with your new implementation. - 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; In this case you could set;
```bash ```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); 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 ### Changing Icon colors
```bash ```bash
-e BONOB_ICON_FOREGROUND_COLOR=white \ -e BNB_ICON_FOREGROUND_COLOR=white \
-e BONOB_ICON_BACKGROUND_COLOR=darkgrey -e BNB_ICON_BACKGROUND_COLOR=darkgrey
``` ```
![White & Dark Grey](https://github.com/simojenki/bonob/blob/master/docs/images/whiteDarkGrey.png?raw=true) ![White & Dark Grey](https://github.com/simojenki/bonob/blob/master/docs/images/whiteDarkGrey.png?raw=true)
```bash ```bash
-e BONOB_ICON_FOREGROUND_COLOR=chartreuse \ -e BNB_ICON_FOREGROUND_COLOR=chartreuse \
-e BONOB_ICON_BACKGROUND_COLOR=fuchsia -e BNB_ICON_BACKGROUND_COLOR=fuchsia
``` ```
![Chartreuse & Fuchsia](https://github.com/simojenki/bonob/blob/master/docs/images/chartreuseFuchsia.png?raw=true) ![Chartreuse & Fuchsia](https://github.com/simojenki/bonob/blob/master/docs/images/chartreuseFuchsia.png?raw=true)

View File

@@ -22,13 +22,13 @@ services:
- "4534:4534" - "4534:4534"
restart: unless-stopped restart: unless-stopped
environment: environment:
BONOB_PORT: 4534 BNB_PORT: 4534
# ip address of your machine running bonob # ip address of your machine running bonob
BONOB_URL: http://192.168.1.111:4534 BNB_URL: http://192.168.1.111:4534
BONOB_SECRET: changeme BNB_SECRET: changeme
BONOB_SONOS_SERVICE_ID: 246 BNB_SONOS_SERVICE_ID: 246
BONOB_SONOS_AUTO_REGISTER: "true" BNB_SONOS_AUTO_REGISTER: "true"
BONOB_SONOS_DEVICE_DISCOVERY: "true" BNB_SONOS_DEVICE_DISCOVERY: "true"
# ip address of one of your sonos devices # ip address of one of your sonos devices
BONOB_SONOS_SEED_HOST: 192.168.1.121 BNB_SONOS_SEED_HOST: 192.168.1.121
BONOB_NAVIDROME_URL: http://navidrome:4533 BNB_SUBSONIC_URL: http://navidrome:4533

View File

@@ -50,8 +50,8 @@
"scripts": { "scripts": {
"clean": "rm -Rf build node_modules", "clean": "rm -Rf build node_modules",
"build": "tsc", "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", "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": "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", "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", "register-dev": "ts-node ./src/register.ts http://$(hostname):4534",
"test": "jest", "test": "jest",
"gitinfo": "git describe --tags > .gitinfo" "gitinfo": "git describe --tags > .gitinfo"

View File

@@ -2,7 +2,7 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import server from "./server"; import server from "./server";
import logger from "./logger"; import logger from "./logger";
import { appendMimeTypeToClientFor, DEFAULT, Navidrome } from "./navidrome"; import { appendMimeTypeToClientFor, DEFAULT, Subsonic } from "./subsonic";
import encryption from "./encryption"; import encryption from "./encryption";
import { InMemoryAccessTokens, sha256 } from "./access_tokens"; import { InMemoryAccessTokens, sha256 } from "./access_tokens";
import { InMemoryLinkCodes } from "./link_codes"; import { InMemoryLinkCodes } from "./link_codes";
@@ -24,20 +24,20 @@ const bonob = bonobService(
const sonosSystem = sonos(config.sonos.discovery); const sonosSystem = sonos(config.sonos.discovery);
const streamUserAgent = config.navidrome.customClientsFor const streamUserAgent = config.subsonic.customClientsFor
? appendMimeTypeToClientFor(config.navidrome.customClientsFor.split(",")) ? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(","))
: DEFAULT; : DEFAULT;
const navidrome = new Navidrome( const subsonic = new Subsonic(
config.navidrome.url, config.subsonic.url,
encryption(config.secret), encryption(config.secret),
streamUserAgent streamUserAgent
); );
const featureFlagAwareMusicService: MusicService = { const featureFlagAwareMusicService: MusicService = {
generateToken: navidrome.generateToken, generateToken: subsonic.generateToken,
login: (authToken: string) => login: (authToken: string) =>
navidrome.login(authToken).then((library) => { subsonic.login(authToken).then((library) => {
return { return {
...library, ...library,
scrobble: (id: string) => { scrobble: (id: string) => {

View File

@@ -2,56 +2,91 @@ import { hostname } from "os";
import logger from "./logger"; import logger from "./logger";
import url from "./url_builder"; 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 () { export default function () {
const port = +(process.env["BONOB_PORT"] || 4534); const port = +bnbEnvVar("PORT", { default: "4534" })!;
const bonobUrl = const bonobUrl = bnbEnvVar("URL", {
process.env["BONOB_URL"] || legacy: ["BONOB_WEB_ADDRESS"],
process.env["BONOB_WEB_ADDRESS"] || default: `http://${hostname()}:${port}`,
`http://${hostname()}:${port}`; })!;
if (bonobUrl.match("localhost")) { if (bonobUrl.match("localhost")) {
logger.error( 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); 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 { return {
port, port,
bonobUrl: url(bonobUrl), bonobUrl: url(bonobUrl),
secret: process.env["BONOB_SECRET"] || "bonob", secret: bnbEnvVar("SECRET", { default: "bonob" })!,
icons: { icons: {
foregroundColor: wordFrom("BONOB_ICON_FOREGROUND_COLOR"), foregroundColor: bnbEnvVar("ICON_FOREGROUND_COLOR", {
backgroundColor: wordFrom("BONOB_ICON_BACKGROUND_COLOR"), validationPattern: WORD,
}),
backgroundColor: bnbEnvVar("ICON_BACKGROUND_COLOR", {
validationPattern: WORD,
}),
}, },
sonos: { sonos: {
serviceName: process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob", serviceName: bnbEnvVar("SONOS_SERVICE_NAME", { default: "bonob" })!,
discovery: { discovery: {
enabled: enabled:
(process.env["BONOB_SONOS_DEVICE_DISCOVERY"] || "true") == "true", bnbEnvVar("SONOS_DEVICE_DISCOVERY", { default: "true" }) == "true",
seedHost: process.env["BONOB_SONOS_SEED_HOST"], seedHost: bnbEnvVar("SONOS_SEED_HOST"),
}, },
autoRegister: autoRegister:
(process.env["BONOB_SONOS_AUTO_REGISTER"] || "false") == "true", bnbEnvVar("SONOS_AUTO_REGISTER", { default: "false" }) == "true",
sid: Number(process.env["BONOB_SONOS_SERVICE_ID"] || "246"), sid: Number(bnbEnvVar("SONOS_SERVICE_ID", { default: "246" })),
}, },
navidrome: { subsonic: {
url: process.env["BONOB_NAVIDROME_URL"] || `http://${hostname()}:4533`, url: bnbEnvVar("SUBSONIC_URL", { legacy: ["BONOB_NAVIDROME_URL"], default: `http://${hostname()}:4533` })!,
customClientsFor: customClientsFor: bnbEnvVar("SUBSONIC_CUSTOM_CLIENTS", { legacy: ["BONOB_NAVIDROME_CUSTOM_CLIENTS"] }),
process.env["BONOB_NAVIDROME_CUSTOM_CLIENTS"] || undefined,
}, },
scrobbleTracks: (process.env["BONOB_SCROBBLE_TRACKS"] || "true") == "true", scrobbleTracks: bnbEnvVar("SCROBBLE_TRACKS", { default: "true" }) == "true",
reportNowPlaying: reportNowPlaying:
(process.env["BONOB_REPORT_NOW_PLAYING"] || "true") == "true", bnbEnvVar("REPORT_NOW_PLAYING", { default: "true" }) == "true",
}; };
} }

View File

@@ -41,7 +41,7 @@ export type KEY =
const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = { const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
"en-US": { "en-US": {
AppLinkMessage: "Linking sonos with $BONOB_SONOS_SERVICE_NAME", AppLinkMessage: "Linking sonos with $BNB_SONOS_SERVICE_NAME",
artists: "Artists", artists: "Artists",
albums: "Albums", albums: "Albums",
tracks: "Tracks", tracks: "Tracks",
@@ -62,7 +62,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
devices: "Devices", devices: "Devices",
services: "Services", services: "Services",
login: "Login", login: "Login",
logInToBonob: "Log in to $BONOB_SONOS_SERVICE_NAME", logInToBonob: "Log in to $BNB_SONOS_SERVICE_NAME",
username: "Username", username: "Username",
password: "Password", password: "Password",
successfullyRegistered: "Successfully registered", successfullyRegistered: "Successfully registered",
@@ -75,7 +75,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
noSonosDevices: "No sonos devices", noSonosDevices: "No sonos devices",
}, },
"nl-NL": { "nl-NL": {
AppLinkMessage: "Sonos koppelen aan $BONOB_SONOS_SERVICE_NAME", AppLinkMessage: "Sonos koppelen aan $BNB_SONOS_SERVICE_NAME",
artists: "Artiesten", artists: "Artiesten",
albums: "Albums", albums: "Albums",
tracks: "Nummers", tracks: "Nummers",
@@ -96,7 +96,7 @@ const translations: Record<SUPPORTED_LANG, Record<KEY, string>> = {
devices: "Apparaten", devices: "Apparaten",
services: "Services", services: "Services",
login: "Inloggen", login: "Inloggen",
logInToBonob: "Login op $BONOB_SONOS_SERVICE_NAME", logInToBonob: "Login op $BNB_SONOS_SERVICE_NAME",
username: "Gebruikersnaam", username: "Gebruikersnaam",
password: "Wachtwoord", password: "Wachtwoord",
successfullyRegistered: "Registratie geslaagd", successfullyRegistered: "Registratie geslaagd",
@@ -151,7 +151,7 @@ export default (serviceName: string): I8N =>
translations["en-US"]; translations["en-US"];
return (key: KEY) => { return (key: KEY) => {
const value = langToUse[key]?.replace( const value = langToUse[key]?.replace(
"$BONOB_SONOS_SERVICE_NAME", "$BNB_SONOS_SERVICE_NAME",
serviceName serviceName
); );
if (value) return value; if (value) return value;

View File

@@ -52,6 +52,7 @@ export type AlbumSummary = {
name: string; name: string;
year: string | undefined; year: string | undefined;
genre: Genre | undefined; genre: Genre | undefined;
coverArt: string | undefined;
artistName: string; artistName: string;
artistId: string; artistId: string;
@@ -71,6 +72,7 @@ export type Track = {
duration: number; duration: number;
number: number | undefined; number: number | undefined;
genre: Genre | undefined; genre: Genre | undefined;
coverArt: string | undefined;
album: AlbumSummary; album: AlbumSummary;
artist: ArtistSummary; artist: ArtistSummary;
}; };
@@ -118,6 +120,7 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
genre: it.genre, genre: it.genre,
artistName: it.artistName, artistName: it.artistName,
artistId: it.artistId, artistId: it.artistId,
coverArt: it.coverArt
}); });
export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({ export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({
@@ -174,7 +177,7 @@ export interface MusicLibrary {
trackId: string; trackId: string;
range: string | undefined; range: string | undefined;
}): Promise<TrackStream>; }): 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> nowPlaying(id: string): Promise<boolean>
scrobble(id: string): Promise<boolean> scrobble(id: string): Promise<boolean>
searchArtists(query: string): Promise<ArtistSummary[]>; searchArtists(query: string): Promise<ArtistSummary[]>;

View File

@@ -15,6 +15,7 @@ import {
LOGIN_ROUTE, LOGIN_ROUTE,
CREATE_REGISTRATION_ROUTE, CREATE_REGISTRATION_ROUTE,
REMOVE_REGISTRATION_ROUTE, REMOVE_REGISTRATION_ROUTE,
sonosifyMimeType,
} from "./smapi"; } from "./smapi";
import { LinkCodes, InMemoryLinkCodes } from "./link_codes"; import { LinkCodes, InMemoryLinkCodes } from "./link_codes";
import { MusicService, isSuccess } from "./music_service"; import { MusicService, isSuccess } from "./music_service";
@@ -317,6 +318,13 @@ function server(
}, headers=(${JSON.stringify(stream.headers)})` }, headers=(${JSON.stringify(stream.headers)})`
); );
const sonosisfyContentType = (contentType: string) =>
contentType
.split(";")
.map((it) => it.trim())
.map((it) => sonosifyMimeType(it))
.join("; ");
const respondWith = ({ const respondWith = ({
status, status,
filter, filter,
@@ -326,7 +334,7 @@ function server(
}: { }: {
status: number; status: number;
filter: Transform; filter: Transform;
headers: Record<string, string | undefined>; headers: Record<string, string>;
sendStream: boolean; sendStream: boolean;
nowPlaying: boolean; nowPlaying: boolean;
}) => { }) => {
@@ -340,9 +348,11 @@ function server(
: Promise.resolve(true) : Promise.resolve(true)
).then((_) => { ).then((_) => {
res.status(status); res.status(status);
Object.entries(stream.headers) Object.entries(headers)
.filter(([_, v]) => v !== undefined) .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); if (sendStream) stream.stream.pipe(filter).pipe(res);
else res.send(); else res.send();
}); });
@@ -353,7 +363,9 @@ function server(
status: 200, status: 200,
filter: new PassThrough(), filter: new PassThrough(),
headers: { headers: {
"content-type": stream.headers["content-type"], "content-type": sonosisfyContentType(
stream.headers["content-type"]
),
"content-length": stream.headers["content-length"], "content-length": stream.headers["content-length"],
"accept-ranges": stream.headers["accept-ranges"], "accept-ranges": stream.headers["accept-ranges"],
}, },
@@ -365,7 +377,9 @@ function server(
status: 206, status: 206,
filter: new PassThrough(), filter: new PassThrough(),
headers: { headers: {
"content-type": stream.headers["content-type"], "content-type": sonosisfyContentType(
stream.headers["content-type"]
),
"content-length": stream.headers["content-length"], "content-length": stream.headers["content-length"],
"content-range": stream.headers["content-range"], "content-range": stream.headers["content-range"],
"accept-ranges": stream.headers["accept-ranges"], "accept-ranges": stream.headers["accept-ranges"],
@@ -457,25 +471,22 @@ function server(
"centre", "centre",
]; ];
app.get("/art/:type/:ids/size/:size", (req, res) => { app.get("/art/:ids/size/:size", (req, res) => {
const authToken = accessTokens.authTokenFor( const authToken = accessTokens.authTokenFor(
req.query[BONOB_ACCESS_TOKEN_HEADER] as string req.query[BONOB_ACCESS_TOKEN_HEADER] as string
); );
const type = req.params["type"]!;
const ids = req.params["ids"]!.split("&"); const ids = req.params["ids"]!.split("&");
const size = Number.parseInt(req.params["size"]!); const size = Number.parseInt(req.params["size"]!);
if (!authToken) { if (!authToken) {
return res.status(401).send(); return res.status(401).send();
} else if (type != "artist" && type != "album") {
return res.status(400).send();
} else if (!(size > 0)) { } else if (!(size > 0)) {
return res.status(400).send(); return res.status(400).send();
} }
return musicService return musicService
.login(authToken) .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((coverArts) => coverArts.filter((it) => it))
.then(shuffle) .then(shuffle)
.then((coverArts) => { .then((coverArts) => {
@@ -513,12 +524,9 @@ function server(
} }
}) })
.catch((e: Error) => { .catch((e: Error) => {
logger.error( logger.error(`Failed fetching image ${ids.join("&")}/size/${size}`, {
`Failed fetching image ${type}/${ids.join("&")}/size/${size}`, cause: e,
{ });
cause: e,
}
);
return res.status(500).send(); return res.status(500).send();
}); });
}); });

View File

@@ -215,10 +215,7 @@ const genre = (bonobUrl: URLBuilder, genre: Genre) => ({
itemType: "container", itemType: "container",
id: `genre:${genre.id}`, id: `genre:${genre.id}`,
title: genre.name, title: genre.name,
albumArtURI: iconArtURI( albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(),
bonobUrl,
iconForGenre(genre.name)
).href(),
}); });
const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({ const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
@@ -238,31 +235,37 @@ export const playlistAlbumArtURL = (
bonobUrl: URLBuilder, bonobUrl: URLBuilder,
playlist: Playlist 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) { if (ids.length == 0) {
return iconArtURI(bonobUrl, "error"); return iconArtURI(bonobUrl, "error");
} else { } else {
return bonobUrl.append({ 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) => export const defaultAlbumArtURI = (
bonobUrl.append({ pathname: `/art/album/${album.id}/size/180` });
export const iconArtURI = (
bonobUrl: URLBuilder, 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({ bonobUrl.append({
pathname: `/icon/${icon}/size/legacy` pathname: `/icon/${icon}/size/legacy`,
}); });
export const defaultArtistArtURI = ( export const defaultArtistArtURI = (
bonobUrl: URLBuilder, bonobUrl: URLBuilder,
artist: ArtistSummary 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) => ({ export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
itemType: "album", itemType: "album",
@@ -281,17 +284,17 @@ export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
export const track = (bonobUrl: URLBuilder, track: Track) => ({ export const track = (bonobUrl: URLBuilder, track: Track) => ({
itemType: "track", itemType: "track",
id: `track:${track.id}`, id: `track:${track.id}`,
mimeType: track.mimeType, mimeType: sonosifyMimeType(track.mimeType),
title: track.name, title: track.name,
trackMetadata: { trackMetadata: {
album: track.album.name, album: track.album.name,
albumId: track.album.id, albumId: `album:${track.album.id}`,
albumArtist: track.artist.name, albumArtist: track.artist.name,
albumArtistId: track.artist.id, albumArtistId: `artist:${track.artist.id}`,
albumArtURI: defaultAlbumArtURI(bonobUrl, track.album).href(), albumArtURI: defaultAlbumArtURI(bonobUrl, track).href(),
artist: track.artist.name, artist: track.artist.name,
artistId: track.artist.id, artistId: `artist:${track.artist.id}`,
duration: track.duration, duration: track.duration,
genre: track.album.genre?.name, genre: track.album.genre?.name,
genreId: track.album.genre?.id, genreId: track.album.genre?.id,
@@ -368,7 +371,7 @@ function bindSmapiSoapServiceToExpress(
const urlWithToken = (accessToken: string) => const urlWithToken = (accessToken: string) =>
bonobUrl.append({ bonobUrl.append({
searchParams: { searchParams: {
"bat": accessToken, bat: accessToken,
}, },
}); });
@@ -506,23 +509,7 @@ function bindSmapiSoapServiceToExpress(
return musicLibrary.track(typeId).then((it) => ({ return musicLibrary.track(typeId).then((it) => ({
getExtendedMetadataResult: { getExtendedMetadataResult: {
mediaMetadata: { mediaMetadata: {
id: `track:${it.id}`, ...track(urlWithToken(accessToken), it)
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(),
},
}, },
}, },
})); }));

View File

@@ -148,7 +148,7 @@ export type song = {
_artist: string; _artist: string;
_track: string | undefined; _track: string | undefined;
_genre: string; _genre: string;
_coverArt: string; _coverArt: string | undefined;
_created: "2004-11-08T23:36:11"; _created: "2004-11-08T23:36:11";
_duration: string | undefined; _duration: string | undefined;
_bitRate: "128"; _bitRate: "128";
@@ -179,6 +179,7 @@ export type entry = {
_track: string; _track: string;
_year: string; _year: string;
_genre: string; _genre: string;
_coverArt: string;
_contentType: string; _contentType: string;
_duration: string; _duration: string;
_albumId: string; _albumId: string;
@@ -223,6 +224,12 @@ export function isError(
return (subsonicResponse as SubsonicError).error !== undefined; 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 = { export type IdName = {
id: string; id: string;
name: string; name: string;
@@ -239,6 +246,8 @@ export type getAlbumListParams = {
export const MAX_ALBUM_LIST = 500; export const MAX_ALBUM_LIST = 500;
const maybeAsCoverArt = (coverArt: string | undefined) => coverArt ? `coverArt:${coverArt}` : undefined
const asTrack = (album: Album, song: song) => ({ const asTrack = (album: Album, song: song) => ({
id: song._id, id: song._id,
name: song._title, name: song._title,
@@ -246,6 +255,7 @@ const asTrack = (album: Album, song: song) => ({
duration: parseInt(song._duration || "0"), duration: parseInt(song._duration || "0"),
number: parseInt(song._track || "0"), number: parseInt(song._track || "0"),
genre: maybeAsGenre(song._genre), genre: maybeAsGenre(song._genre),
coverArt: maybeAsCoverArt(song._coverArt),
album, album,
artist: { artist: {
id: song._artistId, id: song._artistId,
@@ -260,6 +270,7 @@ const asAlbum = (album: album) => ({
genre: maybeAsGenre(album._genre), genre: maybeAsGenre(album._genre),
artistId: album._artistId, artistId: album._artistId,
artistName: album._artist, artistName: album._artist,
coverArt: maybeAsCoverArt(album._coverArt)
}); });
export const asGenre = (genreName: string) => ({ export const asGenre = (genreName: string) => ({
@@ -298,7 +309,7 @@ export const asURLSearchParams = (q: any) => {
return urlSearchParams; return urlSearchParams;
}; };
export class Navidrome implements MusicService { export class Subsonic implements MusicService {
url: string; url: string;
encryption: Encryption; encryption: Encryption;
streamClientApplication: StreamClientApplication; streamClientApplication: StreamClientApplication;
@@ -335,7 +346,7 @@ export class Navidrome implements MusicService {
}) })
.then((response) => { .then((response) => {
if (response.status != 200 && response.status != 206) { 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; } else return response;
}); });
@@ -368,7 +379,7 @@ export class Navidrome implements MusicService {
) )
.then((json) => json["subsonic-response"]) .then((json) => json["subsonic-response"])
.then((json) => { .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; else return json as unknown as T;
}); });
@@ -427,6 +438,7 @@ export class Navidrome implements MusicService {
genre: maybeAsGenre(album._genre), genre: maybeAsGenre(album._genre),
artistId: album._artistId, artistId: album._artistId,
artistName: album._artist, artistName: album._artist,
coverArt: maybeAsCoverArt(album._coverArt)
})); }));
getArtist = ( getArtist = (
@@ -440,14 +452,7 @@ export class Navidrome implements MusicService {
.then((it) => ({ .then((it) => ({
id: it._id, id: it._id,
name: it._name, name: it._name,
albums: (it.album || []).map((album) => ({ albums: this.toAlbumSummary(it.album || []),
id: album._id,
name: album._name,
year: album._year,
genre: maybeAsGenre(album._genre),
artistId: it._id,
artistName: it._name,
})),
})); }));
getArtistWithInfo = (credentials: Credentials, id: string) => getArtistWithInfo = (credentials: Credentials, id: string) =>
@@ -487,6 +492,7 @@ export class Navidrome implements MusicService {
genre: maybeAsGenre(album._genre), genre: maybeAsGenre(album._genre),
artistId: album._artistId, artistId: album._artistId,
artistName: album._artist, artistName: album._artist,
coverArt: maybeAsCoverArt(album._coverArt)
})); }));
search3 = (credentials: Credentials, q: any) => search3 = (credentials: Credentials, q: any) =>
@@ -602,14 +608,16 @@ export class Navidrome implements MusicService {
stream: res.data, stream: res.data,
})) }))
), ),
coverArt: async (id: string, type: "album" | "artist", size?: number) => { coverArt: async (coverArt: string, size?: number) => {
if (type == "album") { const [type, id] = splitCoverArtId(coverArt);
if (type == "coverArt") {
return navidrome.getCoverArt(credentials, id, size).then((res) => ({ return navidrome.getCoverArt(credentials, id, size).then((res) => ({
contentType: res.headers["content-type"], contentType: res.headers["content-type"],
data: Buffer.from(res.data, "binary"), data: Buffer.from(res.data, "binary"),
})); }));
} else { } else {
return navidrome.getArtistWithInfo(credentials, id).then((artist) => { return navidrome.getArtistWithInfo(credentials, id).then((artist) => {
const albumsWithCoverArt = artist.albums.filter(it => it.coverArt);
if (artist.image.large) { if (artist.image.large) {
return axios return axios
.get(artist.image.large!, { .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 return navidrome
.getCoverArt(credentials, artist.albums[0]!.id, size) .getCoverArt(credentials, splitCoverArtId(albumsWithCoverArt[0]!.coverArt!)[1], size)
.then((res) => ({ .then((res) => ({
contentType: res.headers["content-type"], contentType: res.headers["content-type"],
data: Buffer.from(res.data, "binary"), data: Buffer.from(res.data, "binary"),
@@ -708,6 +716,7 @@ export class Navidrome implements MusicService {
duration: parseInt(entry._duration || "0"), duration: parseInt(entry._duration || "0"),
number: trackNumber++, number: trackNumber++,
genre: maybeAsGenre(entry._genre), genre: maybeAsGenre(entry._genre),
coverArt: maybeAsCoverArt(entry._coverArt),
album: { album: {
id: entry._albumId, id: entry._albumId,
name: entry._album, name: entry._album,
@@ -715,6 +724,7 @@ export class Navidrome implements MusicService {
genre: maybeAsGenre(entry._genre), genre: maybeAsGenre(entry._genre),
artistName: entry._artist, artistName: entry._artist,
artistId: entry._artistId, artistId: entry._artistId,
coverArt: maybeAsCoverArt(entry._coverArt)
}, },
artist: { artist: {
id: entry._artistId, id: entry._artistId,

View File

@@ -141,6 +141,7 @@ export function aTrack(fields: Partial<Track> = {}): Track {
genre, genre,
artist: artistToArtistSummary(artist), artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(anAlbum({ artistId: artist.id, artistName: artist.name, genre })), album: albumToAlbumSummary(anAlbum({ artistId: artist.id, artistName: artist.name, genre })),
coverArt: `coverArt:${uuid()}`,
...fields, ...fields,
}; };
} }
@@ -154,6 +155,7 @@ export function anAlbum(fields: Partial<Album> = {}): Album {
year: `19${randomInt(99)}`, year: `19${randomInt(99)}`,
artistId: `Artist ${uuid()}`, artistId: `Artist ${uuid()}`,
artistName: `Artist ${randomString()}`, artistName: `Artist ${randomString()}`,
coverArt: `coverArt:${uuid()}`,
...fields, ...fields,
}; };
} }
@@ -170,7 +172,8 @@ export const BLONDIE: Artist = {
year: "1976", year: "1976",
genre: NEW_WAVE, genre: NEW_WAVE,
artistId: BLONDIE_ID, artistId: BLONDIE_ID,
artistName: BLONDIE_NAME artistName: BLONDIE_NAME,
coverArt: `coverArt:${uuid()}`
}, },
{ {
id: uuid(), id: uuid(),
@@ -178,7 +181,8 @@ export const BLONDIE: Artist = {
year: "1978", year: "1978",
genre: POP_ROCK, genre: POP_ROCK,
artistId: BLONDIE_ID, artistId: BLONDIE_ID,
artistName: BLONDIE_NAME artistName: BLONDIE_NAME,
coverArt: `coverArt:${uuid()}`
}, },
], ],
image: { image: {
@@ -195,9 +199,9 @@ export const BOB_MARLEY: Artist = {
id: BOB_MARLEY_ID, id: BOB_MARLEY_ID,
name: BOB_MARLEY_NAME, name: BOB_MARLEY_NAME,
albums: [ albums: [
{ id: uuid(), name: "Burin'", year: "1973", genre: REGGAE, 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 }, { 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 }, { id: uuid(), name: "Kaya", year: "1978", genre: SKA, artistId: BOB_MARLEY_ID, artistName: BOB_MARLEY_NAME, coverArt: `coverArt:${uuid()}` },
], ],
image: { image: {
small: "http://localhost/BOB_MARLEY/sml", small: "http://localhost/BOB_MARLEY/sml",
@@ -234,6 +238,7 @@ export const METALLICA: Artist = {
genre: METAL, genre: METAL,
artistId: METALLICA_ID, artistId: METALLICA_ID,
artistName: METALLICA_NAME, artistName: METALLICA_NAME,
coverArt: `coverArt:${uuid()}`
}, },
{ {
id: uuid(), id: uuid(),
@@ -241,7 +246,8 @@ export const METALLICA: Artist = {
year: "1986", year: "1986",
genre: METAL, genre: METAL,
artistId: METALLICA_ID, artistId: METALLICA_ID,
artistName: METALLICA_NAME, artistName: METALLICA_NAME,
coverArt: `coverArt:${uuid()}`
}, },
], ],
image: { image: {

View File

@@ -1,5 +1,79 @@
import { hostname } from "os"; 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", () => { describe("config", () => {
const OLD_ENV = process.env; const OLD_ENV = process.env;
@@ -43,26 +117,22 @@ describe("config", () => {
} }
describe("bonobUrl", () => { describe("bonobUrl", () => {
describe("when BONOB_URL is specified", () => { ["BNB_URL", "BONOB_URL", "BONOB_WEB_ADDRESS"].forEach(key => {
it("should be used", () => { describe(`when ${key} is specified`, () => {
const url = "http://bonob1.example.com:8877/"; it("should be used", () => {
process.env["BONOB_URL"] = url; const url = "http://bonob1.example.com:8877/";
expect(config().bonobUrl.href()).toEqual(url); process.env["BNB_URL"] = "";
process.env["BONOB_URL"] = "";
process.env["BONOB_WEB_ADDRESS"] = "";
process.env[key] = url;
expect(config().bonobUrl.href()).toEqual(url);
});
}); });
}); });
describe("when BONOB_URL is not specified, however legacy BONOB_WEB_ADDRESS is specified", () => { describe("when none of BNB_URL, BONOB_URL, BONOB_WEB_ADDRESS are specified", () => {
it("should be used", () => {
const url = "http://bonob2.example.com:9988/";
process.env["BONOB_URL"] = "";
process.env["BONOB_WEB_ADDRESS"] = url;
expect(config().bonobUrl.href()).toEqual(url);
});
});
describe("when neither BONOB_URL nor BONOB_WEB_ADDRESS are specified", () => {
describe("when BONOB_PORT is not specified", () => { describe("when BONOB_PORT is not specified", () => {
it(`should default to http://${hostname()}:4534`, () => { it(`should default to http://${hostname()}:4534`, () => {
expect(config().bonobUrl.href()).toEqual( 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", () => { describe("when BONOB_PORT is specified as 3322", () => {
it(`should default to http://${hostname()}:3322`, () => { it(`should default to http://${hostname()}:3322`, () => {
process.env["BONOB_PORT"] = "3322"; process.env["BONOB_PORT"] = "3322";
@@ -82,90 +161,69 @@ 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("icons", () => {
describe("foregroundColor", () => { describe("foregroundColor", () => {
describe("when BONOB_ICON_FOREGROUND_COLOR is not specified", () => { ["BNB_ICON_FOREGROUND_COLOR", "BONOB_ICON_FOREGROUND_COLOR"].forEach(k => {
it(`should default to undefined`, () => { describe(`when ${k} is not specified`, () => {
expect(config().icons.foregroundColor).toEqual(undefined); it(`should default to undefined`, () => {
expect(config().icons.foregroundColor).toEqual(undefined);
});
}); });
});
describe(`when ${k} is ''`, () => {
describe("when BONOB_ICON_FOREGROUND_COLOR is ''", () => { it(`should default to undefined`, () => {
it(`should default to undefined`, () => { process.env[k] = "";
process.env["BONOB_ICON_FOREGROUND_COLOR"] = ""; expect(config().icons.foregroundColor).toEqual(undefined);
expect(config().icons.foregroundColor).toEqual(undefined); });
}); });
});
describe(`when ${k} is specified`, () => {
describe("when BONOB_ICON_FOREGROUND_COLOR is specified", () => { it(`should use it`, () => {
it(`should use it`, () => { process.env[k] = "pink";
process.env["BONOB_ICON_FOREGROUND_COLOR"] = "pink"; expect(config().icons.foregroundColor).toEqual("pink");
expect(config().icons.foregroundColor).toEqual("pink"); });
}); });
});
describe(`when ${k} is an invalid string`, () => {
describe("when BONOB_ICON_FOREGROUND_COLOR is an invalid string", () => { it(`should blow up`, () => {
it(`should blow up`, () => { process.env[k] = "#dfasd";
process.env["BONOB_ICON_FOREGROUND_COLOR"] = "#dfasd"; expect(() => config()).toThrow(
expect(() => config()).toThrow( `Invalid value specified for 'BNB_ICON_FOREGROUND_COLOR', must match ${WORD}`
"Invalid color specified for BONOB_ICON_FOREGROUND_COLOR" );
); });
}); });
}); });
}); });
describe("backgroundColor", () => { describe("backgroundColor", () => {
describe("when BONOB_ICON_BACKGROUND_COLOR is not specified", () => { ["BNB_ICON_BACKGROUND_COLOR", "BONOB_ICON_BACKGROUND_COLOR"].forEach(k => {
it(`should default to undefined`, () => { describe(`when ${k} is not specified`, () => {
expect(config().icons.backgroundColor).toEqual(undefined); it(`should default to undefined`, () => {
expect(config().icons.backgroundColor).toEqual(undefined);
});
}); });
});
describe(`when ${k} is ''`, () => {
describe("when BONOB_ICON_BACKGROUND_COLOR is ''", () => { it(`should default to undefined`, () => {
it(`should default to undefined`, () => { process.env[k] = "";
process.env["BONOB_ICON_BACKGROUND_COLOR"] = ""; expect(config().icons.backgroundColor).toEqual(undefined);
expect(config().icons.backgroundColor).toEqual(undefined); });
}); });
});
describe(`when ${k} is specified`, () => {
describe("when BONOB_ICON_BACKGROUND_COLOR is specified", () => { it(`should use it`, () => {
it(`should use it`, () => { process.env[k] = "blue";
process.env["BONOB_ICON_BACKGROUND_COLOR"] = "blue"; expect(config().icons.backgroundColor).toEqual("blue");
expect(config().icons.backgroundColor).toEqual("blue"); });
}); });
});
describe(`when ${k} is an invalid string`, () => {
describe("when BONOB_ICON_BACKGROUND_COLOR is an invalid string", () => { it(`should blow up`, () => {
it(`should blow up`, () => { process.env[k] = "#red";
process.env["BONOB_ICON_BACKGROUND_COLOR"] = "#red"; expect(() => config()).toThrow(
expect(() => config()).toThrow( `Invalid value specified for 'BNB_ICON_BACKGROUND_COLOR', must match ${WORD}`
"Invalid color specified for BONOB_ICON_BACKGROUND_COLOR" );
); });
}); });
}); });
}); });
@@ -176,9 +234,11 @@ describe("config", () => {
expect(config().secret).toEqual("bonob"); expect(config().secret).toEqual("bonob");
}); });
it("should be overridable", () => { ["BNB_SECRET", "BONOB_SECRET"].forEach(key => {
process.env["BONOB_SECRET"] = "new secret"; it(`should be overridable using ${key}`, () => {
expect(config().secret).toEqual("new secret"); process.env[key] = "new secret";
expect(config().secret).toEqual("new secret");
});
}); });
}); });
@@ -188,83 +248,116 @@ describe("config", () => {
expect(config().sonos.serviceName).toEqual("bonob"); expect(config().sonos.serviceName).toEqual("bonob");
}); });
it("should be overridable", () => { ["BNB_SONOS_SERVICE_NAME", "BONOB_SONOS_SERVICE_NAME"].forEach(k => {
process.env["BONOB_SONOS_SERVICE_NAME"] = "foobar1000"; it("should be overridable", () => {
expect(config().sonos.serviceName).toEqual("foobar1000"); process.env[k] = "foobar1000";
expect(config().sonos.serviceName).toEqual("foobar1000");
});
}); });
}); });
describeBooleanConfigValue( ["BNB_SONOS_DEVICE_DISCOVERY", "BONOB_SONOS_DEVICE_DISCOVERY"].forEach(k => {
"deviceDiscovery", describeBooleanConfigValue(
"BONOB_SONOS_DEVICE_DISCOVERY", "deviceDiscovery",
true, k,
(config) => config.sonos.discovery.enabled true,
); (config) => config.sonos.discovery.enabled
);
});
describe("seedHost", () => { describe("seedHost", () => {
it("should default to undefined", () => { it("should default to undefined", () => {
expect(config().sonos.discovery.seedHost).toBeUndefined(); expect(config().sonos.discovery.seedHost).toBeUndefined();
}); });
it("should be overridable", () => { ["BNB_SONOS_SEED_HOST", "BONOB_SONOS_SEED_HOST"].forEach(k => {
process.env["BONOB_SONOS_SEED_HOST"] = "123.456.789.0"; it("should be overridable", () => {
expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0"); process.env[k] = "123.456.789.0";
expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0");
});
}); });
}); });
describeBooleanConfigValue( ["BNB_SONOS_AUTO_REGISTER", "BONOB_SONOS_AUTO_REGISTER"].forEach(k => {
"autoRegister", describeBooleanConfigValue(
"BONOB_SONOS_AUTO_REGISTER", "autoRegister",
false, k,
(config) => config.sonos.autoRegister false,
); (config) => config.sonos.autoRegister
);
});
describe("sid", () => { describe("sid", () => {
it("should default to 246", () => { it("should default to 246", () => {
expect(config().sonos.sid).toEqual(246); expect(config().sonos.sid).toEqual(246);
}); });
it("should be overridable", () => { ["BNB_SONOS_SERVICE_ID", "BONOB_SONOS_SERVICE_ID"].forEach(k => {
process.env["BONOB_SONOS_SERVICE_ID"] = "786"; it("should be overridable", () => {
expect(config().sonos.sid).toEqual(786); process.env[k] = "786";
expect(config().sonos.sid).toEqual(786);
});
}); });
}); });
}); });
describe("navidrome", () => { describe("subsonic", () => {
describe("url", () => { describe("url", () => {
it("should default to http://${hostname()}:4533", () => { ["BNB_SUBSONIC_URL", "BONOB_SUBSONIC_URL", "BONOB_NAVIDROME_URL"].forEach(k => {
expect(config().navidrome.url).toEqual(`http://${hostname()}:4533`); describe(`when ${k} is not specified`, () => {
}); it(`should default to http://${hostname()}:4533`, () => {
expect(config().subsonic.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(`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", () => { describe("customClientsFor", () => {
it("should default to undefined", () => { it("should default to undefined", () => {
expect(config().navidrome.customClientsFor).toBeUndefined(); expect(config().subsonic.customClientsFor).toBeUndefined();
}); });
it("should be overridable", () => { ["BNB_SUBSONIC_CUSTOM_CLIENTS", "BONOB_SUBSONIC_CUSTOM_CLIENTS", "BONOB_NAVIDROME_CUSTOM_CLIENTS"].forEach(k => {
process.env["BONOB_NAVIDROME_CUSTOM_CLIENTS"] = "whoop/whoop"; it(`should be overridable for ${k}`, () => {
expect(config().navidrome.customClientsFor).toEqual("whoop/whoop"); process.env[k] = "whoop/whoop";
expect(config().subsonic.customClientsFor).toEqual("whoop/whoop");
});
}); });
}); });
});
["BNB_SCROBBLE_TRACKS", "BONOB_SCROBBLE_TRACKS"].forEach(k => {
describeBooleanConfigValue(
"scrobbleTracks",
k,
true,
(config) => config.scrobbleTracks
);
}); });
describeBooleanConfigValue( ["BNB_REPORT_NOW_PLAYING", "BONOB_REPORT_NOW_PLAYING"].forEach(k => {
"scrobbleTracks", describeBooleanConfigValue(
"BONOB_SCROBBLE_TRACKS", "reportNowPlaying",
true, k,
(config) => config.scrobbleTracks true,
); (config) => config.reportNowPlaying
describeBooleanConfigValue( );
"reportNowPlaying", });
"BONOB_REPORT_NOW_PLAYING",
true,
(config) => config.reportNowPlaying
);
}); });

View File

@@ -125,7 +125,7 @@ export class InMemoryMusicService implements MusicService {
), ),
stream: (_: { trackId: string; range: string | undefined }) => stream: (_: { trackId: string; range: string | undefined }) =>
Promise.reject("unsupported operation"), 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}`), Promise.reject(`Cannot retrieve coverArt for ${id}, size ${size}`),
scrobble: async (_: string) => { scrobble: async (_: string) => {
return Promise.resolve(true); return Promise.resolve(true);

View File

@@ -774,7 +774,8 @@ describe("server", () => {
const trackStream = { const trackStream = {
status: 200, status: 200,
headers: { 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", "content-length": "123",
}, },
stream: streamContent(""), stream: streamContent(""),
@@ -793,7 +794,7 @@ describe("server", () => {
expect(res.status).toEqual(trackStream.status); expect(res.status).toEqual(trackStream.status);
expect(res.headers["content-type"]).toEqual( 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.headers["content-length"]).toEqual("123");
expect(res.body).toEqual({}); expect(res.body).toEqual({});
@@ -883,7 +884,8 @@ describe("server", () => {
const stream = { const stream = {
status: 200, status: 200,
headers: { 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), stream: streamContent(content),
}; };
@@ -902,7 +904,7 @@ describe("server", () => {
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(stream.status);
expect(res.headers["content-type"]).toEqual( expect(res.headers["content-type"]).toEqual(
"audio/mp3; charset=utf-8" "audio/flac; charset=utf-8"
); );
expect(res.header["accept-ranges"]).toBeUndefined(); expect(res.header["accept-ranges"]).toBeUndefined();
expect(res.headers["content-length"]).toEqual( expect(res.headers["content-length"]).toEqual(
@@ -1173,7 +1175,7 @@ describe("server", () => {
describe("when there is no access-token", () => { describe("when there is no access-token", () => {
it("should return a 401", async () => { 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); expect(res.status).toEqual(401);
}); });
@@ -1184,7 +1186,7 @@ describe("server", () => {
now = now.add(1, "day"); now = now.add(1, "day");
const res = await request(server).get( 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); expect(res.status).toEqual(401);
@@ -1192,18 +1194,6 @@ describe("server", () => {
}); });
describe("when there is a valid access token", () => { 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", () => { describe("artist art", () => {
["0", "-1", "foo"].forEach((size) => { ["0", "-1", "foo"].forEach((size) => {
describe(`invalid size of ${size}`, () => { describe(`invalid size of ${size}`, () => {
@@ -1211,7 +1201,7 @@ describe("server", () => {
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
const res = await request(server) const res = await request(server)
.get( .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); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
@@ -1231,7 +1221,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .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); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
@@ -1242,8 +1232,7 @@ describe("server", () => {
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(musicLibrary.coverArt).toHaveBeenCalledWith( expect(musicLibrary.coverArt).toHaveBeenCalledWith(
albumId, `artist:${albumId}`,
"artist",
180 180
); );
}); });
@@ -1257,7 +1246,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .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); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
@@ -1271,7 +1260,7 @@ describe("server", () => {
describe("fetching a collage of 4 when all are available", () => { describe("fetching a collage of 4 when all are available", () => {
it("should return the image and a 200", async () => { 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); musicService.login.mockResolvedValue(musicLibrary);
@@ -1283,11 +1272,10 @@ describe("server", () => {
); );
}); });
const res = await request(server) const res = await request(server)
.get( .get(
`/art/artist/${ids.join( `/art/${ids.join("&")}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
"&"
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
@@ -1298,7 +1286,6 @@ describe("server", () => {
ids.forEach((id) => { ids.forEach((id) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith( expect(musicLibrary.coverArt).toHaveBeenCalledWith(
id, id,
"artist",
200 200
); );
}); });
@@ -1311,7 +1298,7 @@ describe("server", () => {
describe("fetching a collage of 4, however only 1 is available", () => { describe("fetching a collage of 4, however only 1 is available", () => {
it("should return the single image", async () => { 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); musicService.login.mockResolvedValue(musicLibrary);
@@ -1327,7 +1314,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
`/art/artist/${ids.join( `/art/${ids.join(
"&" "&"
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` )}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
) )
@@ -1340,7 +1327,7 @@ describe("server", () => {
describe("fetching a collage of 4 and all are missing", () => { describe("fetching a collage of 4 and all are missing", () => {
it("should return a 404", async () => { 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); musicService.login.mockResolvedValue(musicLibrary);
@@ -1350,7 +1337,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
`/art/artist/${ids.join( `/art/${ids.join(
"&" "&"
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` )}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
) )
@@ -1362,7 +1349,7 @@ describe("server", () => {
describe("fetching a collage of 9 when all are available", () => { describe("fetching a collage of 9 when all are available", () => {
it("should return the image and a 200", async () => { 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); musicService.login.mockResolvedValue(musicLibrary);
@@ -1376,7 +1363,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
`/art/artist/${ids.join( `/art/${ids.join(
"&" "&"
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` )}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
) )
@@ -1389,7 +1376,6 @@ describe("server", () => {
ids.forEach((id) => { ids.forEach((id) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith( expect(musicLibrary.coverArt).toHaveBeenCalledWith(
id, id,
"artist",
180 180
); );
}); });
@@ -1402,7 +1388,7 @@ describe("server", () => {
describe("fetching a collage of 9 when only 2 are available", () => { describe("fetching a collage of 9 when only 2 are available", () => {
it("should still return an image and a 200", async () => { 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); musicService.login.mockResolvedValue(musicLibrary);
@@ -1426,7 +1412,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
`/art/artist/${ids.join( `/art/${ids.join(
"&" "&"
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` )}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
) )
@@ -1439,7 +1425,6 @@ describe("server", () => {
ids.forEach((id) => { ids.forEach((id) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith( expect(musicLibrary.coverArt).toHaveBeenCalledWith(
id, id,
"artist",
180 180
); );
}); });
@@ -1452,7 +1437,7 @@ describe("server", () => {
describe("fetching a collage of 11", () => { describe("fetching a collage of 11", () => {
it("should still return an image and a 200, though will only display 9", async () => { 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); musicService.login.mockResolvedValue(musicLibrary);
@@ -1466,7 +1451,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
`/art/artist/${ids.join( `/art/${ids.join(
"&" "&"
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}` )}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${accessToken}`
) )
@@ -1479,7 +1464,6 @@ describe("server", () => {
ids.forEach((id) => { ids.forEach((id) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith( expect(musicLibrary.coverArt).toHaveBeenCalledWith(
id, id,
"artist",
180 180
); );
}); });
@@ -1498,7 +1482,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .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); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
@@ -1515,7 +1499,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .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); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
@@ -1531,7 +1515,7 @@ describe("server", () => {
musicService.login.mockResolvedValue(musicLibrary); musicService.login.mockResolvedValue(musicLibrary);
const res = await request(server) const res = await request(server)
.get( .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); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
@@ -1553,7 +1537,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .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); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
@@ -1564,8 +1548,7 @@ describe("server", () => {
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);
expect(musicLibrary.coverArt).toHaveBeenCalledWith( expect(musicLibrary.coverArt).toHaveBeenCalledWith(
albumId, `coverArt:${albumId}`,
"album",
180 180
); );
}); });
@@ -1578,7 +1561,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .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); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
@@ -1593,7 +1576,7 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .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); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);

View File

@@ -23,6 +23,7 @@ import {
searchResult, searchResult,
iconArtURI, iconArtURI,
playlistAlbumArtURL, playlistAlbumArtURL,
sonosifyMimeType,
} from "../src/smapi"; } from "../src/smapi";
import { import {
@@ -48,7 +49,7 @@ import {
} from "../src/music_service"; } from "../src/music_service";
import { AccessTokens } from "../src/access_tokens"; import { AccessTokens } from "../src/access_tokens";
import dayjs from "dayjs"; import dayjs from "dayjs";
import url from "../src/url_builder"; import url, { URLBuilder } from "../src/url_builder";
import { iconForGenre } from "../src/icon"; import { iconForGenre } from "../src/icon";
const parseXML = (value: string) => new DOMParserImpl().parseFromString(value); 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 bonobUrl = url("http://localhost:4567/foo?access-token=1234");
const someTrack = aTrack({ const someTrack = aTrack({
id: uuid(), id: uuid(),
mimeType: "audio/something", // audio/x-flac should be mapped to audio/flac
mimeType: "audio/x-flac",
name: "great song", name: "great song",
duration: randomInt(1000), duration: randomInt(1000),
number: randomInt(100), number: randomInt(100),
@@ -262,22 +264,23 @@ describe("track", () => {
genre: { id: "genre101", name: "some genre" }, genre: { id: "genre101", name: "some genre" },
}), }),
artist: anArtist({ name: "great artist", id: uuid() }), artist: anArtist({ name: "great artist", id: uuid() }),
coverArt:"coverArt:887766"
}); });
expect(track(bonobUrl, someTrack)).toEqual({ expect(track(bonobUrl, someTrack)).toEqual({
itemType: "track", itemType: "track",
id: `track:${someTrack.id}`, id: `track:${someTrack.id}`,
mimeType: someTrack.mimeType, mimeType: 'audio/flac',
title: someTrack.name, title: someTrack.name,
trackMetadata: { trackMetadata: {
album: someTrack.album.name, album: someTrack.album.name,
albumId: someTrack.album.id, albumId: `album:${someTrack.album.id}`,
albumArtist: someTrack.artist.name, albumArtist: someTrack.artist.name,
albumArtistId: someTrack.artist.id, albumArtistId: `artist:${someTrack.artist.id}`,
albumArtURI: `http://localhost:4567/foo/art/album/${someTrack.album.id}/size/180?access-token=1234`, albumArtURI: `http://localhost:4567/foo/art/${someTrack.coverArt}/size/180?access-token=1234`,
artist: someTrack.artist.name, artist: someTrack.artist.name,
artistId: someTrack.artist.id, artistId: `artist:${someTrack.artist.id}`,
duration: someTrack.duration, duration: someTrack.duration,
genre: someTrack.album.genre?.name, genre: someTrack.album.genre?.name,
genreId: someTrack.album.genre?.id, 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("playlistAlbumArtURL", () => {
describe("when the playlist has no albumIds", () => { describe("when the playlist has no coverArt ids", () => {
it("should return question mark icon", () => { it("should return question mark icon", () => {
const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const bonobUrl = url("http://localhost:1234/context-path?search=yes");
const playlist = aPlaylist({ const playlist = aPlaylist({
entries: [aTrack({ album: undefined }), aTrack({ album: undefined })], entries: [aTrack({ coverArt: undefined }), aTrack({ coverArt: undefined })],
}); });
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( 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", () => { it("should return them on the url to the image", () => {
const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const bonobUrl = url("http://localhost:1234/context-path?search=yes");
const playlist = aPlaylist({ const playlist = aPlaylist({
entries: [ entries: [
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }), aTrack({ coverArt: "1" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }), aTrack({ coverArt: "2" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }), aTrack({ coverArt: "1" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }), aTrack({ coverArt: "2" }),
], ],
}); });
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( 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,52 +360,75 @@ describe("playlistAlbumArtURL", () => {
const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const bonobUrl = url("http://localhost:1234/context-path?search=yes");
const playlist = aPlaylist({ const playlist = aPlaylist({
entries: [ entries: [
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }), aTrack({ coverArt: "1" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }), aTrack({ coverArt: "2" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }), aTrack({ coverArt: "2" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "3" })) }), aTrack({ coverArt: "3" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "4" })) }), aTrack({ coverArt: "4" }),
], ],
}); });
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( 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", () => { describe("when the playlist has at least 9 distinct albumIds", () => {
it("should return 9 of the ids on the url", () => { it("should return the first 9 of the ids on the url", () => {
const bonobUrl = url("http://localhost:1234/context-path?search=yes"); const bonobUrl = url("http://localhost:1234/context-path?search=yes");
const playlist = aPlaylist({ const playlist = aPlaylist({
entries: [ entries: [
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "1" })) }), aTrack({ coverArt: "1" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "2" })) }), aTrack({ coverArt: "2" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "3" })) }), aTrack({ coverArt: "2" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "4" })) }), aTrack({ coverArt: "2" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "5" })) }), aTrack({ coverArt: "3" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "6" })) }), aTrack({ coverArt: "4" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "7" })) }), aTrack({ coverArt: "5" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "8" })) }), aTrack({ coverArt: "6" }),
aTrack({ album: albumToAlbumSummary(anAlbum({ id: "9" })) }), aTrack({ coverArt: "7" }),
aTrack({ coverArt: "8" }),
aTrack({ coverArt: "9" }),
aTrack({ coverArt: "10" }),
aTrack({ coverArt: "11" }),
], ],
}); });
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual( 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", () => { describe("defaultAlbumArtURI", () => {
it("should create the correct URI", () => { const bonobUrl = new URLBuilder("http://bonob.example.com:8080/context?search=yes");
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
const album = anAlbum();
expect(defaultAlbumArtURI(bonobUrl, album).href()).toEqual( describe("when there is an album coverArt", () => {
`http://localhost:1234/context-path/art/album/${album.id}/size/180?search=yes` 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"
);
});
}); });
}); });
@@ -396,7 +438,7 @@ describe("defaultArtistArtURI", () => {
const artist = anArtist(); const artist = anArtist();
expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual( 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 accessToken = `accessToken-${uuid()}`;
const bonobUrlWithAccessToken = bonobUrl.append({ const bonobUrlWithAccessToken = bonobUrl.append({
searchParams: { "bat": accessToken }, searchParams: { bat: accessToken },
}); });
const service = bonobService("test-api", 133, bonobUrl, "AppLink"); const service = bonobService("test-api", 133, bonobUrl, "AppLink");
@@ -1020,7 +1062,7 @@ describe("api", () => {
title: genre.name, title: genre.name,
albumArtURI: iconArtURI( albumArtURI: iconArtURI(
bonobUrl, bonobUrl,
iconForGenre(genre.name), iconForGenre(genre.name)
).href(), ).href(),
})), })),
index: 0, index: 0,
@@ -1045,7 +1087,7 @@ describe("api", () => {
title: genre.name, title: genre.name,
albumArtURI: iconArtURI( albumArtURI: iconArtURI(
bonobUrl, bonobUrl,
iconForGenre(genre.name), iconForGenre(genre.name)
).href(), ).href(),
})), })),
index: 1, index: 1,
@@ -2302,14 +2344,17 @@ describe("api", () => {
artistId: `artist:${track.artist.id}`, artistId: `artist:${track.artist.id}`,
artist: track.artist.name, artist: track.artist.name,
albumId: `album:${track.album.id}`, albumId: `album:${track.album.id}`,
albumArtist: track.artist.name,
albumArtistId: `artist:${track.artist.id}`,
album: track.album.name, album: track.album.name,
genre: track.genre?.name, genre: track.genre?.name,
genreId: track.genre?.id, genreId: track.genre?.id,
duration: track.duration, duration: track.duration,
albumArtURI: defaultAlbumArtURI( albumArtURI: defaultAlbumArtURI(
bonobUrlWithAccessToken, bonobUrlWithAccessToken,
track.album track
).href(), ).href(),
trackNumber: track.number,
}, },
}, },
}, },
@@ -2510,7 +2555,7 @@ describe("api", () => {
expect(root[0]).toEqual({ expect(root[0]).toEqual({
getMediaMetadataResult: track( getMediaMetadataResult: track(
bonobUrl.with({ bonobUrl.with({
searchParams: { "bat": accessToken }, searchParams: { bat: accessToken },
}), }),
someTrack someTrack
), ),

View File

@@ -3,14 +3,15 @@ import { v4 as uuid } from "uuid";
import { import {
isDodgyImage, isDodgyImage,
Navidrome, Subsonic,
t, t,
BROWSER_HEADERS, BROWSER_HEADERS,
DODGY_IMAGE_NAME, DODGY_IMAGE_NAME,
asGenre, asGenre,
appendMimeTypeToClientFor, appendMimeTypeToClientFor,
asURLSearchParams, asURLSearchParams,
} from "../src/navidrome"; splitCoverArtId,
} from "../src/subsonic";
import encryption from "../src/encryption"; import encryption from "../src/encryption";
import axios from "axios"; import axios from "axios";
@@ -44,6 +45,8 @@ import {
aPlaylist, aPlaylist,
aPlaylistSummary, aPlaylistSummary,
aTrack, aTrack,
POP,
ROCK,
} from "./builders"; } from "./builders";
import { b64Encode } from "../src/b64"; import { b64Encode } from "../src/b64";
@@ -181,6 +184,8 @@ const getArtistInfoXml = (
</artistInfo2> </artistInfo2>
</subsonic-response>`; </subsonic-response>`;
const maybeIdFromCoverArtId = (coverArt: string | undefined) => coverArt ? splitCoverArtId(coverArt)[1] : "";
const albumXml = ( const albumXml = (
artist: Artist, artist: Artist,
album: AlbumSummary, album: AlbumSummary,
@@ -191,7 +196,7 @@ const albumXml = (
title="${album.name}" name="${album.name}" album="${album.name}" title="${album.name}" name="${album.name}" album="${album.name}"
artist="${artist.name}" artist="${artist.name}"
genre="${album.genre?.name}" genre="${album.genre?.name}"
coverArt="foo" coverArt="${maybeIdFromCoverArtId(album.coverArt)}"
duration="123" duration="123"
playCount="4" playCount="4"
year="${album.year}" year="${album.year}"
@@ -209,7 +214,7 @@ const songXml = (track: Track) => `<song
track="${track.number}" track="${track.number}"
genre="${track.genre?.name}" genre="${track.genre?.name}"
isDir="false" isDir="false"
coverArt="71381" coverArt="${maybeIdFromCoverArtId(track.coverArt)}"
created="2004-11-08T23:36:11" created="2004-11-08T23:36:11"
duration="${track.duration}" duration="${track.duration}"
bitRate="128" bitRate="128"
@@ -341,7 +346,7 @@ const getPlayList = (
track="${it.number}" track="${it.number}"
year="${it.album.year}" year="${it.album.year}"
genre="${it.album.genre?.name}" genre="${it.album.genre?.name}"
coverArt="..." coverArt="${splitCoverArtId(it.coverArt!)[1]}"
size="123" size="123"
contentType="${it.mimeType}" contentType="${it.mimeType}"
suffix="mp3" 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>`; 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 url = "http://127.0.0.22:4567";
const username = "user1"; const username = "user1";
const password = "pass1"; const password = "pass1";
const salt = "saltysalty"; const salt = "saltysalty";
const streamClientApplication = jest.fn(); const streamClientApplication = jest.fn();
const navidrome = new Navidrome( const navidrome = new Subsonic(
url, url,
encryption("secret"), encryption("secret"),
streamClientApplication streamClientApplication
@@ -500,7 +519,7 @@ describe("Navidrome", () => {
const token = await navidrome.generateToken({ username, password }); const token = await navidrome.generateToken({ username, password });
expect(token).toEqual({ 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( return expect(
musicLibrary.stream({ trackId, range: undefined }) 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 }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(coverArtId, "album")); .then((it) => it.coverArt(`coverArt:${coverArtId}`));
expect(result).toEqual({ expect(result).toEqual({
contentType: streamResponse.headers["content-type"], contentType: streamResponse.headers["content-type"],
@@ -2698,7 +2717,7 @@ describe("Navidrome", () => {
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(coverArtId, "album", size)); .then((it) => it.coverArt(`coverArt:${coverArtId}`, size));
expect(result).toEqual({ expect(result).toEqual({
contentType: streamResponse.headers["content-type"], contentType: streamResponse.headers["content-type"],
@@ -2754,7 +2773,7 @@ describe("Navidrome", () => {
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist")); .then((it) => it.coverArt(`artist:${artistId}`));
expect(result).toEqual({ expect(result).toEqual({
contentType: streamResponse.headers["content-type"], contentType: streamResponse.headers["content-type"],
@@ -2783,85 +2802,206 @@ describe("Navidrome", () => {
describe("when the artist doest not have a valid artist uri", () => { describe("when the artist doest not have a valid artist uri", () => {
describe("however has some albums", () => { describe("however has some albums", () => {
it("should fetch the artists first album image", async () => { const artistId = "someArtist123";
const artistId = "someArtist123";
const images: Images = { const images: Images = {
small: undefined, small: undefined,
medium: undefined, medium: undefined,
large: undefined, large: undefined,
}; };
const streamResponse = { const streamResponse = {
status: 200, status: 200,
headers: { headers: {
"content-type": "image/jpeg", "content-type": "image/jpeg",
}, },
data: Buffer.from("the image", "ascii"), data: Buffer.from("the image", "ascii"),
}; };
const album1 = anAlbum(); describe("all albums have coverArt", () => {
const album2 = anAlbum(); it("should fetch the coverArt from the first album", async () => {
const album1 = anAlbum({ coverArt: `coverArt:album1CoverArt` });
const artist = anArtist({ const album2 = anAlbum({ coverArt: `coverArt:album2CoverArt` });
id: artistId,
albums: [album1, album2], const artist = anArtist({
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(artistId, "artist"));
expect(result).toEqual({
contentType: streamResponse.headers["content-type"],
data: streamResponse.data,
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
params: asURLSearchParams({
...authParams,
id: artistId, id: artistId,
}), albums: [album1, album2],
headers, image: images,
}); });
expect(axios.get).toHaveBeenCalledWith( mockGET
`${url}/rest/getArtistInfo2`, .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({ params: asURLSearchParams({
...authParams, ...authParams,
id: artistId, id: artistId,
count: 50,
includeNotPresent: true,
}), }),
headers, 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(album1.coverArt!)[1],
}),
headers,
responseType: "arraybuffer",
}
);
});
});
expect(axios.get).toHaveBeenCalledWith( describe("the first album does not have coverArt", () => {
`${url}/rest/getCoverArt`, 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({ params: asURLSearchParams({
...authParams, ...authParams,
id: album1.id, id: artistId,
}), }),
headers, headers,
responseType: "arraybuffer", });
}
); 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,
}
);
});
}); });
}); });
@@ -2903,7 +3043,7 @@ describe("Navidrome", () => {
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist")); .then((it) => it.coverArt(`artist:${artistId}`));
expect(result).toBeUndefined(); expect(result).toBeUndefined();
@@ -2978,7 +3118,7 @@ describe("Navidrome", () => {
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist", size)); .then((it) => it.coverArt(`artist:${artistId}`, size));
expect(result).toEqual({ expect(result).toEqual({
contentType: streamResponse.headers["content-type"], contentType: streamResponse.headers["content-type"],
@@ -3050,7 +3190,7 @@ describe("Navidrome", () => {
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist", size)); .then((it) => it.coverArt(`artist:${artistId}`, size));
expect(result).toEqual({ expect(result).toEqual({
contentType: streamResponse.headers["content-type"], contentType: streamResponse.headers["content-type"],
@@ -3083,7 +3223,7 @@ describe("Navidrome", () => {
{ {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: album1.id, id: splitCoverArtId(album1.coverArt!)[1],
size, size,
}), }),
headers, headers,
@@ -3131,7 +3271,7 @@ describe("Navidrome", () => {
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist")); .then((it) => it.coverArt(`artist:${artistId}`));
expect(result).toBeUndefined(); expect(result).toBeUndefined();
@@ -3178,8 +3318,8 @@ describe("Navidrome", () => {
data: Buffer.from("the image", "ascii"), data: Buffer.from("the image", "ascii"),
}; };
const album1 = anAlbum({ id: "album1Id" }); const album1 = anAlbum({ id: "album1Id", coverArt: "coverArt:album1CoverArt" });
const album2 = anAlbum({ id: "album2Id" }); const album2 = anAlbum({ id: "album2Id", coverArt: "coverArt:album2CoverArt" });
const artist = anArtist({ const artist = anArtist({
id: artistId, id: artistId,
@@ -3201,7 +3341,7 @@ describe("Navidrome", () => {
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist", size)); .then((it) => it.coverArt(`artist:${artistId}`, size));
expect(result).toEqual({ expect(result).toEqual({
contentType: streamResponse.headers["content-type"], contentType: streamResponse.headers["content-type"],
@@ -3234,7 +3374,7 @@ describe("Navidrome", () => {
{ {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: album1.id, id: splitCoverArtId(album1.coverArt!)[1],
size, size,
}), }),
headers, headers,
@@ -3282,7 +3422,7 @@ describe("Navidrome", () => {
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist")); .then((it) => it.coverArt(`artist:${artistId}`));
expect(result).toBeUndefined(); expect(result).toBeUndefined();
@@ -3896,7 +4036,7 @@ describe("Navidrome", () => {
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.playlist(id)) .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 () => { it("should return the playlist with entries", async () => {
const id = uuid(); const id = uuid();
const name = "Great Playlist"; const name = "Great Playlist";
const artist1 = anArtist();
const album1 = anAlbum({ artistId: artist1.id, artistName: artist1.name, genre: POP });
const track1 = aTrack({ const track1 = aTrack({
genre: { id: b64Encode("pop"), name: "pop" }, genre: POP,
number: 66, 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({ const track2 = aTrack({
genre: { id: b64Encode("rock"), name: "rock" }, genre: ROCK,
number: 77, number: 77,
coverArt: album2.coverArt,
artist: artistToArtistSummary(artist2),
album: albumToAlbumSummary(album2)
}); });
mockGET mockGET
@@ -4263,7 +4414,7 @@ describe("Navidrome", () => {
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.similarSongs(id)) .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 () => { it("should return them", async () => {
const artistId = "bobMarleyId"; const artistId = "bobMarleyId";
const artistName = "Bob Marley"; const artistName = "Bob Marley";
const pop = asGenre("Pop");
const album1 = anAlbum({ name: "Burnin", genre: pop }); const album1 = anAlbum({ name: "Burnin", genre: POP });
const album2 = anAlbum({ name: "Churning", genre: pop }); const album2 = anAlbum({ name: "Churning", genre: POP });
const artist = anArtist({ const artist = anArtist({
id: artistId, id: artistId,
@@ -4337,19 +4487,19 @@ describe("Navidrome", () => {
const track1 = aTrack({ const track1 = aTrack({
artist: artistToArtistSummary(artist), artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album1), album: albumToAlbumSummary(album1),
genre: pop, genre: POP
}); });
const track2 = aTrack({ const track2 = aTrack({
artist: artistToArtistSummary(artist), artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album2), album: albumToAlbumSummary(album2),
genre: pop, genre: POP,
}); });
const track3 = aTrack({ const track3 = aTrack({
artist: artistToArtistSummary(artist), artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album1), album: albumToAlbumSummary(album1),
genre: pop, genre: POP,
}); });
mockGET mockGET