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({