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
ENV BONOB_PORT=4534
ENV BNB_PORT=4534
EXPOSE $BONOB_PORT
EXPOSE $BNB_PORT
WORKDIR /bonob

View File

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

View File

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

View File

@@ -50,8 +50,8 @@
"scripts": {
"clean": "rm -Rf build node_modules",
"build": "tsc",
"dev": "BONOB_ICON_FOREGROUND_COLOR=white BONOB_ICON_BACKGROUND_COLOR=darkgrey BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts",
"devr": "BONOB_ICON_FOREGROUND_COLOR=white BONOB_ICON_BACKGROUND_COLOR=darkgrey BONOB_SONOS_SERVICE_NAME=bonobDev BONOB_SONOS_DEVICE_DISCOVERY=true BONOB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts",
"dev": "BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true nodemon -V ./src/app.ts",
"devr": "BNB_ICON_FOREGROUND_COLOR=white BNB_ICON_BACKGROUND_COLOR=darkgrey BNB_SONOS_SERVICE_NAME=bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true nodemon -V ./src/app.ts",
"register-dev": "ts-node ./src/register.ts http://$(hostname):4534",
"test": "jest",
"gitinfo": "git describe --tags > .gitinfo"

View File

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

View File

@@ -2,56 +2,91 @@ import { hostname } from "os";
import logger from "./logger";
import url from "./url_builder";
export const WORD = /^\w+$/;
type EnvVarOpts = {
default: string | undefined;
legacy: string[] | undefined;
validationPattern: RegExp | undefined;
};
export function envVar(
name: string,
opts: Partial<EnvVarOpts> = {
default: undefined,
legacy: undefined,
validationPattern: undefined,
}
) {
const result = [name, ...(opts.legacy || [])]
.map((it) => ({ key: it, value: process.env[it] }))
.find((it) => it.value);
if (
result &&
result.value &&
opts.validationPattern &&
!result.value.match(opts.validationPattern)
) {
throw `Invalid value specified for '${name}', must match ${opts.validationPattern}`;
}
if(result && result.value && result.key != name) {
logger.warn(`Configuration key '${result.key}' is deprecated, replace with '${name}'`)
}
return result?.value || opts.default;
}
export const bnbEnvVar = (key: string, opts: Partial<EnvVarOpts> = {}) =>
envVar(`BNB_${key}`, {
...opts,
legacy: [`BONOB_${key}`, ...(opts.legacy || [])],
});
export default function () {
const port = +(process.env["BONOB_PORT"] || 4534);
const bonobUrl =
process.env["BONOB_URL"] ||
process.env["BONOB_WEB_ADDRESS"] ||
`http://${hostname()}:${port}`;
const port = +bnbEnvVar("PORT", { default: "4534" })!;
const bonobUrl = bnbEnvVar("URL", {
legacy: ["BONOB_WEB_ADDRESS"],
default: `http://${hostname()}:${port}`,
})!;
if (bonobUrl.match("localhost")) {
logger.error(
"BONOB_URL containing localhost is almost certainly incorrect, sonos devices will not be able to communicate with bonob using localhost, please specify either public IP or DNS entry"
"BNB_URL containing localhost is almost certainly incorrect, sonos devices will not be able to communicate with bonob using localhost, please specify either public IP or DNS entry"
);
process.exit(1);
}
const wordFrom = (envVar: string) => {
const value = process.env[envVar];
if (value && value != "") {
if (value.match(/^\w+$/)) return value;
else throw `Invalid color specified for ${envVar}`;
} else {
return undefined;
}
};
return {
port,
bonobUrl: url(bonobUrl),
secret: process.env["BONOB_SECRET"] || "bonob",
secret: bnbEnvVar("SECRET", { default: "bonob" })!,
icons: {
foregroundColor: wordFrom("BONOB_ICON_FOREGROUND_COLOR"),
backgroundColor: wordFrom("BONOB_ICON_BACKGROUND_COLOR"),
foregroundColor: bnbEnvVar("ICON_FOREGROUND_COLOR", {
validationPattern: WORD,
}),
backgroundColor: bnbEnvVar("ICON_BACKGROUND_COLOR", {
validationPattern: WORD,
}),
},
sonos: {
serviceName: process.env["BONOB_SONOS_SERVICE_NAME"] || "bonob",
serviceName: bnbEnvVar("SONOS_SERVICE_NAME", { default: "bonob" })!,
discovery: {
enabled:
(process.env["BONOB_SONOS_DEVICE_DISCOVERY"] || "true") == "true",
seedHost: process.env["BONOB_SONOS_SEED_HOST"],
bnbEnvVar("SONOS_DEVICE_DISCOVERY", { default: "true" }) == "true",
seedHost: bnbEnvVar("SONOS_SEED_HOST"),
},
autoRegister:
(process.env["BONOB_SONOS_AUTO_REGISTER"] || "false") == "true",
sid: Number(process.env["BONOB_SONOS_SERVICE_ID"] || "246"),
bnbEnvVar("SONOS_AUTO_REGISTER", { default: "false" }) == "true",
sid: Number(bnbEnvVar("SONOS_SERVICE_ID", { default: "246" })),
},
navidrome: {
url: process.env["BONOB_NAVIDROME_URL"] || `http://${hostname()}:4533`,
customClientsFor:
process.env["BONOB_NAVIDROME_CUSTOM_CLIENTS"] || undefined,
subsonic: {
url: bnbEnvVar("SUBSONIC_URL", { legacy: ["BONOB_NAVIDROME_URL"], default: `http://${hostname()}:4533` })!,
customClientsFor: bnbEnvVar("SUBSONIC_CUSTOM_CLIENTS", { legacy: ["BONOB_NAVIDROME_CUSTOM_CLIENTS"] }),
},
scrobbleTracks: (process.env["BONOB_SCROBBLE_TRACKS"] || "true") == "true",
scrobbleTracks: bnbEnvVar("SCROBBLE_TRACKS", { default: "true" }) == "true",
reportNowPlaying:
(process.env["BONOB_REPORT_NOW_PLAYING"] || "true") == "true",
bnbEnvVar("REPORT_NOW_PLAYING", { default: "true" }) == "true",
};
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,79 @@
import { hostname } from "os";
import config from "../src/config";
import config, { envVar, WORD } from "../src/config";
describe("envVar", () => {
const OLD_ENV = process.env;
beforeEach(() => {
jest.resetModules();
process.env = { ...OLD_ENV };
process.env["bnb-var"] = "bnb-var-value";
process.env["bnb-legacy2"] = "bnb-legacy2-value";
process.env["bnb-legacy3"] = "bnb-legacy3-value";
});
afterEach(() => {
process.env = OLD_ENV;
});
describe("when the env var exists", () => {
describe("and there are no legacy env vars that match", () => {
it("should return the env var", () => {
expect(envVar("bnb-var")).toEqual("bnb-var-value");
});
});
describe("and there are legacy env vars that match", () => {
it("should return the env var", () => {
expect(
envVar("bnb-var", {
default: "not valid",
legacy: ["bnb-legacy1", "bnb-legacy2", "bnb-legacy3"],
})
).toEqual("bnb-var-value");
});
});
});
describe("when the env var doesnt exist", () => {
describe("and there are no legacy env vars specified", () => {
describe("and there is no default value specified", () => {
it("should be undefined", () => {
expect(envVar("bnb-not-set")).toBeUndefined();
});
});
describe("and there is a default value specified", () => {
it("should return the default", () => {
expect(envVar("bnb-not-set", { default: "widget" })).toEqual(
"widget"
);
});
});
});
describe("when there are legacy env vars specified", () => {
it("should return the value from the first matched legacy env var", () => {
expect(
envVar("bnb-not-set", {
legacy: ["bnb-legacy1", "bnb-legacy2", "bnb-legacy3"],
})
).toEqual("bnb-legacy2-value");
});
});
});
describe("validationPattern", () => {
it("should fail when the value does not match the pattern", () => {
expect(
() => envVar("bnb-var", {
validationPattern: /^foobar$/,
})
).toThrowError(`Invalid value specified for 'bnb-var', must match ${/^foobar$/}`)
});
});
});
describe("config", () => {
const OLD_ENV = process.env;
@@ -43,26 +117,22 @@ describe("config", () => {
}
describe("bonobUrl", () => {
describe("when BONOB_URL is specified", () => {
["BNB_URL", "BONOB_URL", "BONOB_WEB_ADDRESS"].forEach(key => {
describe(`when ${key} is specified`, () => {
it("should be used", () => {
const url = "http://bonob1.example.com:8877/";
process.env["BONOB_URL"] = url;
expect(config().bonobUrl.href()).toEqual(url);
});
});
describe("when BONOB_URL is not specified, however legacy BONOB_WEB_ADDRESS is specified", () => {
it("should be used", () => {
const url = "http://bonob2.example.com:9988/";
process.env["BNB_URL"] = "";
process.env["BONOB_URL"] = "";
process.env["BONOB_WEB_ADDRESS"] = url;
process.env["BONOB_WEB_ADDRESS"] = "";
process.env[key] = url;
expect(config().bonobUrl.href()).toEqual(url);
});
});
});
describe("when neither BONOB_URL nor BONOB_WEB_ADDRESS are specified", () => {
describe("when none of BNB_URL, BONOB_URL, BONOB_WEB_ADDRESS are specified", () => {
describe("when BONOB_PORT is not specified", () => {
it(`should default to http://${hostname()}:4534`, () => {
expect(config().bonobUrl.href()).toEqual(
@@ -71,6 +141,15 @@ describe("config", () => {
});
});
describe("when BNB_PORT is specified as 3322", () => {
it(`should default to http://${hostname()}:3322`, () => {
process.env["BNB_PORT"] = "3322";
expect(config().bonobUrl.href()).toEqual(
`http://${hostname()}:3322/`
);
});
});
describe("when BONOB_PORT is specified as 3322", () => {
it(`should default to http://${hostname()}:3322`, () => {
process.env["BONOB_PORT"] = "3322";
@@ -82,105 +161,86 @@ describe("config", () => {
});
});
describe("navidrome", () => {
describe("url", () => {
describe("when BONOB_NAVIDROME_URL is not specified", () => {
it(`should default to http://${hostname()}:4533`, () => {
expect(config().navidrome.url).toEqual(`http://${hostname()}:4533`);
});
});
describe("when BONOB_NAVIDROME_URL is ''", () => {
it(`should default to http://${hostname()}:4533`, () => {
process.env["BONOB_NAVIDROME_URL"] = "";
expect(config().navidrome.url).toEqual(`http://${hostname()}:4533`);
});
});
describe("when BONOB_NAVIDROME_URL is specified", () => {
it(`should use it`, () => {
const url = "http://navidrome.example.com:1234";
process.env["BONOB_NAVIDROME_URL"] = url;
expect(config().navidrome.url).toEqual(url);
});
});
});
});
describe("icons", () => {
describe("foregroundColor", () => {
describe("when BONOB_ICON_FOREGROUND_COLOR is not specified", () => {
["BNB_ICON_FOREGROUND_COLOR", "BONOB_ICON_FOREGROUND_COLOR"].forEach(k => {
describe(`when ${k} is not specified`, () => {
it(`should default to undefined`, () => {
expect(config().icons.foregroundColor).toEqual(undefined);
});
});
describe("when BONOB_ICON_FOREGROUND_COLOR is ''", () => {
describe(`when ${k} is ''`, () => {
it(`should default to undefined`, () => {
process.env["BONOB_ICON_FOREGROUND_COLOR"] = "";
process.env[k] = "";
expect(config().icons.foregroundColor).toEqual(undefined);
});
});
describe("when BONOB_ICON_FOREGROUND_COLOR is specified", () => {
describe(`when ${k} is specified`, () => {
it(`should use it`, () => {
process.env["BONOB_ICON_FOREGROUND_COLOR"] = "pink";
process.env[k] = "pink";
expect(config().icons.foregroundColor).toEqual("pink");
});
});
describe("when BONOB_ICON_FOREGROUND_COLOR is an invalid string", () => {
describe(`when ${k} is an invalid string`, () => {
it(`should blow up`, () => {
process.env["BONOB_ICON_FOREGROUND_COLOR"] = "#dfasd";
process.env[k] = "#dfasd";
expect(() => config()).toThrow(
"Invalid color specified for BONOB_ICON_FOREGROUND_COLOR"
`Invalid value specified for 'BNB_ICON_FOREGROUND_COLOR', must match ${WORD}`
);
});
});
});
});
describe("backgroundColor", () => {
describe("when BONOB_ICON_BACKGROUND_COLOR is not specified", () => {
["BNB_ICON_BACKGROUND_COLOR", "BONOB_ICON_BACKGROUND_COLOR"].forEach(k => {
describe(`when ${k} is not specified`, () => {
it(`should default to undefined`, () => {
expect(config().icons.backgroundColor).toEqual(undefined);
});
});
describe("when BONOB_ICON_BACKGROUND_COLOR is ''", () => {
describe(`when ${k} is ''`, () => {
it(`should default to undefined`, () => {
process.env["BONOB_ICON_BACKGROUND_COLOR"] = "";
process.env[k] = "";
expect(config().icons.backgroundColor).toEqual(undefined);
});
});
describe("when BONOB_ICON_BACKGROUND_COLOR is specified", () => {
describe(`when ${k} is specified`, () => {
it(`should use it`, () => {
process.env["BONOB_ICON_BACKGROUND_COLOR"] = "blue";
process.env[k] = "blue";
expect(config().icons.backgroundColor).toEqual("blue");
});
});
describe("when BONOB_ICON_BACKGROUND_COLOR is an invalid string", () => {
describe(`when ${k} is an invalid string`, () => {
it(`should blow up`, () => {
process.env["BONOB_ICON_BACKGROUND_COLOR"] = "#red";
process.env[k] = "#red";
expect(() => config()).toThrow(
"Invalid color specified for BONOB_ICON_BACKGROUND_COLOR"
`Invalid value specified for 'BNB_ICON_BACKGROUND_COLOR', must match ${WORD}`
);
});
});
});
});
});
describe("secret", () => {
it("should default to bonob", () => {
expect(config().secret).toEqual("bonob");
});
it("should be overridable", () => {
process.env["BONOB_SECRET"] = "new secret";
["BNB_SECRET", "BONOB_SECRET"].forEach(key => {
it(`should be overridable using ${key}`, () => {
process.env[key] = "new secret";
expect(config().secret).toEqual("new secret");
});
});
});
describe("sonos", () => {
describe("serviceName", () => {
@@ -188,83 +248,116 @@ describe("config", () => {
expect(config().sonos.serviceName).toEqual("bonob");
});
["BNB_SONOS_SERVICE_NAME", "BONOB_SONOS_SERVICE_NAME"].forEach(k => {
it("should be overridable", () => {
process.env["BONOB_SONOS_SERVICE_NAME"] = "foobar1000";
process.env[k] = "foobar1000";
expect(config().sonos.serviceName).toEqual("foobar1000");
});
});
});
["BNB_SONOS_DEVICE_DISCOVERY", "BONOB_SONOS_DEVICE_DISCOVERY"].forEach(k => {
describeBooleanConfigValue(
"deviceDiscovery",
"BONOB_SONOS_DEVICE_DISCOVERY",
k,
true,
(config) => config.sonos.discovery.enabled
);
});
describe("seedHost", () => {
it("should default to undefined", () => {
expect(config().sonos.discovery.seedHost).toBeUndefined();
});
["BNB_SONOS_SEED_HOST", "BONOB_SONOS_SEED_HOST"].forEach(k => {
it("should be overridable", () => {
process.env["BONOB_SONOS_SEED_HOST"] = "123.456.789.0";
process.env[k] = "123.456.789.0";
expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0");
});
});
});
["BNB_SONOS_AUTO_REGISTER", "BONOB_SONOS_AUTO_REGISTER"].forEach(k => {
describeBooleanConfigValue(
"autoRegister",
"BONOB_SONOS_AUTO_REGISTER",
k,
false,
(config) => config.sonos.autoRegister
);
});
describe("sid", () => {
it("should default to 246", () => {
expect(config().sonos.sid).toEqual(246);
});
["BNB_SONOS_SERVICE_ID", "BONOB_SONOS_SERVICE_ID"].forEach(k => {
it("should be overridable", () => {
process.env["BONOB_SONOS_SERVICE_ID"] = "786";
process.env[k] = "786";
expect(config().sonos.sid).toEqual(786);
});
});
});
describe("navidrome", () => {
describe("url", () => {
it("should default to http://${hostname()}:4533", () => {
expect(config().navidrome.url).toEqual(`http://${hostname()}:4533`);
});
it("should be overridable", () => {
process.env["BONOB_NAVIDROME_URL"] = "http://farfaraway.com";
expect(config().navidrome.url).toEqual("http://farfaraway.com");
describe("subsonic", () => {
describe("url", () => {
["BNB_SUBSONIC_URL", "BONOB_SUBSONIC_URL", "BONOB_NAVIDROME_URL"].forEach(k => {
describe(`when ${k} is not specified`, () => {
it(`should default to http://${hostname()}:4533`, () => {
expect(config().subsonic.url).toEqual(`http://${hostname()}:4533`);
});
});
describe(`when ${k} is ''`, () => {
it(`should default to http://${hostname()}:4533`, () => {
process.env[k] = "";
expect(config().subsonic.url).toEqual(`http://${hostname()}:4533`);
});
});
describe(`when ${k} is specified`, () => {
it(`should use it for ${k}`, () => {
const url = "http://navidrome.example.com:1234";
process.env[k] = url;
expect(config().subsonic.url).toEqual(url);
});
});
});
});
describe("customClientsFor", () => {
it("should default to undefined", () => {
expect(config().navidrome.customClientsFor).toBeUndefined();
expect(config().subsonic.customClientsFor).toBeUndefined();
});
it("should be overridable", () => {
process.env["BONOB_NAVIDROME_CUSTOM_CLIENTS"] = "whoop/whoop";
expect(config().navidrome.customClientsFor).toEqual("whoop/whoop");
["BNB_SUBSONIC_CUSTOM_CLIENTS", "BONOB_SUBSONIC_CUSTOM_CLIENTS", "BONOB_NAVIDROME_CUSTOM_CLIENTS"].forEach(k => {
it(`should be overridable for ${k}`, () => {
process.env[k] = "whoop/whoop";
expect(config().subsonic.customClientsFor).toEqual("whoop/whoop");
});
});
});
});
["BNB_SCROBBLE_TRACKS", "BONOB_SCROBBLE_TRACKS"].forEach(k => {
describeBooleanConfigValue(
"scrobbleTracks",
"BONOB_SCROBBLE_TRACKS",
k,
true,
(config) => config.scrobbleTracks
);
});
["BNB_REPORT_NOW_PLAYING", "BONOB_REPORT_NOW_PLAYING"].forEach(k => {
describeBooleanConfigValue(
"reportNowPlaying",
"BONOB_REPORT_NOW_PLAYING",
k,
true,
(config) => config.reportNowPlaying
);
});
});

View File

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

View File

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

View File

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

View File

@@ -3,14 +3,15 @@ import { v4 as uuid } from "uuid";
import {
isDodgyImage,
Navidrome,
Subsonic,
t,
BROWSER_HEADERS,
DODGY_IMAGE_NAME,
asGenre,
appendMimeTypeToClientFor,
asURLSearchParams,
} from "../src/navidrome";
splitCoverArtId,
} from "../src/subsonic";
import encryption from "../src/encryption";
import axios from "axios";
@@ -44,6 +45,8 @@ import {
aPlaylist,
aPlaylistSummary,
aTrack,
POP,
ROCK,
} from "./builders";
import { b64Encode } from "../src/b64";
@@ -181,6 +184,8 @@ const getArtistInfoXml = (
</artistInfo2>
</subsonic-response>`;
const maybeIdFromCoverArtId = (coverArt: string | undefined) => coverArt ? splitCoverArtId(coverArt)[1] : "";
const albumXml = (
artist: Artist,
album: AlbumSummary,
@@ -191,7 +196,7 @@ const albumXml = (
title="${album.name}" name="${album.name}" album="${album.name}"
artist="${artist.name}"
genre="${album.genre?.name}"
coverArt="foo"
coverArt="${maybeIdFromCoverArtId(album.coverArt)}"
duration="123"
playCount="4"
year="${album.year}"
@@ -209,7 +214,7 @@ const songXml = (track: Track) => `<song
track="${track.number}"
genre="${track.genre?.name}"
isDir="false"
coverArt="71381"
coverArt="${maybeIdFromCoverArtId(track.coverArt)}"
created="2004-11-08T23:36:11"
duration="${track.duration}"
bitRate="128"
@@ -341,7 +346,7 @@ const getPlayList = (
track="${it.number}"
year="${it.album.year}"
genre="${it.album.genre?.name}"
coverArt="..."
coverArt="${splitCoverArtId(it.coverArt!)[1]}"
size="123"
contentType="${it.mimeType}"
suffix="mp3"
@@ -430,14 +435,28 @@ const EMPTY = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok
const PING_OK = `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"></subsonic-response>`;
describe("Navidrome", () => {
describe("splitCoverArtId", () => {
it("should split correctly", () => {
expect(splitCoverArtId("foo:bar")).toEqual(["foo", "bar"])
expect(splitCoverArtId("foo:bar:car:jar")).toEqual(["foo", "bar:car:jar"])
});
it("should blow up when the id is invalid", () => {
expect(() => splitCoverArtId("")).toThrow(`'' is an invalid coverArt id`)
expect(() => splitCoverArtId("foo:")).toThrow(`'foo:' is an invalid coverArt id`)
expect(() => splitCoverArtId("foo:")).toThrow(`'foo:' is an invalid coverArt id`)
expect(() => splitCoverArtId(":dog")).toThrow(`':dog' is an invalid coverArt id`)
});
});
describe("Subsonic", () => {
const url = "http://127.0.0.22:4567";
const username = "user1";
const password = "pass1";
const salt = "saltysalty";
const streamClientApplication = jest.fn();
const navidrome = new Navidrome(
const navidrome = new Subsonic(
url,
encryption("secret"),
streamClientApplication
@@ -500,7 +519,7 @@ describe("Navidrome", () => {
const token = await navidrome.generateToken({ username, password });
expect(token).toEqual({
message: "Navidrome error:Wrong username or password",
message: "Subsonic error:Wrong username or password",
});
});
});
@@ -2483,7 +2502,7 @@ describe("Navidrome", () => {
return expect(
musicLibrary.stream({ trackId, range: undefined })
).rejects.toEqual(`Navidrome failed with a 400 status`);
).rejects.toEqual(`Subsonic failed with a 400 status`);
});
});
});
@@ -2660,7 +2679,7 @@ describe("Navidrome", () => {
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(coverArtId, "album"));
.then((it) => it.coverArt(`coverArt:${coverArtId}`));
expect(result).toEqual({
contentType: streamResponse.headers["content-type"],
@@ -2698,7 +2717,7 @@ describe("Navidrome", () => {
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(coverArtId, "album", size));
.then((it) => it.coverArt(`coverArt:${coverArtId}`, size));
expect(result).toEqual({
contentType: streamResponse.headers["content-type"],
@@ -2754,7 +2773,7 @@ describe("Navidrome", () => {
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist"));
.then((it) => it.coverArt(`artist:${artistId}`));
expect(result).toEqual({
contentType: streamResponse.headers["content-type"],
@@ -2783,7 +2802,6 @@ describe("Navidrome", () => {
describe("when the artist doest not have a valid artist uri", () => {
describe("however has some albums", () => {
it("should fetch the artists first album image", async () => {
const artistId = "someArtist123";
const images: Images = {
@@ -2800,8 +2818,10 @@ describe("Navidrome", () => {
data: Buffer.from("the image", "ascii"),
};
const album1 = anAlbum();
const album2 = anAlbum();
describe("all albums have coverArt", () => {
it("should fetch the coverArt from the first album", async () => {
const album1 = anAlbum({ coverArt: `coverArt:album1CoverArt` });
const album2 = anAlbum({ coverArt: `coverArt:album2CoverArt` });
const artist = anArtist({
id: artistId,
@@ -2823,7 +2843,7 @@ describe("Navidrome", () => {
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist"));
.then((it) => it.coverArt(`artist:${artistId}`));
expect(result).toEqual({
contentType: streamResponse.headers["content-type"],
@@ -2856,7 +2876,7 @@ describe("Navidrome", () => {
{
params: asURLSearchParams({
...authParams,
id: album1.id,
id: splitCoverArtId(album1.coverArt!)[1],
}),
headers,
responseType: "arraybuffer",
@@ -2865,6 +2885,126 @@ describe("Navidrome", () => {
});
});
describe("the first album does not have coverArt", () => {
it("should fetch the coverArt from the first album with coverArt", async () => {
const album1 = anAlbum({ coverArt: undefined });
const album2 = anAlbum({ coverArt: `coverArt:album2CoverArt` });
const artist = anArtist({
id: artistId,
albums: [album1, album2],
image: images,
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getArtistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(`artist:${artistId}`));
expect(result).toEqual({
contentType: streamResponse.headers["content-type"],
data: streamResponse.data,
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
params: asURLSearchParams({
...authParams,
id: artistId,
}),
headers,
});
expect(axios.get).toHaveBeenCalledWith(
`${url}/rest/getArtistInfo2`,
{
params: asURLSearchParams({
...authParams,
id: artistId,
count: 50,
includeNotPresent: true,
}),
headers,
}
);
expect(axios.get).toHaveBeenCalledWith(
`${url}/rest/getCoverArt`,
{
params: asURLSearchParams({
...authParams,
id: splitCoverArtId(album2.coverArt!)[1],
}),
headers,
responseType: "arraybuffer",
}
);
});
});
describe("no albums have coverArt", () => {
it("should return undefined", async () => {
const album1 = anAlbum({ coverArt: undefined });
const album2 = anAlbum({ coverArt: undefined });
const artist = anArtist({
id: artistId,
albums: [album1, album2],
image: images,
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(getArtistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(getArtistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(`artist:${artistId}`));
expect(result).toEqual(undefined);
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
params: asURLSearchParams({
...authParams,
id: artistId,
}),
headers,
});
expect(axios.get).toHaveBeenCalledWith(
`${url}/rest/getArtistInfo2`,
{
params: asURLSearchParams({
...authParams,
id: artistId,
count: 50,
includeNotPresent: true,
}),
headers,
}
);
});
});
});
describe("and has no albums", () => {
it("should return undefined", async () => {
const artistId = "someArtist123";
@@ -2903,7 +3043,7 @@ describe("Navidrome", () => {
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist"));
.then((it) => it.coverArt(`artist:${artistId}`));
expect(result).toBeUndefined();
@@ -2978,7 +3118,7 @@ describe("Navidrome", () => {
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist", size));
.then((it) => it.coverArt(`artist:${artistId}`, size));
expect(result).toEqual({
contentType: streamResponse.headers["content-type"],
@@ -3050,7 +3190,7 @@ describe("Navidrome", () => {
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist", size));
.then((it) => it.coverArt(`artist:${artistId}`, size));
expect(result).toEqual({
contentType: streamResponse.headers["content-type"],
@@ -3083,7 +3223,7 @@ describe("Navidrome", () => {
{
params: asURLSearchParams({
...authParams,
id: album1.id,
id: splitCoverArtId(album1.coverArt!)[1],
size,
}),
headers,
@@ -3131,7 +3271,7 @@ describe("Navidrome", () => {
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist"));
.then((it) => it.coverArt(`artist:${artistId}`));
expect(result).toBeUndefined();
@@ -3178,8 +3318,8 @@ describe("Navidrome", () => {
data: Buffer.from("the image", "ascii"),
};
const album1 = anAlbum({ id: "album1Id" });
const album2 = anAlbum({ id: "album2Id" });
const album1 = anAlbum({ id: "album1Id", coverArt: "coverArt:album1CoverArt" });
const album2 = anAlbum({ id: "album2Id", coverArt: "coverArt:album2CoverArt" });
const artist = anArtist({
id: artistId,
@@ -3201,7 +3341,7 @@ describe("Navidrome", () => {
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist", size));
.then((it) => it.coverArt(`artist:${artistId}`, size));
expect(result).toEqual({
contentType: streamResponse.headers["content-type"],
@@ -3234,7 +3374,7 @@ describe("Navidrome", () => {
{
params: asURLSearchParams({
...authParams,
id: album1.id,
id: splitCoverArtId(album1.coverArt!)[1],
size,
}),
headers,
@@ -3282,7 +3422,7 @@ describe("Navidrome", () => {
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.coverArt(artistId, "artist"));
.then((it) => it.coverArt(`artist:${artistId}`));
expect(result).toBeUndefined();
@@ -3896,7 +4036,7 @@ describe("Navidrome", () => {
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.playlist(id))
).rejects.toEqual("Navidrome error:data not found");
).rejects.toEqual("Subsonic error:data not found");
});
});
@@ -3905,13 +4045,24 @@ describe("Navidrome", () => {
it("should return the playlist with entries", async () => {
const id = uuid();
const name = "Great Playlist";
const artist1 = anArtist();
const album1 = anAlbum({ artistId: artist1.id, artistName: artist1.name, genre: POP });
const track1 = aTrack({
genre: { id: b64Encode("pop"), name: "pop" },
genre: POP,
number: 66,
coverArt: album1.coverArt,
artist: artistToArtistSummary(artist1),
album: albumToAlbumSummary(album1)
});
const artist2 = anArtist();
const album2 = anAlbum({ artistId: artist2.id, artistName: artist2.name, genre: ROCK });
const track2 = aTrack({
genre: { id: b64Encode("rock"), name: "rock" },
genre: ROCK,
number: 77,
coverArt: album2.coverArt,
artist: artistToArtistSummary(artist2),
album: albumToAlbumSummary(album2)
});
mockGET
@@ -4263,7 +4414,7 @@ describe("Navidrome", () => {
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.similarSongs(id))
).rejects.toEqual("Navidrome error:data not found");
).rejects.toEqual("Subsonic error:data not found");
});
});
});
@@ -4323,10 +4474,9 @@ describe("Navidrome", () => {
it("should return them", async () => {
const artistId = "bobMarleyId";
const artistName = "Bob Marley";
const pop = asGenre("Pop");
const album1 = anAlbum({ name: "Burnin", genre: pop });
const album2 = anAlbum({ name: "Churning", genre: pop });
const album1 = anAlbum({ name: "Burnin", genre: POP });
const album2 = anAlbum({ name: "Churning", genre: POP });
const artist = anArtist({
id: artistId,
@@ -4337,19 +4487,19 @@ describe("Navidrome", () => {
const track1 = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album1),
genre: pop,
genre: POP
});
const track2 = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album2),
genre: pop,
genre: POP,
});
const track3 = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album1),
genre: pop,
genre: POP,
});
mockGET