From 36d0023a1e5e768b5f95c20dd44736216bc8f3c4 Mon Sep 17 00:00:00 2001 From: Simon J Date: Mon, 27 Sep 2021 14:03:14 +1000 Subject: [PATCH] Migrate Navidrome support to generic subsonic clone support (#55) Renaming BONOB_* env vars to BNB_* --- Dockerfile | 4 +- README.md | 98 ++--- etc/docker-compose.yaml | 16 +- package.json | 4 +- src/app.ts | 14 +- src/config.ts | 95 +++-- src/i8n.ts | 10 +- src/music_service.ts | 5 +- src/server.ts | 40 +- src/smapi.ts | 59 ++- src/{navidrome.ts => subsonic.ts} | 42 +- tests/builders.ts | 18 +- tests/config.test.ts | 365 +++++++++++------- tests/in_memory_music_service.ts | 2 +- tests/server.test.ts | 81 ++-- tests/smapi.test.ts | 135 ++++--- tests/{navidrome.test.ts => subsonic.test.ts} | 344 ++++++++++++----- 17 files changed, 826 insertions(+), 506 deletions(-) rename src/{navidrome.ts => subsonic.ts} (94%) rename tests/{navidrome.test.ts => subsonic.test.ts} (93%) diff --git a/Dockerfile b/Dockerfile index 8cf4e0e..b8cc064 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/README.md b/README.md index 095808d..d34fe97 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,13 @@ A sonos SMAPI implementation to allow registering sources of music with sonos. -Currently only a single integration allowing Navidrome to be registered with sonos. In theory as Navidrome implements the subsonic API, it *may* work with other subsonic api clones. +Support for Subsonic API clones (tested against Navidrome and Gonic). ![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) diff --git a/etc/docker-compose.yaml b/etc/docker-compose.yaml index 4c8cd9d..9df82f1 100644 --- a/etc/docker-compose.yaml +++ b/etc/docker-compose.yaml @@ -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 diff --git a/package.json b/package.json index 253707f..1da64ea 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/src/app.ts b/src/app.ts index 2adbce7..46ae934 100644 --- a/src/app.ts +++ b/src/app.ts @@ -2,7 +2,7 @@ import path from "path"; import fs from "fs"; import server from "./server"; import logger from "./logger"; -import { appendMimeTypeToClientFor, DEFAULT, Navidrome } from "./navidrome"; +import { appendMimeTypeToClientFor, DEFAULT, Subsonic } from "./subsonic"; import encryption from "./encryption"; import { InMemoryAccessTokens, sha256 } from "./access_tokens"; import { InMemoryLinkCodes } from "./link_codes"; @@ -24,20 +24,20 @@ const bonob = bonobService( const sonosSystem = sonos(config.sonos.discovery); -const streamUserAgent = config.navidrome.customClientsFor - ? appendMimeTypeToClientFor(config.navidrome.customClientsFor.split(",")) +const streamUserAgent = config.subsonic.customClientsFor + ? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(",")) : DEFAULT; -const navidrome = new Navidrome( - config.navidrome.url, +const subsonic = new Subsonic( + config.subsonic.url, encryption(config.secret), streamUserAgent ); const featureFlagAwareMusicService: MusicService = { - generateToken: navidrome.generateToken, + generateToken: subsonic.generateToken, login: (authToken: string) => - navidrome.login(authToken).then((library) => { + subsonic.login(authToken).then((library) => { return { ...library, scrobble: (id: string) => { diff --git a/src/config.ts b/src/config.ts index 4d8db6f..57f9e9d 100644 --- a/src/config.ts +++ b/src/config.ts @@ -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 = { + 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 = {}) => + 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", }; } diff --git a/src/i8n.ts b/src/i8n.ts index 32ee1b3..4be7862 100644 --- a/src/i8n.ts +++ b/src/i8n.ts @@ -41,7 +41,7 @@ export type KEY = const translations: Record> = { "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> = { 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> = { 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> = { 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; diff --git a/src/music_service.ts b/src/music_service.ts index ad71fa1..6792384 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -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; - coverArt(id: string, type: "album" | "artist", size?: number): Promise; + coverArt(id: string, size?: number): Promise; nowPlaying(id: string): Promise scrobble(id: string): Promise searchArtists(query: string): Promise; diff --git a/src/server.ts b/src/server.ts index 50c57c2..0f537e0 100644 --- a/src/server.ts +++ b/src/server.ts @@ -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; + headers: Record; 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}`, - { - cause: e, - } - ); + logger.error(`Failed fetching image ${ids.join("&")}/size/${size}`, { + cause: e, + }); return res.status(500).send(); }); }); diff --git a/src/smapi.ts b/src/smapi.ts index 23127e9..cd3191a 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -215,10 +215,7 @@ const genre = (bonobUrl: URLBuilder, genre: Genre) => ({ itemType: "container", id: `genre:${genre.id}`, title: genre.name, - albumArtURI: iconArtURI( - bonobUrl, - iconForGenre(genre.name) - ).href(), + albumArtURI: iconArtURI(bonobUrl, iconForGenre(genre.name)).href(), }); const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({ @@ -238,31 +235,37 @@ export const playlistAlbumArtURL = ( bonobUrl: URLBuilder, playlist: Playlist ) => { - const ids = uniq(playlist.entries.map((it) => it.album?.id).filter((it) => it)); + const ids = uniq( + playlist.entries.map((it) => it.coverArt).filter((it) => it) + ); if (ids.length == 0) { return iconArtURI(bonobUrl, "error"); } else { return bonobUrl.append({ - pathname: `/art/album/${ids.slice(0, 9).join("&")}/size/180` + pathname: `/art/${ids.slice(0, 9).join("&")}/size/180`, }); } }; -export const defaultAlbumArtURI = (bonobUrl: URLBuilder, album: AlbumSummary) => - bonobUrl.append({ pathname: `/art/album/${album.id}/size/180` }); - -export const iconArtURI = ( +export const defaultAlbumArtURI = ( bonobUrl: URLBuilder, - icon: ICON + { coverArt }: { coverArt: string | undefined } ) => + coverArt + ? bonobUrl.append({ pathname: `/art/${coverArt}/size/180` }) + : iconArtURI(bonobUrl, "vinyl"); + +export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) => bonobUrl.append({ - pathname: `/icon/${icon}/size/legacy` + pathname: `/icon/${icon}/size/legacy`, }); export const defaultArtistArtURI = ( bonobUrl: URLBuilder, artist: ArtistSummary -) => bonobUrl.append({ pathname: `/art/artist/${artist.id}/size/180` }); +) => bonobUrl.append({ pathname: `/art/artist:${artist.id}/size/180` }); + +export const sonosifyMimeType = (mimeType: string) => mimeType == "audio/x-flac" ? "audio/flac" : mimeType; export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({ itemType: "album", @@ -281,17 +284,17 @@ export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({ export const track = (bonobUrl: URLBuilder, track: Track) => ({ itemType: "track", id: `track:${track.id}`, - mimeType: track.mimeType, + mimeType: sonosifyMimeType(track.mimeType), title: track.name, trackMetadata: { album: track.album.name, - albumId: track.album.id, + albumId: `album:${track.album.id}`, albumArtist: track.artist.name, - albumArtistId: track.artist.id, - albumArtURI: defaultAlbumArtURI(bonobUrl, track.album).href(), + albumArtistId: `artist:${track.artist.id}`, + albumArtURI: defaultAlbumArtURI(bonobUrl, track).href(), artist: track.artist.name, - artistId: track.artist.id, + artistId: `artist:${track.artist.id}`, duration: track.duration, genre: track.album.genre?.name, genreId: track.album.genre?.id, @@ -368,7 +371,7 @@ function bindSmapiSoapServiceToExpress( const urlWithToken = (accessToken: string) => bonobUrl.append({ searchParams: { - "bat": accessToken, + bat: accessToken, }, }); @@ -506,23 +509,7 @@ function bindSmapiSoapServiceToExpress( return musicLibrary.track(typeId).then((it) => ({ getExtendedMetadataResult: { mediaMetadata: { - id: `track:${it.id}`, - itemType: "track", - title: it.name, - mimeType: it.mimeType, - trackMetadata: { - artistId: `artist:${it.artist.id}`, - artist: it.artist.name, - albumId: `album:${it.album.id}`, - album: it.album.name, - genre: it.genre?.name, - genreId: it.genre?.id, - duration: it.duration, - albumArtURI: defaultAlbumArtURI( - urlWithToken(accessToken), - it.album - ).href(), - }, + ...track(urlWithToken(accessToken), it) }, }, })); diff --git a/src/navidrome.ts b/src/subsonic.ts similarity index 94% rename from src/navidrome.ts rename to src/subsonic.ts index 9af913b..22d5db1 100644 --- a/src/navidrome.ts +++ b/src/subsonic.ts @@ -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, diff --git a/tests/builders.ts b/tests/builders.ts index 3382951..d8fb3a5 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -141,6 +141,7 @@ export function aTrack(fields: Partial = {}): 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 { 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(), @@ -241,7 +246,8 @@ export const METALLICA: Artist = { year: "1986", genre: METAL, artistId: METALLICA_ID, - artistName: METALLICA_NAME, + artistName: METALLICA_NAME, + coverArt: `coverArt:${uuid()}` }, ], image: { diff --git a/tests/config.test.ts b/tests/config.test.ts index 81be850..d4a7e17 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -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", () => { - it("should be used", () => { - const url = "http://bonob1.example.com:8877/"; - process.env["BONOB_URL"] = url; + ["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/"; - 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", () => { - 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 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,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("foregroundColor", () => { - describe("when BONOB_ICON_FOREGROUND_COLOR is not specified", () => { - it(`should default to undefined`, () => { - expect(config().icons.foregroundColor).toEqual(undefined); + ["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 ''", () => { - it(`should default to undefined`, () => { - process.env["BONOB_ICON_FOREGROUND_COLOR"] = ""; - expect(config().icons.foregroundColor).toEqual(undefined); + + describe(`when ${k} is ''`, () => { + it(`should default to undefined`, () => { + process.env[k] = ""; + expect(config().icons.foregroundColor).toEqual(undefined); + }); }); - }); - - describe("when BONOB_ICON_FOREGROUND_COLOR is specified", () => { - it(`should use it`, () => { - process.env["BONOB_ICON_FOREGROUND_COLOR"] = "pink"; - expect(config().icons.foregroundColor).toEqual("pink"); + + describe(`when ${k} is specified`, () => { + it(`should use it`, () => { + process.env[k] = "pink"; + expect(config().icons.foregroundColor).toEqual("pink"); + }); }); - }); - - describe("when BONOB_ICON_FOREGROUND_COLOR is an invalid string", () => { - it(`should blow up`, () => { - process.env["BONOB_ICON_FOREGROUND_COLOR"] = "#dfasd"; - expect(() => config()).toThrow( - "Invalid color specified for BONOB_ICON_FOREGROUND_COLOR" - ); + + describe(`when ${k} is an invalid string`, () => { + it(`should blow up`, () => { + process.env[k] = "#dfasd"; + expect(() => config()).toThrow( + `Invalid value specified for 'BNB_ICON_FOREGROUND_COLOR', must match ${WORD}` + ); + }); }); }); }); describe("backgroundColor", () => { - describe("when BONOB_ICON_BACKGROUND_COLOR is not specified", () => { - it(`should default to undefined`, () => { - expect(config().icons.backgroundColor).toEqual(undefined); + ["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 ''", () => { - it(`should default to undefined`, () => { - process.env["BONOB_ICON_BACKGROUND_COLOR"] = ""; - expect(config().icons.backgroundColor).toEqual(undefined); + + describe(`when ${k} is ''`, () => { + it(`should default to undefined`, () => { + process.env[k] = ""; + expect(config().icons.backgroundColor).toEqual(undefined); + }); }); - }); - - describe("when BONOB_ICON_BACKGROUND_COLOR is specified", () => { - it(`should use it`, () => { - process.env["BONOB_ICON_BACKGROUND_COLOR"] = "blue"; - expect(config().icons.backgroundColor).toEqual("blue"); + + describe(`when ${k} is specified`, () => { + it(`should use it`, () => { + process.env[k] = "blue"; + expect(config().icons.backgroundColor).toEqual("blue"); + }); }); - }); - - describe("when BONOB_ICON_BACKGROUND_COLOR is an invalid string", () => { - it(`should blow up`, () => { - process.env["BONOB_ICON_BACKGROUND_COLOR"] = "#red"; - expect(() => config()).toThrow( - "Invalid color specified for BONOB_ICON_BACKGROUND_COLOR" - ); + + describe(`when ${k} is an invalid string`, () => { + it(`should blow up`, () => { + process.env[k] = "#red"; + expect(() => config()).toThrow( + `Invalid value specified for 'BNB_ICON_BACKGROUND_COLOR', must match ${WORD}` + ); + }); }); }); }); @@ -176,9 +234,11 @@ describe("config", () => { expect(config().secret).toEqual("bonob"); }); - it("should be overridable", () => { - process.env["BONOB_SECRET"] = "new secret"; - expect(config().secret).toEqual("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"); + }); }); }); @@ -188,83 +248,116 @@ describe("config", () => { expect(config().sonos.serviceName).toEqual("bonob"); }); - it("should be overridable", () => { - process.env["BONOB_SONOS_SERVICE_NAME"] = "foobar1000"; - expect(config().sonos.serviceName).toEqual("foobar1000"); + ["BNB_SONOS_SERVICE_NAME", "BONOB_SONOS_SERVICE_NAME"].forEach(k => { + it("should be overridable", () => { + process.env[k] = "foobar1000"; + expect(config().sonos.serviceName).toEqual("foobar1000"); + }); }); }); - describeBooleanConfigValue( - "deviceDiscovery", - "BONOB_SONOS_DEVICE_DISCOVERY", - true, - (config) => config.sonos.discovery.enabled - ); + ["BNB_SONOS_DEVICE_DISCOVERY", "BONOB_SONOS_DEVICE_DISCOVERY"].forEach(k => { + describeBooleanConfigValue( + "deviceDiscovery", + k, + true, + (config) => config.sonos.discovery.enabled + ); + }); describe("seedHost", () => { it("should default to undefined", () => { expect(config().sonos.discovery.seedHost).toBeUndefined(); }); - it("should be overridable", () => { - process.env["BONOB_SONOS_SEED_HOST"] = "123.456.789.0"; - expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0"); + ["BNB_SONOS_SEED_HOST", "BONOB_SONOS_SEED_HOST"].forEach(k => { + it("should be overridable", () => { + process.env[k] = "123.456.789.0"; + expect(config().sonos.discovery.seedHost).toEqual("123.456.789.0"); + }); }); }); - describeBooleanConfigValue( - "autoRegister", - "BONOB_SONOS_AUTO_REGISTER", - false, - (config) => config.sonos.autoRegister - ); + ["BNB_SONOS_AUTO_REGISTER", "BONOB_SONOS_AUTO_REGISTER"].forEach(k => { + describeBooleanConfigValue( + "autoRegister", + k, + false, + (config) => config.sonos.autoRegister + ); + }); + describe("sid", () => { it("should default to 246", () => { expect(config().sonos.sid).toEqual(246); }); - it("should be overridable", () => { - process.env["BONOB_SONOS_SERVICE_ID"] = "786"; - expect(config().sonos.sid).toEqual(786); + ["BNB_SONOS_SERVICE_ID", "BONOB_SONOS_SERVICE_ID"].forEach(k => { + it("should be overridable", () => { + process.env[k] = "786"; + expect(config().sonos.sid).toEqual(786); + }); }); }); }); - describe("navidrome", () => { + describe("subsonic", () => { 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"); + ["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", + k, + true, + (config) => config.scrobbleTracks + ); }); - describeBooleanConfigValue( - "scrobbleTracks", - "BONOB_SCROBBLE_TRACKS", - true, - (config) => config.scrobbleTracks - ); - describeBooleanConfigValue( - "reportNowPlaying", - "BONOB_REPORT_NOW_PLAYING", - true, - (config) => config.reportNowPlaying - ); + ["BNB_REPORT_NOW_PLAYING", "BONOB_REPORT_NOW_PLAYING"].forEach(k => { + describeBooleanConfigValue( + "reportNowPlaying", + k, + true, + (config) => config.reportNowPlaying + ); + }); }); diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 9a7b044..43a9eb3 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -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); diff --git a/tests/server.test.ts b/tests/server.test.ts index b0ba8cc..20e68e6 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -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); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 3f87c2e..740cb43 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -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,52 +360,75 @@ 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" + ); + }); }); }); @@ -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 ), diff --git a/tests/navidrome.test.ts b/tests/subsonic.test.ts similarity index 93% rename from tests/navidrome.test.ts rename to tests/subsonic.test.ts index c7562d6..205ddd3 100644 --- a/tests/navidrome.test.ts +++ b/tests/subsonic.test.ts @@ -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 = ( `; +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) => ``; -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,85 +2802,206 @@ 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 artistId = "someArtist123"; - const images: Images = { - small: undefined, - medium: undefined, - large: undefined, - }; + const images: Images = { + small: undefined, + medium: undefined, + large: undefined, + }; - const streamResponse = { - status: 200, - headers: { - "content-type": "image/jpeg", - }, - data: Buffer.from("the image", "ascii"), - }; + const streamResponse = { + status: 200, + headers: { + "content-type": "image/jpeg", + }, + data: Buffer.from("the image", "ascii"), + }; - const album1 = anAlbum(); - const album2 = anAlbum(); - - 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(artistId, "artist")); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParams, + 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, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { + 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, - count: 50, - includeNotPresent: true, }), 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( - `${url}/rest/getCoverArt`, - { + 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: album1.id, + id: artistId, }), 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 }) .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