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

@@ -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}`,
{
cause: e,
}
);
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,