From b6ba9c5a526d83bfa5adf3533db99f445fcfde1f Mon Sep 17 00:00:00 2001 From: Simon J Date: Thu, 30 Sep 2021 12:19:43 +1000 Subject: [PATCH] Use bat query param rather than header when streaming as headers not passed in HEAD requests from sonos. Improve handling of failures when fetching coverArt to return undefined rather than throwing exception (#59) --- src/server.ts | 14 +- src/smapi.ts | 8 +- src/subsonic.ts | 175 ++++++----- tests/server.test.ts | 270 ++++++++++------ tests/smapi.test.ts | 5 +- tests/subsonic.test.ts | 692 ++++++++++++++++++++++++----------------- 6 files changed, 688 insertions(+), 476 deletions(-) diff --git a/src/server.ts b/src/server.ts index da9291c..081ab81 100644 --- a/src/server.ts +++ b/src/server.ts @@ -291,12 +291,14 @@ function server( app.get("/stream/track/:id", async (req, res) => { const id = req.params["id"]!; const trace = uuid(); - + logger.info( - `${trace} bnb<- ${req.method} /stream/track/${id}, headers=${JSON.stringify(req.headers)}` + `${trace} bnb<- ${req.method} ${req.path}?${ + JSON.stringify(req.query) + }, headers=${JSON.stringify(req.headers)}` ); const authToken = pipe( - req.header(BONOB_ACCESS_TOKEN_HEADER), + req.query[BONOB_ACCESS_TOKEN_HEADER] as string, O.fromNullable, O.map((accessToken) => accessTokens.authTokenFor(accessToken)), O.getOrElseW(() => undefined) @@ -340,9 +342,9 @@ function server( sendStream: boolean; }) => { logger.info( - `${trace} bnb-> /stream/track/${id}, status=${status}, headers=${JSON.stringify( - headers - )}` + `${trace} bnb-> ${ + req.path + }, status=${status}, headers=${JSON.stringify(headers)}` ); res.status(status); Object.entries(headers) diff --git a/src/smapi.ts b/src/smapi.ts index 527415b..087e367 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -18,7 +18,6 @@ import { Track, } from "./music_service"; import { AccessTokens } from "./access_tokens"; -import { BONOB_ACCESS_TOKEN_HEADER } from "./server"; import { Clock } from "./clock"; import { URLBuilder } from "./url_builder"; import { asLANGs, I8N } from "./i8n"; @@ -404,14 +403,9 @@ function bindSmapiSoapServiceToExpress( getMediaURIResult: bonobUrl .append({ pathname: `/stream/${type}/${typeId}`, + searchParams: { "bat": accessToken } }) .href(), - httpHeaders: [ - { - header: BONOB_ACCESS_TOKEN_HEADER, - value: accessToken, - }, - ], })), getMediaMetadata: async ( { id }: { id: string }, diff --git a/src/subsonic.ts b/src/subsonic.ts index 22d5db1..1d724bf 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -27,6 +27,7 @@ import axios, { AxiosRequestConfig } from "axios"; import { Encryption } from "./encryption"; import randomString from "./random_string"; import { b64Encode, b64Decode } from "./b64"; +import logger from "./logger"; export const BROWSER_HEADERS = { accept: @@ -225,8 +226,8 @@ export function isError( } 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'` + 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(":")]; }; @@ -246,7 +247,8 @@ export type getAlbumListParams = { export const MAX_ALBUM_LIST = 500; -const maybeAsCoverArt = (coverArt: string | undefined) => coverArt ? `coverArt:${coverArt}` : undefined +const maybeAsCoverArt = (coverArt: string | undefined) => + coverArt ? `coverArt:${coverArt}` : undefined; const asTrack = (album: Album, song: song) => ({ id: song._id, @@ -270,7 +272,7 @@ const asAlbum = (album: album) => ({ genre: maybeAsGenre(album._genre), artistId: album._artistId, artistName: album._artist, - coverArt: maybeAsCoverArt(album._coverArt) + coverArt: maybeAsCoverArt(album._coverArt), }); export const asGenre = (genreName: string) => ({ @@ -438,7 +440,7 @@ export class Subsonic implements MusicService { genre: maybeAsGenre(album._genre), artistId: album._artistId, artistName: album._artist, - coverArt: maybeAsCoverArt(album._coverArt) + coverArt: maybeAsCoverArt(album._coverArt), })); getArtist = ( @@ -492,7 +494,7 @@ export class Subsonic implements MusicService { genre: maybeAsGenre(album._genre), artistId: album._artistId, artistName: album._artist, - coverArt: maybeAsCoverArt(album._coverArt) + coverArt: maybeAsCoverArt(album._coverArt), })); search3 = (credentials: Credentials, q: any) => @@ -508,12 +510,12 @@ export class Subsonic implements MusicService { })); async login(token: string) { - const navidrome = this; + const subsonic = this; const credentials: Credentials = this.parseToken(token); const musicLibrary: MusicLibrary = { artists: (q: ArtistQuery): Promise> => - navidrome + subsonic .getArtists(credentials) .then(slice2(q)) .then(([page, total]) => ({ @@ -521,15 +523,15 @@ export class Subsonic implements MusicService { results: page.map((it) => ({ id: it.id, name: it.name })), })), artist: async (id: string): Promise => - navidrome.getArtistWithInfo(credentials, id), + subsonic.getArtistWithInfo(credentials, id), albums: async (q: AlbumQuery): Promise> => Promise.all([ - navidrome + subsonic .getArtists(credentials) .then((it) => _.inject(it, (total, artist) => total + artist.albumCount, 0) ), - navidrome + subsonic .getJSON(credentials, "/rest/getAlbumList2", { type: q.type, ...(q.genre ? { genre: b64Decode(q.genre) } : {}), @@ -537,15 +539,14 @@ export class Subsonic implements MusicService { offset: q._index, }) .then((response) => response.albumList2.album || []) - .then(navidrome.toAlbumSummary), + .then(subsonic.toAlbumSummary), ]).then(([total, albums]) => ({ results: albums.slice(0, q._count), total: albums.length == 500 ? total : q._index + albums.length, })), - album: (id: string): Promise => - navidrome.getAlbum(credentials, id), + album: (id: string): Promise => subsonic.getAlbum(credentials, id), genres: () => - navidrome + subsonic .getJSON(credentials, "/rest/getGenres") .then((it) => pipe( @@ -557,7 +558,7 @@ export class Subsonic implements MusicService { ) ), tracks: (albumId: string) => - navidrome + subsonic .getJSON(credentials, "/rest/getAlbum", { id: albumId, }) @@ -565,7 +566,7 @@ export class Subsonic implements MusicService { .then((album) => (album.song || []).map((song) => asTrack(asAlbum(album), song)) ), - track: (trackId: string) => navidrome.getTrack(credentials, trackId), + track: (trackId: string) => subsonic.getTrack(credentials, trackId), stream: async ({ trackId, range, @@ -573,8 +574,8 @@ export class Subsonic implements MusicService { trackId: string; range: string | undefined; }) => - navidrome.getTrack(credentials, trackId).then((track) => - navidrome + subsonic.getTrack(credentials, trackId).then((track) => + subsonic .get( credentials, `/rest/stream`, @@ -611,51 +612,69 @@ export class Subsonic implements MusicService { 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!, { - headers: BROWSER_HEADERS, - responseType: "arraybuffer", - }) - .then((res) => { - const image = Buffer.from(res.data, "binary"); - if (size) { - return sharp(image) - .resize(size) - .toBuffer() - .then((resized) => ({ - contentType: res.headers["content-type"], - data: resized, - })); - } else { - return { - contentType: res.headers["content-type"], - data: image, - }; - } - }); - } else if (albumsWithCoverArt.length > 0) { - return navidrome - .getCoverArt(credentials, splitCoverArtId(albumsWithCoverArt[0]!.coverArt!)[1], size) - .then((res) => ({ - contentType: res.headers["content-type"], - data: Buffer.from(res.data, "binary"), - })); - } else { + return subsonic + .getCoverArt(credentials, id, size) + .then((res) => ({ + contentType: res.headers["content-type"], + data: Buffer.from(res.data, "binary"), + })) + .catch((e) => { + logger.error(`Failed getting coverArt ${coverArt}: ${e}`); return undefined; - } - }); + }); + } else { + return subsonic + .getArtistWithInfo(credentials, id) + .then((artist) => { + const albumsWithCoverArt = artist.albums.filter( + (it) => it.coverArt + ); + if (artist.image.large) { + return axios + .get(artist.image.large!, { + headers: BROWSER_HEADERS, + responseType: "arraybuffer", + }) + .then((res) => { + const image = Buffer.from(res.data, "binary"); + if (size) { + return sharp(image) + .resize(size) + .toBuffer() + .then((resized) => ({ + contentType: res.headers["content-type"], + data: resized, + })); + } else { + return { + contentType: res.headers["content-type"], + data: image, + }; + } + }); + } else if (albumsWithCoverArt.length > 0) { + return subsonic + .getCoverArt( + credentials, + splitCoverArtId(albumsWithCoverArt[0]!.coverArt!)[1], + size + ) + .then((res) => ({ + contentType: res.headers["content-type"], + data: Buffer.from(res.data, "binary"), + })); + } else { + return undefined; + } + }) + .catch((e) => { + logger.error(`Failed getting coverArt ${coverArt}: ${e}`); + return undefined; + }); } }, scrobble: async (id: string) => - navidrome + subsonic .get(credentials, `/rest/scrobble`, { id, submission: true, @@ -663,7 +682,7 @@ export class Subsonic implements MusicService { .then((_) => true) .catch(() => false), nowPlaying: async (id: string) => - navidrome + subsonic .get(credentials, `/rest/scrobble`, { id, submission: false, @@ -671,7 +690,7 @@ export class Subsonic implements MusicService { .then((_) => true) .catch(() => false), searchArtists: async (query: string) => - navidrome + subsonic .search3(credentials, { query, artistCount: 20 }) .then(({ artists }) => artists.map((artist) => ({ @@ -680,26 +699,26 @@ export class Subsonic implements MusicService { })) ), searchAlbums: async (query: string) => - navidrome + subsonic .search3(credentials, { query, albumCount: 20 }) - .then(({ albums }) => navidrome.toAlbumSummary(albums)), + .then(({ albums }) => subsonic.toAlbumSummary(albums)), searchTracks: async (query: string) => - navidrome + subsonic .search3(credentials, { query, songCount: 20 }) .then(({ songs }) => Promise.all( - songs.map((it) => navidrome.getTrack(credentials, it._id)) + songs.map((it) => subsonic.getTrack(credentials, it._id)) ) ), playlists: async () => - navidrome + subsonic .getJSON(credentials, "/rest/getPlaylists") .then((it) => it.playlists.playlist || []) .then((playlists) => playlists.map((it) => ({ id: it._id, name: it._name })) ), playlist: async (id: string) => - navidrome + subsonic .getJSON(credentials, "/rest/getPlaylist", { id, }) @@ -724,7 +743,7 @@ export class Subsonic implements MusicService { genre: maybeAsGenre(entry._genre), artistName: entry._artist, artistId: entry._artistId, - coverArt: maybeAsCoverArt(entry._coverArt) + coverArt: maybeAsCoverArt(entry._coverArt), }, artist: { id: entry._artistId, @@ -734,34 +753,34 @@ export class Subsonic implements MusicService { }; }), createPlaylist: async (name: string) => - navidrome + subsonic .getJSON(credentials, "/rest/createPlaylist", { name, }) .then((it) => it.playlist) .then((it) => ({ id: it._id, name: it._name })), deletePlaylist: async (id: string) => - navidrome + subsonic .getJSON(credentials, "/rest/deletePlaylist", { id, }) .then((_) => true), addToPlaylist: async (playlistId: string, trackId: string) => - navidrome + subsonic .getJSON(credentials, "/rest/updatePlaylist", { playlistId, songIdToAdd: trackId, }) .then((_) => true), removeFromPlaylist: async (playlistId: string, indicies: number[]) => - navidrome + subsonic .getJSON(credentials, "/rest/updatePlaylist", { playlistId, songIndexToRemove: indicies, }) .then((_) => true), similarSongs: async (id: string) => - navidrome + subsonic .getJSON( credentials, "/rest/getSimilarSongs2", @@ -771,15 +790,15 @@ export class Subsonic implements MusicService { .then((songs) => Promise.all( songs.map((song) => - navidrome + subsonic .getAlbum(credentials, song._albumId) .then((album) => asTrack(album, song)) ) ) ), topSongs: async (artistId: string) => - navidrome.getArtist(credentials, artistId).then(({ name }) => - navidrome + subsonic.getArtist(credentials, artistId).then(({ name }) => + subsonic .getJSON(credentials, "/rest/getTopSongs", { artist: name, count: 50, @@ -788,7 +807,7 @@ export class Subsonic implements MusicService { .then((songs) => Promise.all( songs.map((song) => - navidrome + subsonic .getAlbum(credentials, song._albumId) .then((album) => asTrack(album, song)) ) diff --git a/tests/server.test.ts b/tests/server.test.ts index d1dff50..98721ca 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -186,7 +186,7 @@ describe("server", () => { bonobUrl, new InMemoryMusicService(), { - version: "v123.456" + version: "v123.456", } ); @@ -230,49 +230,48 @@ describe("server", () => { name: "bonobMissing", sid: 88, }); - + const fakeSonos: Sonos = { devices: () => Promise.resolve([]), - services: () => - Promise.resolve([]), + services: () => Promise.resolve([]), remove: () => Promise.resolve(false), register: () => Promise.resolve(false), }; - + const server = makeServer( fakeSonos, missingBonobService, bonobUrl, new InMemoryMusicService() ); - + describe("devices list", () => { it("should be empty", async () => { const res = await request(server) .get(bonobUrl.append({ pathname: "/" }).path()) .set("accept-language", acceptLanguage) .send(); - + expect(res.status).toEqual(200); expect(res.text).toMatch(`

${lang("devices")} \(0\)

`); expect(res.text).not.toMatch(/class=device/); - expect(res.text).toContain(lang("noSonosDevices")); + expect(res.text).toContain(lang("noSonosDevices")); }); }); - + describe("services", () => { it("should be empty", async () => { const res = await request(server) .get(bonobUrl.append({ pathname: "/" }).path()) .set("accept-language", acceptLanguage) .send(); - + expect(res.status).toEqual(200); expect(res.text).toMatch(`

${lang("services")} \(0\)

`); }); }); }); - + describe("there are 2 devices and bonob is not registered", () => { const service1 = aService({ name: "s1", @@ -294,19 +293,19 @@ describe("server", () => { name: "bonobMissing", sid: 88, }); - + const device1: Device = aDevice({ name: "device1", ip: "172.0.0.1", port: 4301, }); - + const device2: Device = aDevice({ name: "device2", ip: "172.0.0.2", port: 4302, }); - + const fakeSonos: Sonos = { devices: () => Promise.resolve([device1, device2]), services: () => @@ -314,35 +313,35 @@ describe("server", () => { remove: () => Promise.resolve(false), register: () => Promise.resolve(false), }; - + const server = makeServer( fakeSonos, missingBonobService, bonobUrl, new InMemoryMusicService() ); - + describe("devices list", () => { it("should contain the devices returned from sonos", async () => { const res = await request(server) .get(bonobUrl.append({ pathname: "/" }).path()) .set("accept-language", acceptLanguage) .send(); - + expect(res.status).toEqual(200); expect(res.text).toMatch(`

${lang("devices")} \(2\)

`); expect(res.text).toMatch(/device1\s+\(172.0.0.1:4301\)/); expect(res.text).toMatch(/device2\s+\(172.0.0.2:4302\)/); }); }); - + describe("services", () => { it("should contain a list of services returned from sonos", async () => { const res = await request(server) .get(bonobUrl.append({ pathname: "/" }).path()) .set("accept-language", acceptLanguage) .send(); - + expect(res.status).toEqual(200); expect(res.text).toMatch(`

${lang("services")} \(4\)

`); expect(res.text).toMatch(/s1\s+\(1\)/); @@ -351,7 +350,7 @@ describe("server", () => { expect(res.text).toMatch(/s4\s+\(4\)/); }); }); - + describe("registration status", () => { it("should be not-registered", async () => { const res = await request(server) @@ -372,10 +371,10 @@ describe("server", () => { }); }); }); - + describe("there are 2 devices and bonob is registered", () => { const service1 = aService(); - + const service2 = aService(); const device1: Device = aDevice({ @@ -383,32 +382,33 @@ describe("server", () => { ip: "172.0.0.1", port: 4301, }); - + const device2: Device = aDevice({ name: "device2", ip: "172.0.0.2", port: 4302, }); - + const bonobService = aService({ name: "bonobNotMissing", sid: 99, }); - + const fakeSonos: Sonos = { devices: () => Promise.resolve([device1, device2]), - services: () => Promise.resolve([service1, service2, bonobService]), + services: () => + Promise.resolve([service1, service2, bonobService]), remove: () => Promise.resolve(false), register: () => Promise.resolve(false), }; - + const server = makeServer( fakeSonos, bonobService, bonobUrl, new InMemoryMusicService() ); - + describe("registration status", () => { it("should be registered", async () => { const res = await request(server) @@ -755,13 +755,14 @@ describe("server", () => { it("should return a 401", async () => { now = now.add(1, "day"); - const res = await request(server) - .head( - bonobUrl - .append({ pathname: `/stream/track/${trackId}` }) - .path() - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + const res = await request(server).head( + bonobUrl + .append({ + pathname: `/stream/track/${trackId}`, + searchParams: { bat: accessToken }, + }) + .path() + ); expect(res.status).toEqual(401); }); @@ -786,10 +787,9 @@ describe("server", () => { const res = await request(server) .head( bonobUrl - .append({ pathname: `/stream/track/${trackId}` }) + .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .path() - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + ); expect(res.status).toEqual(trackStream.status); expect(res.headers["content-type"]).toEqual( @@ -812,8 +812,10 @@ describe("server", () => { musicLibrary.stream.mockResolvedValue(trackStream); const res = await request(server) - .head(`/stream/track/${trackId}`) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + .head(bonobUrl + .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) + .path() + ); expect(res.status).toEqual(404); expect(res.body).toEqual({}); @@ -840,10 +842,9 @@ describe("server", () => { const res = await request(server) .get( bonobUrl - .append({ pathname: `/stream/track/${trackId}` }) + .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .path() - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + ); expect(res.status).toEqual(401); }); @@ -863,10 +864,9 @@ describe("server", () => { const res = await request(server) .get( bonobUrl - .append({ pathname: `/stream/track/${trackId}` }) + .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .path() - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + ); expect(res.status).toEqual(404); @@ -894,10 +894,9 @@ describe("server", () => { const res = await request(server) .get( bonobUrl - .append({ pathname: `/stream/track/${trackId}` }) + .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .path() - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + ); expect(res.status).toEqual(stream.status); expect(res.headers["content-type"]).toEqual( @@ -933,10 +932,9 @@ describe("server", () => { const res = await request(server) .get( bonobUrl - .append({ pathname: `/stream/track/${trackId}` }) + .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .path() - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + ); expect(res.status).toEqual(stream.status); expect(res.headers["content-type"]).toEqual( @@ -970,10 +968,9 @@ describe("server", () => { const res = await request(server) .get( bonobUrl - .append({ pathname: `/stream/track/${trackId}` }) + .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .path() - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + ); expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( @@ -1008,10 +1005,9 @@ describe("server", () => { const res = await request(server) .get( bonobUrl - .append({ pathname: `/stream/track/${trackId}` }) + .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .path() - ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); + ); expect(res.status).toEqual(stream.status); expect(res.header["content-type"]).toEqual( @@ -1051,10 +1047,9 @@ describe("server", () => { const res = await request(server) .get( bonobUrl - .append({ pathname: `/stream/track/${trackId}` }) + .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .path() ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken) .set("Range", requestedRange); expect(res.status).toEqual(stream.status); @@ -1093,10 +1088,9 @@ describe("server", () => { const res = await request(server) .get( bonobUrl - .append({ pathname: `/stream/track/${trackId}` }) + .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } }) .path() ) - .set(BONOB_ACCESS_TOKEN_HEADER, accessToken) .set("Range", "4000-5000"); expect(res.status).toEqual(stream.status); @@ -1242,11 +1236,24 @@ describe("server", () => { }); describe("fetching multiple images as a collage", () => { - const png = fs.readFileSync(path.join(__dirname, '..', 'docs', 'images', 'chartreuseFuchsia.png')); + 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 ids = ["artist:1", "artist:2", "coverArt:3", "coverArt:4"]; + const ids = [ + "artist:1", + "artist:2", + "coverArt:3", + "coverArt:4", + ]; musicService.login.mockResolvedValue(musicLibrary); @@ -1258,10 +1265,11 @@ describe("server", () => { ); }); - const res = await request(server) .get( - `/art/${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); @@ -1270,10 +1278,7 @@ describe("server", () => { expect(musicService.login).toHaveBeenCalledWith(authToken); ids.forEach((id) => { - expect(musicLibrary.coverArt).toHaveBeenCalledWith( - id, - 200 - ); + expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 200); }); const image = await Image.load(res.body); @@ -1294,7 +1299,7 @@ describe("server", () => { musicLibrary.coverArt.mockResolvedValueOnce( coverArtResponse({ data: png, - contentType: "image/some-mime-type" + contentType: "image/some-mime-type", }) ); @@ -1307,10 +1312,12 @@ describe("server", () => { .set(BONOB_ACCESS_TOKEN_HEADER, accessToken); expect(res.status).toEqual(200); - expect(res.header["content-type"]).toEqual("image/some-mime-type"); + 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 ids = ["artist:1", "artist:2", "artist:3", "artist:4"]; @@ -1335,7 +1342,17 @@ describe("server", () => { describe("fetching a collage of 9 when all are available", () => { it("should return the image and a 200", async () => { - const ids = ["artist:1", "artist:2", "coverArt:3", "artist:4", "artist:5", "artist:6", "artist:7", "artist:8", "artist: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); @@ -1360,10 +1377,7 @@ describe("server", () => { expect(musicService.login).toHaveBeenCalledWith(authToken); ids.forEach((id) => { - expect(musicLibrary.coverArt).toHaveBeenCalledWith( - id, - 180 - ); + expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 180); }); const image = await Image.load(res.body); @@ -1374,7 +1388,17 @@ 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 = ["artist:1", "artist:2", "artist:3", "artist:4", "artist:5", "artist:6", "artist:7", "artist:8", "artist: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); @@ -1409,21 +1433,30 @@ describe("server", () => { expect(musicService.login).toHaveBeenCalledWith(authToken); ids.forEach((id) => { - expect(musicLibrary.coverArt).toHaveBeenCalledWith( - id, - 180 - ); + expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 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 ids = ["artist:1", "artist:2", "artist:3", "artist:4", "artist:5", "artist:6", "artist:7", "artist:8", "artist:9", "artist:10", "artist: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); @@ -1448,17 +1481,14 @@ describe("server", () => { expect(musicService.login).toHaveBeenCalledWith(authToken); ids.forEach((id) => { - expect(musicLibrary.coverArt).toHaveBeenCalledWith( - id, - 180 - ); + expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 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 () => { @@ -1692,12 +1722,18 @@ describe("server", () => { expect(svg).toContain(`fill="brightpink"`); }); - function itShouldBeFestive(theme: string, date: string, id: string, color1: string, color2: string) { + function itShouldBeFestive( + theme: string, + date: string, + id: string, + color1: string, + color2: string + ) { it(`should return a ${theme} icon on ${date}`, async () => { const response = await request( server({ now: () => dayjs(date) }) ).get(`/icon/${type}/size/180`); - + expect(response.status).toEqual(200); const svg = Buffer.from(response.body).toString(); expect(svg).toContain(`id="${id}"`); @@ -1706,14 +1742,50 @@ describe("server", () => { }); } - itShouldBeFestive("christmas '22", "2022/12/25", "christmas", "red", "green") - itShouldBeFestive("christmas '23", "2023/12/25", "christmas", "red", "green") + itShouldBeFestive( + "christmas '22", + "2022/12/25", + "christmas", + "red", + "green" + ); + itShouldBeFestive( + "christmas '23", + "2023/12/25", + "christmas", + "red", + "green" + ); - itShouldBeFestive("halloween", "2022/10/31", "halloween", "black", "orange") - itShouldBeFestive("halloween", "2023/10/31", "halloween", "black", "orange") + itShouldBeFestive( + "halloween", + "2022/10/31", + "halloween", + "black", + "orange" + ); + itShouldBeFestive( + "halloween", + "2023/10/31", + "halloween", + "black", + "orange" + ); - itShouldBeFestive("cny '22", "2022/02/01", "yoTiger", "red", "yellow") - itShouldBeFestive("cny '23", "2023/01/22", "yoRabbit", "red", "yellow") + itShouldBeFestive( + "cny '22", + "2022/02/01", + "yoTiger", + "red", + "yellow" + ); + itShouldBeFestive( + "cny '23", + "2023/01/22", + "yoRabbit", + "red", + "yellow" + ); }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index 272d872..6a16ff9 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -2471,12 +2471,9 @@ describe("api", () => { getMediaURIResult: bonobUrl .append({ pathname: `/stream/track/${trackId}`, + searchParams: { "bat": accessToken } }) .href(), - httpHeaders: { - header: "bat", - value: accessToken, - }, }); expect(musicService.login).toHaveBeenCalledWith(authToken); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 205ddd3..7d0dcc5 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -184,7 +184,8 @@ const getArtistInfoXml = ( `; -const maybeIdFromCoverArtId = (coverArt: string | undefined) => coverArt ? splitCoverArtId(coverArt)[1] : ""; +const maybeIdFromCoverArtId = (coverArt: string | undefined) => + coverArt ? splitCoverArtId(coverArt)[1] : ""; const albumXml = ( artist: Artist, @@ -437,15 +438,21 @@ const PING_OK = ` { it("should split correctly", () => { - expect(splitCoverArtId("foo:bar")).toEqual(["foo", "bar"]) - expect(splitCoverArtId("foo:bar:car:jar")).toEqual(["foo", "bar:car:jar"]) + 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`) + 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` + ); }); }); @@ -1677,17 +1684,10 @@ describe("Subsonic", () => { // the artists have 5 albums in the getArtists endpoint const artist1 = anArtist({ - albums: [ - album1, - album2, - album3, - album4, - ], + albums: [album1, album2, album3, album4], }); const artist2 = anArtist({ - albums: [ - album5, - ], + albums: [album5], }); const artists = [artist1, artist2]; @@ -1713,7 +1713,7 @@ describe("Subsonic", () => { ) ); }); - + it("should return the page of albums, updating the total to be accurate", async () => { const q: AlbumQuery = { _index: 0, @@ -1727,12 +1727,7 @@ describe("Subsonic", () => { .then((it) => it.albums(q)); expect(result).toEqual({ - results: [ - album1, - album2, - album3, - album5, - ], + results: [album1, album2, album3, album5], total: 4, }); @@ -1741,15 +1736,18 @@ describe("Subsonic", () => { headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParams, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - }); + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParams, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); }); }); @@ -1775,7 +1773,7 @@ describe("Subsonic", () => { ) ); }); - + it("should filter out the pre-fetched albums", async () => { const q: AlbumQuery = { _index: 0, @@ -1789,10 +1787,7 @@ describe("Subsonic", () => { .then((it) => it.albums(q)); expect(result).toEqual({ - results: [ - album1, - album2, - ], + results: [album1, album2], total: 4, }); @@ -1801,17 +1796,20 @@ describe("Subsonic", () => { headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParams, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - }); + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParams, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); }); - }); + }); describe("when the query is for the last page only", () => { beforeEach(() => { @@ -1834,7 +1832,7 @@ describe("Subsonic", () => { ) ); }); - + it("should return the last page of albums, updating the total to be accurate", async () => { const q: AlbumQuery = { _index: 2, @@ -1848,10 +1846,7 @@ describe("Subsonic", () => { .then((it) => it.albums(q)); expect(result).toEqual({ - results: [ - album3, - album5, - ], + results: [album3, album5], total: 4, }); @@ -1860,17 +1855,20 @@ describe("Subsonic", () => { headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParams, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - }); + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParams, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); }); - }); + }); }); describe("when the number of albums returned from getAlbums is more than the number of albums in the getArtists endpoint", () => { @@ -1879,11 +1877,15 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]))) + Promise.resolve( + ok( + getArtistsXml([ + // artist1 has lost 2 albums on the getArtists end point + { ...artist1, albums: [album1, album2] }, + artist2, + ]) + ) + ) ) .mockImplementationOnce(() => Promise.resolve( @@ -1899,7 +1901,7 @@ describe("Subsonic", () => { ) ); }); - + it("should return the page of albums, updating the total to be accurate", async () => { const q: AlbumQuery = { _index: 0, @@ -1913,13 +1915,7 @@ describe("Subsonic", () => { .then((it) => it.albums(q)); expect(result).toEqual({ - results: [ - album1, - album2, - album3, - album4, - album5, - ], + results: [album1, album2, album3, album4, album5], total: 5, }); @@ -1928,15 +1924,18 @@ describe("Subsonic", () => { headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParams, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - }); + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParams, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); }); }); @@ -1945,12 +1944,16 @@ describe("Subsonic", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]))) - ) + Promise.resolve( + ok( + getArtistsXml([ + // artist1 has lost 2 albums on the getArtists end point + { ...artist1, albums: [album1, album2] }, + artist2, + ]) + ) + ) + ) .mockImplementationOnce(() => Promise.resolve( ok( @@ -1965,7 +1968,7 @@ describe("Subsonic", () => { ) ); }); - + it("should filter out the pre-fetched albums", async () => { const q: AlbumQuery = { _index: 0, @@ -1979,10 +1982,7 @@ describe("Subsonic", () => { .then((it) => it.albums(q)); expect(result).toEqual({ - results: [ - album1, - album2, - ], + results: [album1, album2], total: 5, }); @@ -1991,29 +1991,36 @@ describe("Subsonic", () => { headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParams, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - }); + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParams, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); }); - }); + }); describe("when the query is for the last page only", () => { beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(getArtistsXml([ - // artist1 has lost 2 albums on the getArtists end point - { ...artist1, albums: [album1, album2] }, - artist2, - ]))) - ) + Promise.resolve( + ok( + getArtistsXml([ + // artist1 has lost 2 albums on the getArtists end point + { ...artist1, albums: [album1, album2] }, + artist2, + ]) + ) + ) + ) .mockImplementationOnce(() => Promise.resolve( ok( @@ -2040,11 +2047,7 @@ describe("Subsonic", () => { .then((it) => it.albums(q)); expect(result).toEqual({ - results: [ - album3, - album4, - album5, - ], + results: [album3, album4, album5], total: 5, }); @@ -2053,19 +2056,21 @@ describe("Subsonic", () => { headers, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { - params: asURLSearchParams({ - ...authParams, - type: "alphabeticalByArtist", - size: 500, - offset: q._index, - }), - headers, - }); + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getAlbumList2`, + { + params: asURLSearchParams({ + ...authParams, + type: "alphabeticalByArtist", + size: 500, + offset: q._index, + }), + headers, + } + ); }); - }); + }); }); - }); }); @@ -2735,6 +2740,25 @@ describe("Subsonic", () => { }); }); }); + + describe("when an unexpected error occurs", () => { + it("should return undefined", async () => { + const coverArtId = "someCoverArt"; + const size = 1879; + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => Promise.reject("BOOOM")); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.coverArt(`coverArt:${coverArtId}`, size)); + + expect(result).toBeUndefined(); + }); + }); }); describe("fetching artist art", () => { @@ -2798,6 +2822,38 @@ describe("Subsonic", () => { responseType: "arraybuffer", }); }); + + describe("and an error occurs fetching the uri", () => { + it("should return undefined", async () => { + const artistId = "someArtist123"; + + const images: Images = { + small: "http://example.com/images/small", + medium: "http://example.com/images/medium", + large: "http://example.com/images/large", + }; + + const artist = anArtist({ id: artistId, image: images }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistXml(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoXml(artist))) + ) + .mockImplementationOnce(() => Promise.reject("BOOOM")); + + 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).toBeUndefined(); + }); + }); }); describe("when the artist doest not have a valid artist uri", () => { @@ -2818,151 +2874,17 @@ describe("Subsonic", () => { data: Buffer.from("the image", "ascii"), }; - 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, - albums: [album1, album2], - image: images, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParams, - id: artistId, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParams, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, - id: splitCoverArtId(album1.coverArt!)[1], - }), - headers, - responseType: "arraybuffer", - } - ); - }); - }); - - describe("the first album does not have coverArt", () => { - it("should fetch the coverArt from the first album with coverArt", async () => { - const album1 = anAlbum({ coverArt: undefined }); - const album2 = anAlbum({ coverArt: `coverArt:album2CoverArt` }); - - const artist = anArtist({ - id: artistId, - albums: [album1, album2], - image: images, - }); - - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistXml(artist))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(getArtistInfoXml(artist))) - ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.coverArt(`artist:${artistId}`)); - - expect(result).toEqual({ - contentType: streamResponse.headers["content-type"], - data: streamResponse.data, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { - params: asURLSearchParams({ - ...authParams, - id: artistId, - }), - headers, - }); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getArtistInfo2`, - { - params: asURLSearchParams({ - ...authParams, - id: artistId, - count: 50, - includeNotPresent: true, - }), - headers, - } - ); - - expect(axios.get).toHaveBeenCalledWith( - `${url}/rest/getCoverArt`, - { - params: asURLSearchParams({ - ...authParams, - id: splitCoverArtId(album2.coverArt!)[1], - }), - headers, - responseType: "arraybuffer", - } - ); - }); - }); - describe("no albums have coverArt", () => { it("should return undefined", async () => { const album1 = anAlbum({ coverArt: undefined }); const album2 = anAlbum({ coverArt: undefined }); - + const artist = anArtist({ id: artistId, albums: [album1, album2], image: images, }); - + mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => @@ -2971,24 +2893,29 @@ describe("Subsonic", () => { .mockImplementationOnce(() => Promise.resolve(ok(getArtistInfoXml(artist))) ) - .mockImplementationOnce(() => Promise.resolve(streamResponse)); - + .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/getArtist`, + { + params: asURLSearchParams({ + ...authParams, + id: artistId, + }), + headers, + } + ); + expect(axios.get).toHaveBeenCalledWith( `${url}/rest/getArtistInfo2`, { @@ -3001,7 +2928,194 @@ describe("Subsonic", () => { headers, } ); + }); + }); + + describe("some albums have coverArt", () => { + 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, + albums: [album1, album2], + image: images, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistXml(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoXml(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.coverArt(`artist:${artistId}`)); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getArtist`, + { + params: asURLSearchParams({ + ...authParams, + id: artistId, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getArtistInfo2`, + { + params: asURLSearchParams({ + ...authParams, + id: artistId, + count: 50, + includeNotPresent: true, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getCoverArt`, + { + params: asURLSearchParams({ + ...authParams, + id: splitCoverArtId(album1.coverArt!)[1], + }), + headers, + responseType: "arraybuffer", + } + ); }); + }); + + describe("the first album does not have coverArt", () => { + it("should fetch the coverArt from the first album with coverArt", async () => { + const album1 = anAlbum({ coverArt: undefined }); + const album2 = anAlbum({ + coverArt: `coverArt:album2CoverArt`, + }); + + const artist = anArtist({ + id: artistId, + albums: [album1, album2], + image: images, + }); + + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistXml(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(getArtistInfoXml(artist))) + ) + .mockImplementationOnce(() => + Promise.resolve(streamResponse) + ); + + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.coverArt(`artist:${artistId}`)); + + expect(result).toEqual({ + contentType: streamResponse.headers["content-type"], + data: streamResponse.data, + }); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getArtist`, + { + params: asURLSearchParams({ + ...authParams, + id: artistId, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getArtistInfo2`, + { + params: asURLSearchParams({ + ...authParams, + id: artistId, + count: 50, + includeNotPresent: true, + }), + headers, + } + ); + + expect(axios.get).toHaveBeenCalledWith( + `${url}/rest/getCoverArt`, + { + params: asURLSearchParams({ + ...authParams, + id: splitCoverArtId(album2.coverArt!)[1], + }), + headers, + responseType: "arraybuffer", + } + ); + }); + }); + + describe("an unexpected error occurs getting the albums 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, + 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.reject("BOOOM")); + + 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).toBeUndefined(); + }); + }); }); }); @@ -3318,8 +3432,14 @@ describe("Subsonic", () => { data: Buffer.from("the image", "ascii"), }; - const album1 = anAlbum({ id: "album1Id", coverArt: "coverArt:album1CoverArt" }); - const album2 = anAlbum({ id: "album2Id", coverArt: "coverArt:album2CoverArt" }); + const album1 = anAlbum({ + id: "album1Id", + coverArt: "coverArt:album1CoverArt", + }); + const album2 = anAlbum({ + id: "album2Id", + coverArt: "coverArt:album2CoverArt", + }); const artist = anArtist({ id: artistId, @@ -4046,23 +4166,31 @@ describe("Subsonic", () => { const id = uuid(); const name = "Great Playlist"; const artist1 = anArtist(); - const album1 = anAlbum({ artistId: artist1.id, artistName: artist1.name, genre: POP }); + const album1 = anAlbum({ + artistId: artist1.id, + artistName: artist1.name, + genre: POP, + }); const track1 = aTrack({ genre: POP, number: 66, coverArt: album1.coverArt, artist: artistToArtistSummary(artist1), - album: albumToAlbumSummary(album1) + album: albumToAlbumSummary(album1), }); const artist2 = anArtist(); - const album2 = anAlbum({ artistId: artist2.id, artistName: artist2.name, genre: ROCK }); + const album2 = anAlbum({ + artistId: artist2.id, + artistName: artist2.name, + genre: ROCK, + }); const track2 = aTrack({ genre: ROCK, number: 77, coverArt: album2.coverArt, artist: artistToArtistSummary(artist2), - album: albumToAlbumSummary(album2) + album: albumToAlbumSummary(album2), }); mockGET @@ -4487,7 +4615,7 @@ describe("Subsonic", () => { const track1 = aTrack({ artist: artistToArtistSummary(artist), album: albumToAlbumSummary(album1), - genre: POP + genre: POP, }); const track2 = aTrack({