Compare commits

...

2 Commits

Author SHA1 Message Date
Simon J
9b9a348b20 Fix issue where transcoded files would not play, provide support for custom clients to transcode (#194) 2024-02-07 16:21:28 +11:00
Simon J
6bf89b87e2 Feature/no more sharp (#193)
* Playlist icons working as rendered by ND

* remove duplication in cover art image url creation

* Remove unused ability to create collages of images
2024-02-05 17:22:27 +11:00
12 changed files with 887 additions and 1288 deletions

View File

@@ -16,5 +16,12 @@
"version": "latest",
"moby": true
}
},
"customizations": {
"vscode": {
"extensions": [
"esbenp.prettier-vscode"
]
}
}
}

View File

@@ -21,7 +21,8 @@ Support for Subsonic API clones (tested against Navidrome and Gonic).
- Discovery of sonos devices using seed IP address
- Auto registration with sonos on start
- Multiple registrations within a single household.
- Transcoding support for flacs using a specific player for the flac mimeType bonob/sonos
- Transcoding within subsonic clone
- Custom players by mime type, allowing custom transcoding rules for different file types
## Running
@@ -163,7 +164,6 @@ BNB_URL | http://$(hostname):4534 | URL (including path) for bonob so that sonos
BNB_SECRET | bonob | secret used for encrypting credentials
BNB_AUTH_TIMEOUT | 1h | Timeout for the sonos auth token, described in the format [ms](https://github.com/vercel/ms), ie. '5s' == 5 seconds, '11h' == 11 hours. In the case of using Navidrome this should be less than the value for ND_SESSIONTIMEOUT
BNB_LOG_LEVEL | info | Log level. One of ['debug', 'info', 'warn', 'error']
BNB_DISABLE_PLAYLIST_ART | undefined | Disables playlist art generation, ie. when there are many playlists and art generation takes too long
BNB_SERVER_LOG_REQUESTS | false | Whether or not to log http requests
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.
@@ -171,7 +171,7 @@ BNB_SONOS_SEED_HOST | undefined | sonos device seed host for discovery, or ommit
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_SUBSONIC_CUSTOM_CLIENTS | undefined | Comma delimeted mime types for custom subsonic clients when streaming. Must specify by the source mime type and the transcoded mime type. For example; <p>If you want to simply re-encode some flacs, then you could specify just "audio/flac". <p>However; if your subsonic server will transcode the track then you need to specify the resulting mime type, ie. "audio/flac>audio/mp3" <p>If you want to specify many something like; "audio/flac>audio/mp3,audio/ogg" would use client = 'bonob+audio/flac' for flacs, and 'bonob+audio/ogg' for oggs. <p>!!! Getting this configuration wrong will confuse SONOS as it will expect the wrong mime type for a track, as a result it will not play. Use with care...
BNB_SUBSONIC_ARTIST_IMAGE_CACHE | undefined | Path for caching of artist images that are sourced externally. ie. Navidrome provides spotify URLs. Remember to provide a volume-mapping for Docker, when enabling this cache.
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

View File

@@ -68,8 +68,8 @@
"scripts": {
"clean": "rm -Rf build node_modules",
"build": "tsc",
"dev": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
"devr": "BNB_DISABLE_PLAYLIST_ART=true BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
"dev": "BNB_SUBSONIC_CUSTOM_CLIENTS1=audio/flac,audio/mpeg,audio/mp4\\>audio/flac BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
"devr": "BNB_LOG_LEVEL=debug BNB_DEBUG=true BNB_SCROBBLE_TRACKS=false BNB_REPORT_NOW_PLAYING=false BNB_SONOS_SEED_HOST=$BNB_DEV_SONOS_DEVICE_IP BNB_SONOS_SERVICE_NAME=z_bonobDev BNB_SONOS_DEVICE_DISCOVERY=true BNB_SONOS_AUTO_REGISTER=true BNB_URL=\"http://${BNB_DEV_HOST_IP}:4534\" BNB_SUBSONIC_URL=\"${BNB_DEV_SUBSONIC_URL}\" nodemon -V ./src/app.ts",
"register-dev": "ts-node ./src/register.ts http://${BNB_DEV_HOST_IP}:4534",
"test": "jest",
"testw": "jest --watch",

View File

@@ -4,11 +4,11 @@ import server from "./server";
import logger from "./logger";
import {
appendMimeTypeToClientFor,
axiosImageFetcher,
cachingImageFetcher,
DEFAULT,
Subsonic,
TranscodingCustomPlayers,
NO_CUSTOM_PLAYERS
} from "./subsonic";
import { InMemoryAPITokens, sha256 } from "./api_tokens";
import { InMemoryLinkCodes } from "./link_codes";
@@ -32,9 +32,9 @@ const bonob = bonobService(
const sonosSystem = sonos(config.sonos.discovery);
const streamUserAgent = config.subsonic.customClientsFor
? appendMimeTypeToClientFor(config.subsonic.customClientsFor.split(","))
: DEFAULT;
const customPlayers = config.subsonic.customClientsFor
? TranscodingCustomPlayers.from(config.subsonic.customClientsFor)
: NO_CUSTOM_PLAYERS;
const artistImageFetcher = config.subsonic.artistImageCache
? cachingImageFetcher(config.subsonic.artistImageCache, axiosImageFetcher)
@@ -42,7 +42,7 @@ const artistImageFetcher = config.subsonic.artistImageCache
const subsonic = new Subsonic(
config.subsonic.url,
streamUserAgent,
customPlayers,
artistImageFetcher
);

View File

@@ -51,10 +51,15 @@ export type Rating = {
stars: number;
}
export type Encoding = {
player: string,
mimeType: string
}
export type Track = {
id: string;
name: string;
mimeType: string;
encoding: Encoding,
duration: number;
number: number | undefined;
genre: Genre | undefined;
@@ -113,7 +118,8 @@ export const albumToAlbumSummary = (it: Album): AlbumSummary => ({
export const playlistToPlaylistSummary = (it: Playlist): PlaylistSummary => ({
id: it.id,
name: it.name
name: it.name,
coverArt: it.coverArt
})
export type StreamingHeader = "content-type" | "content-length" | "content-range" | "accept-ranges";
@@ -131,7 +137,8 @@ export type CoverArt = {
export type PlaylistSummary = {
id: string,
name: string
name: string,
coverArt?: BUrn | undefined
}
export type Playlist = PlaylistSummary & {

View File

@@ -31,9 +31,8 @@ import { pipe } from "fp-ts/lib/function";
import { URLBuilder } from "./url_builder";
import makeI8N, { asLANGs, KEY, keys as i8nKeys, LANG } from "./i8n";
import { Icon, ICONS, festivals, features } from "./icon";
import _, { shuffle } from "underscore";
import _ from "underscore";
import morgan from "morgan";
import { takeWithRepeats } from "./utils";
import { parse } from "./burn";
import { axiosImageFetcher, ImageFetcher } from "./subsonic";
import {
@@ -558,23 +557,11 @@ function server(
});
});
const GRAVITY_9 = [
"north",
"northeast",
"east",
"southeast",
"south",
"southwest",
"west",
"northwest",
"centre",
];
app.get("/art/:burns/size/:size", (req, res) => {
app.get("/art/:burn/size/:size", (req, res) => {
const serviceToken = apiTokens.authTokenFor(
req.query[BONOB_ACCESS_TOKEN_HEADER] as string
);
const urns = req.params["burns"]!.split("&").map(parse);
const urn = parse(req.params["burn"]!);
const size = Number.parseInt(req.params["size"]!);
if (!serviceToken) {
@@ -585,55 +572,24 @@ function server(
return musicService
.login(serviceToken)
.then((musicLibrary) =>
Promise.all(
urns.map((it) => {
if (it.system == "external") {
return serverOpts.externalImageResolver(it.resource);
} else {
return musicLibrary.coverArt(it, size);
}
})
)
)
.then((coverArts) => coverArts.filter((it) => it))
.then(shuffle)
.then((coverArts) => {
if (coverArts.length == 1) {
const coverArt = coverArts[0]!;
.then((musicLibrary) => {
if (urn.system == "external") {
return serverOpts.externalImageResolver(urn.resource);
} else {
return musicLibrary.coverArt(urn, size);
}
})
.then((coverArt) => {
if(coverArt) {
res.status(200);
res.setHeader("content-type", coverArt.contentType);
return res.send(coverArt.data);
} else if (coverArts.length > 1) {
const gravity = [...GRAVITY_9];
return sharp({
create: {
width: size * 3,
height: size * 3,
channels: 3,
background: { r: 255, g: 255, b: 255 },
},
})
.composite(
takeWithRepeats(coverArts, 9).map((art) => ({
input: art?.data,
gravity: gravity.pop(),
}))
)
.png()
.toBuffer()
.then((image) => sharp(image).resize(size).png().toBuffer())
.then((image) => {
res.status(200);
res.setHeader("content-type", "image/png");
return res.send(image);
});
} else {
return res.status(404).send();
}
})
})
.catch((e: Error) => {
logger.error(`Failed fetching image ${urns.join("&")}/size/${size}`, {
logger.error(`Failed fetching image ${urn}/size/${size}`, {
cause: e,
});
return res.status(500).send();

View File

@@ -26,7 +26,7 @@ import { Clock } from "./clock";
import { URLBuilder } from "./url_builder";
import { asLANGs, I8N } from "./i8n";
import { ICON, iconForGenre } from "./icon";
import _, { uniq } from "underscore";
import _ from "underscore";
import { BUrn, formatForURL } from "./burn";
import {
isExpiredTokenError,
@@ -253,7 +253,7 @@ const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
itemType: "playlist",
id: `playlist:${playlist.id}`,
title: playlist.name,
albumArtURI: playlistAlbumArtURL(bonobUrl, playlist).href(),
albumArtURI: coverArtURI(bonobUrl, playlist).href(),
canPlay: true,
attributes: {
readOnly: false,
@@ -262,32 +262,9 @@ const playlist = (bonobUrl: URLBuilder, playlist: Playlist) => ({
},
});
export const playlistAlbumArtURL = (
export const coverArtURI = (
bonobUrl: URLBuilder,
playlist: Playlist
) => {
// todo: this should be put into config, or even just removed for the ND music source
if(process.env["BNB_DISABLE_PLAYLIST_ART"]) return iconArtURI(bonobUrl, "music");
const burns: BUrn[] = uniq(
playlist.entries.filter((it) => it.coverArt != undefined),
(it) => it.album.id
).map((it) => it.coverArt!);
if (burns.length == 0) {
return iconArtURI(bonobUrl, "error");
} else {
return bonobUrl.append({
pathname: `/art/${burns
.slice(0, 9)
.map((it) => encodeURIComponent(formatForURL(it)))
.join("&")}/size/180`,
});
}
};
export const defaultAlbumArtURI = (
bonobUrl: URLBuilder,
{ coverArt }: { coverArt: BUrn | undefined }
{ coverArt }: { coverArt?: BUrn | undefined }
) =>
pipe(
coverArt,
@@ -305,21 +282,6 @@ export const iconArtURI = (bonobUrl: URLBuilder, icon: ICON) =>
pathname: `/icon/${icon}/size/legacy`,
});
export const defaultArtistArtURI = (
bonobUrl: URLBuilder,
artist: ArtistSummary
) =>
pipe(
artist.image,
O.fromNullable,
O.map((it) =>
bonobUrl.append({
pathname: `/art/${encodeURIComponent(formatForURL(it))}/size/180`,
})
),
O.getOrElseW(() => iconArtURI(bonobUrl, "vinyl"))
);
export const sonosifyMimeType = (mimeType: string) =>
mimeType == "audio/x-flac" ? "audio/flac" : mimeType;
@@ -329,7 +291,7 @@ export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
artist: album.artistName,
artistId: `artist:${album.artistId}`,
title: album.name,
albumArtURI: defaultAlbumArtURI(bonobUrl, album).href(),
albumArtURI: coverArtURI(bonobUrl, album).href(),
canPlay: true,
// defaults
// canScroll: false,
@@ -340,7 +302,7 @@ export const album = (bonobUrl: URLBuilder, album: AlbumSummary) => ({
export const track = (bonobUrl: URLBuilder, track: Track) => ({
itemType: "track",
id: `track:${track.id}`,
mimeType: sonosifyMimeType(track.mimeType),
mimeType: sonosifyMimeType(track.encoding.mimeType),
title: track.name,
trackMetadata: {
@@ -348,7 +310,7 @@ export const track = (bonobUrl: URLBuilder, track: Track) => ({
albumId: `album:${track.album.id}`,
albumArtist: track.artist.name,
albumArtistId: track.artist.id ? `artist:${track.artist.id}` : undefined,
albumArtURI: defaultAlbumArtURI(bonobUrl, track).href(),
albumArtURI: coverArtURI(bonobUrl, track).href(),
artist: track.artist.name,
artistId: track.artist.id ? `artist:${track.artist.id}` : undefined,
duration: track.duration,
@@ -366,7 +328,7 @@ export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({
id: `artist:${artist.id}`,
artistId: artist.id,
title: artist.name,
albumArtURI: defaultArtistArtURI(bonobUrl, artist).href(),
albumArtURI: coverArtURI(bonobUrl, { coverArt: artist.image }).href(),
});
function splitId<T>(id: string) {
@@ -872,9 +834,12 @@ function bindSmapiSoapServiceToExpress(
.then((it) =>
Promise.all(
it.map((playlist) => {
// todo: whats this odd copy all about, can we just delete it?
return {
id: playlist.id,
name: playlist.name,
coverArt: playlist.coverArt,
// todo: are these every important?
entries: []
};
}

View File

@@ -20,6 +20,8 @@ import {
AlbumQueryType,
Artist,
AuthFailure,
PlaylistSummary,
Encoding,
} from "./music_service";
import sharp from "sharp";
import _ from "underscore";
@@ -162,7 +164,7 @@ export type song = {
duration: number | undefined;
bitRate: number | undefined;
suffix: string | undefined;
contentType: string | undefined;
contentType: string;
transcodedContentType: string | undefined;
type: string | undefined;
userRating: number | undefined;
@@ -178,12 +180,15 @@ type GetAlbumResponse = {
type playlist = {
id: string;
name: string;
coverArt: string | undefined;
};
type GetPlaylistResponse = {
// todo: isnt the type here a composite? playlistSummary && { entry: song[]; }
playlist: {
id: string;
name: string;
coverArt: string | undefined;
entry: song[];
};
};
@@ -271,10 +276,16 @@ export const artistImageURN = (
}
};
export const asTrack = (album: Album, song: song): Track => ({
export const asTrack = (album: Album, song: song, customPlayers: CustomPlayers): Track => ({
id: song.id,
name: song.title,
mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType!,
encoding: pipe(
customPlayers.encodingFor({ mimeType: song.contentType }),
O.getOrElse(() => ({
player: DEFAULT_CLIENT_APPLICATION,
mimeType: song.transcodedContentType ? song.transcodedContentType : song.contentType
}))
),
duration: song.duration || 0,
number: song.track || 0,
genre: maybeAsGenre(song.genre),
@@ -306,6 +317,13 @@ const asAlbum = (album: album): Album => ({
coverArt: coverArtURN(album.coverArt),
});
// coverArtURN
const asPlayListSummary = (playlist: playlist): PlaylistSummary => ({
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
});
export const asGenre = (genreName: string) => ({
id: b64Encode(genreName),
name: genreName,
@@ -319,19 +337,53 @@ const maybeAsGenre = (genreName: string | undefined): Genre | undefined =>
O.getOrElseW(() => undefined)
);
export type StreamClientApplication = (track: Track) => string;
export interface CustomPlayers {
encodingFor({ mimeType }: { mimeType: string }): O.Option<Encoding>
}
export type CustomClient = {
mimeType: string;
transcodedMimeType: string;
};
export class TranscodingCustomPlayers implements CustomPlayers {
transcodings: Map<string, string>;
constructor(transcodings: Map<string, string>) {
this.transcodings = transcodings;
}
static from(config: string): TranscodingCustomPlayers {
const parts: [string, string][] = config
.split(",")
.map((it) => it.split(">"))
.map((pair) => {
if (pair.length == 1) return [pair[0]!, pair[0]!];
else if (pair.length == 2) return [pair[0]!, pair[1]!];
else throw new Error(`Invalid configuration item ${config}`);
});
return new TranscodingCustomPlayers(new Map(parts));
}
encodingFor = ({ mimeType }: { mimeType: string }): O.Option<Encoding> => pipe(
this.transcodings.get(mimeType),
O.fromNullable,
O.map(transcodedMimeType => ({
player:`${DEFAULT_CLIENT_APPLICATION}+${mimeType}`,
mimeType: transcodedMimeType
}))
)
}
export const NO_CUSTOM_PLAYERS: CustomPlayers = {
encodingFor(_) {
return O.none
},
}
const DEFAULT_CLIENT_APPLICATION = "bonob";
const USER_AGENT = "bonob";
export const DEFAULT: StreamClientApplication = (_: Track) =>
DEFAULT_CLIENT_APPLICATION;
export function appendMimeTypeToClientFor(mimeTypes: string[]) {
return (track: Track) =>
mimeTypes.includes(track.mimeType) ? `bonob+${track.mimeType}` : "bonob";
}
export const asURLSearchParams = (q: any) => {
const urlSearchParams = new URLSearchParams();
Object.keys(q).forEach((k) => {
@@ -346,28 +398,28 @@ export type ImageFetcher = (url: string) => Promise<CoverArt | undefined>;
export const cachingImageFetcher =
(cacheDir: string, delegate: ImageFetcher) =>
async (url: string): Promise<CoverArt | undefined> => {
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
return fse
.readFile(filename)
.then((data) => ({ contentType: "image/png", data }))
.catch(() =>
delegate(url).then((image) => {
if (image) {
return sharp(image.data)
.png()
.toBuffer()
.then((png) => {
return fse
.writeFile(filename, png)
.then(() => ({ contentType: "image/png", data: png }));
});
} else {
return undefined;
}
})
);
};
async (url: string): Promise<CoverArt | undefined> => {
const filename = path.join(cacheDir, `${Md5.hashStr(url)}.png`);
return fse
.readFile(filename)
.then((data) => ({ contentType: "image/png", data }))
.catch(() =>
delegate(url).then((image) => {
if (image) {
return sharp(image.data)
.png()
.toBuffer()
.then((png) => {
return fse
.writeFile(filename, png)
.then(() => ({ contentType: "image/png", data: png }));
});
} else {
return undefined;
}
})
);
};
export const axiosImageFetcher = (url: string): Promise<CoverArt | undefined> =>
axios
@@ -415,16 +467,16 @@ interface SubsonicMusicLibrary extends MusicLibrary {
export class Subsonic implements MusicService {
url: URLBuilder;
streamClientApplication: StreamClientApplication;
customPlayers: CustomPlayers;
externalImageFetcher: ImageFetcher;
constructor(
url: URLBuilder,
streamClientApplication: StreamClientApplication = DEFAULT,
customPlayers: CustomPlayers = NO_CUSTOM_PLAYERS,
externalImageFetcher: ImageFetcher = axiosImageFetcher
) {
this.url = url;
this.streamClientApplication = streamClientApplication;
this.customPlayers = customPlayers;
this.externalImageFetcher = externalImageFetcher;
}
@@ -619,7 +671,7 @@ export class Subsonic implements MusicService {
.then((it) => it.song)
.then((song) =>
this.getAlbum(credentials, song.albumId!).then((album) =>
asTrack(album, song)
asTrack(album, song, this.customPlayers)
)
);
@@ -722,7 +774,7 @@ export class Subsonic implements MusicService {
})
.then((it) => it.album)
.then((album) =>
(album.song || []).map((song) => asTrack(asAlbum(album), song))
(album.song || []).map((song) => asTrack(asAlbum(album), song, this.customPlayers))
),
track: (trackId: string) => subsonic.getTrack(credentials, trackId),
rate: (trackId: string, rating: Rating) =>
@@ -773,7 +825,7 @@ export class Subsonic implements MusicService {
`/rest/stream`,
{
id: trackId,
c: this.streamClientApplication(track),
c: track.encoding.player,
},
{
headers: pipe(
@@ -790,15 +842,15 @@ export class Subsonic implements MusicService {
responseType: "stream",
}
)
.then((res) => ({
status: res.status,
.then((stream) => ({
status: stream.status,
headers: {
"content-type": res.headers["content-type"],
"content-length": res.headers["content-length"],
"content-range": res.headers["content-range"],
"accept-ranges": res.headers["accept-ranges"],
"content-type": stream.headers["content-type"],
"content-length": stream.headers["content-length"],
"content-range": stream.headers["content-range"],
"accept-ranges": stream.headers["accept-ranges"],
},
stream: res.data,
stream: stream.data,
}))
),
coverArt: async (coverArtURN: BUrn, size?: number) =>
@@ -861,9 +913,7 @@ export class Subsonic implements MusicService {
subsonic
.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
.then((it) => it.playlists.playlist || [])
.then((playlists) =>
playlists.map((it) => ({ id: it.id, name: it.name }))
),
.then((playlists) => playlists.map(asPlayListSummary)),
playlist: async (id: string) =>
subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", {
@@ -875,6 +925,7 @@ export class Subsonic implements MusicService {
return {
id: playlist.id,
name: playlist.name,
coverArt: coverArtURN(playlist.coverArt),
entries: (playlist.entry || []).map((entry) => ({
...asTrack(
{
@@ -886,7 +937,8 @@ export class Subsonic implements MusicService {
artistId: entry.artistId,
coverArt: coverArtURN(entry.coverArt),
},
entry
entry,
this.customPlayers
),
number: trackNumber++,
})),
@@ -898,7 +950,12 @@ export class Subsonic implements MusicService {
name,
})
.then((it) => it.playlist)
.then((it) => ({ id: it.id, name: it.name })),
// todo: why is this line so similar to other playlist lines??
.then((it) => ({
id: it.id,
name: it.name,
coverArt: coverArtURN(it.coverArt),
})),
deletePlaylist: async (id: string) =>
subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", {
@@ -932,7 +989,7 @@ export class Subsonic implements MusicService {
songs.map((song) =>
subsonic
.getAlbum(credentials, song.albumId!)
.then((album) => asTrack(album, song))
.then((album) => asTrack(album, song, this.customPlayers))
)
)
),
@@ -949,7 +1006,7 @@ export class Subsonic implements MusicService {
songs.map((song) =>
subsonic
.getAlbum(credentials, song.albumId!)
.then((album) => asTrack(album, song))
.then((album) => asTrack(album, song, this.customPlayers))
)
)
)
@@ -966,7 +1023,7 @@ export class Subsonic implements MusicService {
TE.tryCatch(
() =>
axios.post(
this.url.append({ pathname: '/auth/login' }).href(),
this.url.append({ pathname: "/auth/login" }).href(),
_.pick(credentials, "username", "password")
),
() => new AuthFailure("Failed to get bearerToken")

View File

@@ -173,7 +173,10 @@ export function aTrack(fields: Partial<Track> = {}): Track {
return {
id,
name: `Track ${id}`,
mimeType: `audio/mp3-${id}`,
encoding: {
player: "bonob",
mimeType: `audio/mp3-${id}`
},
duration: randomInt(500),
number: randomInt(100),
genre,

View File

@@ -2,9 +2,7 @@ import { v4 as uuid } from "uuid";
import dayjs from "dayjs";
import request from "supertest";
import Image from "image-js";
import fs from "fs";
import { either as E, taskEither as TE } from "fp-ts";
import path from "path";
import { AuthFailure, MusicService } from "../src/music_service";
import makeServer, {
@@ -1323,279 +1321,6 @@ describe("server", () => {
});
});
describe("fetching multiple images as a collage", () => {
const png = fs.readFileSync(
path.join(
__dirname,
"..",
"docs",
"images",
"chartreuseFuchsia.png"
)
);
describe("fetching a collage of 4 when all are available", () => {
it("should return the image and a 200", async () => {
const urns = [
"art:1",
"art:2",
"art:3",
"art:4",
].map(resource => ({ system:"subsonic", resource }));
musicService.login.mockResolvedValue(musicLibrary);
urns.forEach((_) => {
musicLibrary.coverArt.mockResolvedValueOnce(
coverArtResponse({
data: png,
})
);
});
const res = await request(server)
.get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&"
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
)
.set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(200);
expect(res.header["content-type"]).toEqual("image/png");
expect(musicService.login).toHaveBeenCalledWith(serviceToken);
urns.forEach((it) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 200);
});
const image = await Image.load(res.body);
expect(image.width).toEqual(200);
expect(image.height).toEqual(200);
});
});
describe("fetching a collage of 4, however only 1 is available", () => {
it("should return the single image", async () => {
const urns = ["art:1", "art:2", "art:3", "art:4"].map(resource => ({ system:"subsonic", resource }));
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
musicLibrary.coverArt.mockResolvedValueOnce(
coverArtResponse({
data: png,
contentType: "image/some-mime-type",
})
);
const res = await request(server)
.get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&"
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
)
.set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(200);
expect(res.header["content-type"]).toEqual(
"image/some-mime-type"
);
});
});
describe("fetching a collage of 4 and all are missing", () => {
it("should return a 404", async () => {
const urns = ["art:1", "art:2", "art:3", "art:4"].map(resource => ({ system:"subsonic", resource }));
musicService.login.mockResolvedValue(musicLibrary);
urns.forEach((_) => {
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
});
const res = await request(server)
.get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&"
)}/size/200?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
)
.set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(404);
});
});
describe("fetching a collage of 9 when all are available", () => {
it("should return the image and a 200", async () => {
const urns = [
"artist:1",
"artist:2",
"coverArt:3",
"artist:4",
"artist:5",
"artist:6",
"artist:7",
"artist:8",
"artist:9",
].map(resource => ({ system:"subsonic", resource }));
musicService.login.mockResolvedValue(musicLibrary);
urns.forEach((_) => {
musicLibrary.coverArt.mockResolvedValueOnce(
coverArtResponse({
data: png,
})
);
});
const res = await request(server)
.get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&"
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
)
.set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(200);
expect(res.header["content-type"]).toEqual("image/png");
expect(musicService.login).toHaveBeenCalledWith(serviceToken);
urns.forEach((it) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180);
});
const image = await Image.load(res.body);
expect(image.width).toEqual(180);
expect(image.height).toEqual(180);
});
});
describe("fetching a collage of 9 when only 2 are available", () => {
it("should still return an image and a 200", async () => {
const urns = [
"artist:1",
"artist:2",
"artist:3",
"artist:4",
"artist:5",
"artist:6",
"artist:7",
"artist:8",
"artist:9",
].map(resource => ({ system:"subsonic", resource }));
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.coverArt.mockResolvedValueOnce(
coverArtResponse({
data: png,
})
);
musicLibrary.coverArt.mockResolvedValueOnce(
coverArtResponse({
data: png,
})
);
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
musicLibrary.coverArt.mockResolvedValueOnce(undefined);
const res = await request(server)
.get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&"
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
)
.set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(200);
expect(res.header["content-type"]).toEqual("image/png");
expect(musicService.login).toHaveBeenCalledWith(serviceToken);
urns.forEach((urn) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith(urn, 180);
});
const image = await Image.load(res.body);
expect(image.width).toEqual(180);
expect(image.height).toEqual(180);
});
});
describe("fetching a collage of 11", () => {
it("should still return an image and a 200, though will only display 9", async () => {
const urns = [
"artist:1",
"artist:2",
"artist:3",
"artist:4",
"artist:5",
"artist:6",
"artist:7",
"artist:8",
"artist:9",
"artist:10",
"artist:11",
].map(resource => ({ system:"subsonic", resource }));
musicService.login.mockResolvedValue(musicLibrary);
urns.forEach((_) => {
musicLibrary.coverArt.mockResolvedValueOnce(
coverArtResponse({
data: png,
})
);
});
const res = await request(server)
.get(
`/art/${urns.map(it => encodeURIComponent(formatForURL(it))).join(
"&"
)}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
)
.set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(200);
expect(res.header["content-type"]).toEqual("image/png");
expect(musicService.login).toHaveBeenCalledWith(serviceToken);
urns.forEach((it) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith(it, 180);
});
const image = await Image.load(res.body);
expect(image.width).toEqual(180);
expect(image.height).toEqual(180);
});
});
describe("when the image is not available", () => {
it("should return a 404", async () => {
const coverArtURN = { system:"subsonic", resource:"art:404"};
musicService.login.mockResolvedValue(musicLibrary);
musicLibrary.coverArt.mockResolvedValue(undefined);
const res = await request(server)
.get(
`/art/${encodeURIComponent(formatForURL(coverArtURN))}/size/180?${BONOB_ACCESS_TOKEN_HEADER}=${apiToken}`
)
.set(BONOB_ACCESS_TOKEN_HEADER, apiToken);
expect(res.status).toEqual(404);
});
});
});
describe("when there is an error", () => {
it("should return a 500", async () => {
musicService.login.mockResolvedValue(musicLibrary);

View File

@@ -18,11 +18,9 @@ import {
track,
artist,
album,
defaultAlbumArtURI,
defaultArtistArtURI,
coverArtURI,
searchResult,
iconArtURI,
playlistAlbumArtURL,
sonosifyMimeType,
ratingAsInt,
ratingFromInt,
@@ -41,7 +39,6 @@ import {
TRIP_HOP,
PUNK,
aPlaylist,
anAlbumSummary,
} from "./builders";
import { InMemoryMusicService } from "./in_memory_music_service";
import supersoap from "./supersoap";
@@ -56,7 +53,6 @@ import dayjs from "dayjs";
import url, { URLBuilder } from "../src/url_builder";
import { iconForGenre } from "../src/icon";
import { formatForURL } from "../src/burn";
import { range } from "underscore";
import { FixedClock } from "../src/clock";
import { ExpiredTokenError, InvalidTokenError, SmapiAuthTokens, SmapiToken, ToSmapiFault } from "../src/smapi_auth";
@@ -356,7 +352,10 @@ describe("track", () => {
const someTrack = aTrack({
id: uuid(),
// audio/x-flac should be mapped to audio/flac
mimeType: "audio/x-flac",
encoding: {
player: "something",
mimeType: "audio/x-flac"
},
name: "great song",
duration: randomInt(1000),
number: randomInt(100),
@@ -411,7 +410,10 @@ describe("track", () => {
const someTrack = aTrack({
id: uuid(),
// audio/x-flac should be mapped to audio/flac
mimeType: "audio/x-flac",
encoding: {
player: "something",
mimeType: "audio/x-flac"
},
name: "great song",
duration: randomInt(1000),
number: randomInt(100),
@@ -471,7 +473,7 @@ describe("album", () => {
itemType: "album",
id: `album:${someAlbum.id}`,
title: someAlbum.name,
albumArtURI: defaultAlbumArtURI(bonobUrl, someAlbum).href(),
albumArtURI: coverArtURI(bonobUrl, someAlbum).href(),
canPlay: true,
artist: someAlbum.artistName,
artistId: `artist:${someAlbum.artistId}`,
@@ -495,299 +497,8 @@ describe("sonosifyMimeType", () => {
});
});
describe("playlistAlbumArtURL", () => {
const coverArt1 = { system: "subsonic", resource: "1" };
const coverArt2 = { system: "subsonic", resource: "2" };
const coverArt3 = { system: "subsonic", resource: "3" };
const coverArt4 = { system: "subsonic", resource: "4" };
const coverArt5 = { system: "subsonic", resource: "5" };
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({ coverArt: undefined }),
aTrack({ coverArt: undefined }),
],
});
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
`http://localhost:1234/context-path/icon/error/size/legacy?search=yes`
);
});
});
describe("when the playlist has external ids", () => {
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
const externalArt1 = {
system: "external",
resource: "http://example.com/image1.jpg",
};
const externalArt2 = {
system: "external",
resource: "http://example.com/image2.jpg",
};
const playlist = aPlaylist({
entries: [
aTrack({
coverArt: externalArt1,
album: anAlbumSummary({ id: "album1" }),
}),
aTrack({
coverArt: externalArt2,
album: anAlbumSummary({ id: "album2" }),
}),
],
});
it("should format the url with encrypted urn", () => {
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
`http://localhost:1234/context-path/art/${encodeURIComponent(
formatForURL(externalArt1)
)}&${encodeURIComponent(
formatForURL(externalArt2)
)}/size/180?search=yes`
);
});
describe("when BNB_NO_PLAYLIST_ART is set", () => {
const OLD_ENV = process.env;
beforeEach(() => {
process.env = { ...OLD_ENV };
process.env["BNB_DISABLE_PLAYLIST_ART"] = "true";
});
afterEach(() => {
process.env = OLD_ENV;
});
it("should return an icon", () => {
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
`http://localhost:1234/context-path/icon/music/size/legacy?search=yes`
);
});
});
});
describe("when the playlist has 4 tracks from 2 different albums, including some tracks that are missing coverArt urns", () => {
it("should use the cover art once per album", () => {
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
const playlist = aPlaylist({
entries: [
aTrack({
coverArt: undefined,
album: anAlbumSummary({ id: "album1" }),
}),
aTrack({
coverArt: coverArt1,
album: anAlbumSummary({ id: "album1" }),
}),
aTrack({
coverArt: coverArt2,
album: anAlbumSummary({ id: "album2" }),
}),
aTrack({
coverArt: undefined,
album: anAlbumSummary({ id: "album2" }),
}),
aTrack({
coverArt: coverArt3,
album: anAlbumSummary({ id: "album1" }),
}),
aTrack({
coverArt: coverArt4,
album: anAlbumSummary({ id: "album2" }),
}),
aTrack({
coverArt: undefined,
album: anAlbumSummary({ id: "album2" }),
}),
],
});
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
`http://localhost:1234/context-path/art/${encodeURIComponent(
formatForURL(coverArt1)
)}&${encodeURIComponent(formatForURL(coverArt2))}/size/180?search=yes`
);
});
});
describe("when the playlist has 4 tracks from 2 different albums", () => {
it("should use the cover art once per album", () => {
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
const playlist = aPlaylist({
entries: [
aTrack({
coverArt: coverArt1,
album: anAlbumSummary({ id: "album1" }),
}),
aTrack({
coverArt: coverArt2,
album: anAlbumSummary({ id: "album2" }),
}),
aTrack({
coverArt: coverArt3,
album: anAlbumSummary({ id: "album1" }),
}),
aTrack({
coverArt: coverArt4,
album: anAlbumSummary({ id: "album2" }),
}),
],
});
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
`http://localhost:1234/context-path/art/${encodeURIComponent(
formatForURL(coverArt1)
)}&${encodeURIComponent(formatForURL(coverArt2))}/size/180?search=yes`
);
});
});
describe("when the playlist has 4 tracks from 3 different albums", () => {
it("should use the cover art once per album", () => {
const bonobUrl = url("http://localhost:1234/context-path?search=yes");
const playlist = aPlaylist({
entries: [
aTrack({
coverArt: coverArt1,
album: anAlbumSummary({ id: "album1" }),
}),
aTrack({
coverArt: coverArt2,
album: anAlbumSummary({ id: "album2" }),
}),
aTrack({
coverArt: coverArt3,
album: anAlbumSummary({ id: "album1" }),
}),
aTrack({
coverArt: coverArt4,
album: anAlbumSummary({ id: "album3" }),
}),
],
});
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
`http://localhost:1234/context-path/art/${encodeURIComponent(
formatForURL(coverArt1)
)}&${encodeURIComponent(formatForURL(coverArt2))}&${encodeURIComponent(
formatForURL(coverArt4)
)}/size/180?search=yes`
);
});
});
describe("when the playlist has 4 tracks from 4 different albums", () => {
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({
coverArt: coverArt1,
album: anAlbumSummary({ id: "album1" }),
}),
aTrack({
coverArt: coverArt2,
album: anAlbumSummary({ id: "album2" }),
}),
aTrack({
coverArt: coverArt3,
album: anAlbumSummary({ id: "album3" }),
}),
aTrack({
coverArt: coverArt4,
album: anAlbumSummary({ id: "album4" }),
}),
aTrack({
coverArt: coverArt5,
album: anAlbumSummary({ id: "album1" }),
}),
],
});
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
`http://localhost:1234/context-path/art/${encodeURIComponent(
formatForURL(coverArt1)
)}&${encodeURIComponent(formatForURL(coverArt2))}&${encodeURIComponent(
formatForURL(coverArt3)
)}&${encodeURIComponent(formatForURL(coverArt4))}/size/180?search=yes`
);
});
});
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({
coverArt: { system: "subsonic", resource: "1" },
album: anAlbumSummary({ id: "1" }),
}),
aTrack({
coverArt: { system: "subsonic", resource: "2" },
album: anAlbumSummary({ id: "2" }),
}),
aTrack({
coverArt: { system: "subsonic", resource: "3" },
album: anAlbumSummary({ id: "3" }),
}),
aTrack({
coverArt: { system: "subsonic", resource: "4" },
album: anAlbumSummary({ id: "4" }),
}),
aTrack({
coverArt: { system: "subsonic", resource: "5" },
album: anAlbumSummary({ id: "5" }),
}),
aTrack({
coverArt: { system: "subsonic", resource: "6" },
album: anAlbumSummary({ id: "6" }),
}),
aTrack({
coverArt: { system: "subsonic", resource: "7" },
album: anAlbumSummary({ id: "7" }),
}),
aTrack({
coverArt: { system: "subsonic", resource: "8" },
album: anAlbumSummary({ id: "8" }),
}),
aTrack({
coverArt: { system: "subsonic", resource: "9" },
album: anAlbumSummary({ id: "9" }),
}),
aTrack({
coverArt: { system: "subsonic", resource: "10" },
album: anAlbumSummary({ id: "10" }),
}),
aTrack({
coverArt: { system: "subsonic", resource: "11" },
album: anAlbumSummary({ id: "11" }),
}),
],
});
const burns = range(1, 10)
.map((i) =>
encodeURIComponent(
formatForURL({ system: "subsonic", resource: `${i}` })
)
)
.join("&");
expect(playlistAlbumArtURL(bonobUrl, playlist).href()).toEqual(
`http://localhost:1234/context-path/art/${burns}/size/180?search=yes`
);
});
});
});
describe("defaultAlbumArtURI", () => {
describe("coverArtURI", () => {
const bonobUrl = new URLBuilder(
"http://bonob.example.com:8080/context?search=yes"
);
@@ -797,7 +508,7 @@ describe("defaultAlbumArtURI", () => {
it("should use it", () => {
const coverArt = { system: "subsonic", resource: "12345" };
expect(
defaultAlbumArtURI(bonobUrl, anAlbum({ coverArt })).href()
coverArtURI(bonobUrl, anAlbum({ coverArt })).href()
).toEqual(
`http://bonob.example.com:8080/context/art/${encodeURIComponent(
formatForURL(coverArt)
@@ -813,7 +524,7 @@ describe("defaultAlbumArtURI", () => {
resource: "http://example.com/someimage.jpg",
};
expect(
defaultAlbumArtURI(bonobUrl, anAlbum({ coverArt })).href()
coverArtURI(bonobUrl, anAlbum({ coverArt })).href()
).toEqual(
`http://bonob.example.com:8080/context/art/${encodeURIComponent(
formatForURL(coverArt)
@@ -826,7 +537,7 @@ describe("defaultAlbumArtURI", () => {
describe("when there is no album coverArt", () => {
it("should return a vinly icon image", () => {
expect(
defaultAlbumArtURI(bonobUrl, anAlbum({ coverArt: undefined })).href()
coverArtURI(bonobUrl, anAlbum({ coverArt: undefined })).href()
).toEqual(
"http://bonob.example.com:8080/context/icon/vinyl/size/legacy?search=yes"
);
@@ -834,50 +545,6 @@ describe("defaultAlbumArtURI", () => {
});
});
describe("defaultArtistArtURI", () => {
describe("when the artist has no image", () => {
it("should return an icon", () => {
const bonobUrl = url("http://localhost:1234/something?s=123");
const artist = anArtist({ image: undefined });
expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual(
`http://localhost:1234/something/icon/vinyl/size/legacy?s=123`
);
});
});
describe("when the resource is subsonic", () => {
it("should use the resource", () => {
const bonobUrl = url("http://localhost:1234/something?s=123");
const image = { system: "subsonic", resource: "art:1234" };
const artist = anArtist({ image });
expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual(
`http://localhost:1234/something/art/${encodeURIComponent(
formatForURL(image)
)}/size/180?s=123`
);
});
});
describe("when the resource is external", () => {
it("should encrypt the resource", () => {
const bonobUrl = url("http://localhost:1234/something?s=123");
const image = {
system: "external",
resource: "http://example.com/something.jpg",
};
const artist = anArtist({ image });
expect(defaultArtistArtURI(bonobUrl, artist).href()).toEqual(
`http://localhost:1234/something/art/${encodeURIComponent(
formatForURL(image)
)}/size/180?s=123`
);
});
});
});
describe("wsdl api", () => {
const musicService = {
generateToken: jest.fn(),
@@ -1701,7 +1368,7 @@ describe("wsdl api", () => {
itemType: "playlist",
id: `playlist:${playlist.id}`,
title: playlist.name,
albumArtURI: playlistAlbumArtURL(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
playlist
).href(),
@@ -1733,7 +1400,7 @@ describe("wsdl api", () => {
itemType: "playlist",
id: `playlist:${playlist.id}`,
title: playlist.name,
albumArtURI: playlistAlbumArtURL(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
playlist
).href(),
@@ -1777,7 +1444,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -1814,7 +1481,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -1866,9 +1533,9 @@ describe("wsdl api", () => {
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
{ coverArt: it.image }
).href(),
})),
index: 0,
@@ -1911,9 +1578,9 @@ describe("wsdl api", () => {
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
{ coverArt: it.image }
).href(),
})),
index: 1,
@@ -1972,9 +1639,9 @@ describe("wsdl api", () => {
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
{ coverArt: it.image }
).href(),
})),
index: 0,
@@ -2001,9 +1668,9 @@ describe("wsdl api", () => {
id: `artist:${it.id}`,
artistId: it.id,
title: it.name,
albumArtURI: defaultArtistArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
{ coverArt: it.image }
).href(),
})
),
@@ -2118,7 +1785,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -2166,7 +1833,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -2214,7 +1881,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -2262,7 +1929,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -2310,7 +1977,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -2358,7 +2025,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -2404,7 +2071,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -2450,7 +2117,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -2494,7 +2161,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -2541,7 +2208,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${it.id}`,
title: it.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
it
).href(),
@@ -2918,7 +2585,7 @@ describe("wsdl api", () => {
id: `track:${track.id}`,
itemType: "track",
title: track.name,
mimeType: track.mimeType,
mimeType: track.encoding.mimeType,
trackMetadata: {
artistId: `artist:${track.artist.id}`,
artist: track.artist.name,
@@ -2929,7 +2596,7 @@ describe("wsdl api", () => {
genre: track.genre?.name,
genreId: track.genre?.id,
duration: track.duration,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
track
).href(),
@@ -2966,7 +2633,7 @@ describe("wsdl api", () => {
id: `track:${track.id}`,
itemType: "track",
title: track.name,
mimeType: track.mimeType,
mimeType: track.encoding.mimeType,
trackMetadata: {
artistId: `artist:${track.artist.id}`,
artist: track.artist.name,
@@ -2977,7 +2644,7 @@ describe("wsdl api", () => {
genre: track.genre?.name,
genreId: track.genre?.id,
duration: track.duration,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
track
).href(),
@@ -3020,7 +2687,7 @@ describe("wsdl api", () => {
itemType: "album",
id: `album:${album.id}`,
title: album.name,
albumArtURI: defaultAlbumArtURI(
albumArtURI: coverArtURI(
bonobUrlWithAccessToken,
album
).href(),

File diff suppressed because it is too large Load Diff