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)

This commit is contained in:
Simon J
2021-09-30 12:19:43 +10:00
committed by GitHub
parent fbb621c7c4
commit b6ba9c5a52
6 changed files with 688 additions and 476 deletions

View File

@@ -293,10 +293,12 @@ function server(
const trace = uuid(); const trace = uuid();
logger.info( 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( const authToken = pipe(
req.header(BONOB_ACCESS_TOKEN_HEADER), req.query[BONOB_ACCESS_TOKEN_HEADER] as string,
O.fromNullable, O.fromNullable,
O.map((accessToken) => accessTokens.authTokenFor(accessToken)), O.map((accessToken) => accessTokens.authTokenFor(accessToken)),
O.getOrElseW(() => undefined) O.getOrElseW(() => undefined)
@@ -340,9 +342,9 @@ function server(
sendStream: boolean; sendStream: boolean;
}) => { }) => {
logger.info( logger.info(
`${trace} bnb-> /stream/track/${id}, status=${status}, headers=${JSON.stringify( `${trace} bnb-> ${
headers req.path
)}` }, status=${status}, headers=${JSON.stringify(headers)}`
); );
res.status(status); res.status(status);
Object.entries(headers) Object.entries(headers)

View File

@@ -18,7 +18,6 @@ import {
Track, Track,
} from "./music_service"; } from "./music_service";
import { AccessTokens } from "./access_tokens"; import { AccessTokens } from "./access_tokens";
import { BONOB_ACCESS_TOKEN_HEADER } from "./server";
import { Clock } from "./clock"; import { Clock } from "./clock";
import { URLBuilder } from "./url_builder"; import { URLBuilder } from "./url_builder";
import { asLANGs, I8N } from "./i8n"; import { asLANGs, I8N } from "./i8n";
@@ -404,14 +403,9 @@ function bindSmapiSoapServiceToExpress(
getMediaURIResult: bonobUrl getMediaURIResult: bonobUrl
.append({ .append({
pathname: `/stream/${type}/${typeId}`, pathname: `/stream/${type}/${typeId}`,
searchParams: { "bat": accessToken }
}) })
.href(), .href(),
httpHeaders: [
{
header: BONOB_ACCESS_TOKEN_HEADER,
value: accessToken,
},
],
})), })),
getMediaMetadata: async ( getMediaMetadata: async (
{ id }: { id: string }, { id }: { id: string },

View File

@@ -27,6 +27,7 @@ import axios, { AxiosRequestConfig } from "axios";
import { Encryption } from "./encryption"; import { Encryption } from "./encryption";
import randomString from "./random_string"; import randomString from "./random_string";
import { b64Encode, b64Decode } from "./b64"; import { b64Encode, b64Decode } from "./b64";
import logger from "./logger";
export const BROWSER_HEADERS = { export const BROWSER_HEADERS = {
accept: accept:
@@ -225,8 +226,8 @@ export function isError(
} }
export const splitCoverArtId = (coverArt: string): [string, string] => { export const splitCoverArtId = (coverArt: string): [string, string] => {
const parts = coverArt.split(":").filter(it => it.length > 0); const parts = coverArt.split(":").filter((it) => it.length > 0);
if(parts.length < 2) throw `'${coverArt}' is an invalid coverArt id'` if (parts.length < 2) throw `'${coverArt}' is an invalid coverArt id'`;
return [parts[0]!, parts.slice(1).join(":")]; return [parts[0]!, parts.slice(1).join(":")];
}; };
@@ -246,7 +247,8 @@ export type getAlbumListParams = {
export const MAX_ALBUM_LIST = 500; 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) => ({ const asTrack = (album: Album, song: song) => ({
id: song._id, id: song._id,
@@ -270,7 +272,7 @@ const asAlbum = (album: album) => ({
genre: maybeAsGenre(album._genre), genre: maybeAsGenre(album._genre),
artistId: album._artistId, artistId: album._artistId,
artistName: album._artist, artistName: album._artist,
coverArt: maybeAsCoverArt(album._coverArt) coverArt: maybeAsCoverArt(album._coverArt),
}); });
export const asGenre = (genreName: string) => ({ export const asGenre = (genreName: string) => ({
@@ -438,7 +440,7 @@ export class Subsonic implements MusicService {
genre: maybeAsGenre(album._genre), genre: maybeAsGenre(album._genre),
artistId: album._artistId, artistId: album._artistId,
artistName: album._artist, artistName: album._artist,
coverArt: maybeAsCoverArt(album._coverArt) coverArt: maybeAsCoverArt(album._coverArt),
})); }));
getArtist = ( getArtist = (
@@ -492,7 +494,7 @@ export class Subsonic implements MusicService {
genre: maybeAsGenre(album._genre), genre: maybeAsGenre(album._genre),
artistId: album._artistId, artistId: album._artistId,
artistName: album._artist, artistName: album._artist,
coverArt: maybeAsCoverArt(album._coverArt) coverArt: maybeAsCoverArt(album._coverArt),
})); }));
search3 = (credentials: Credentials, q: any) => search3 = (credentials: Credentials, q: any) =>
@@ -508,12 +510,12 @@ export class Subsonic implements MusicService {
})); }));
async login(token: string) { async login(token: string) {
const navidrome = this; const subsonic = this;
const credentials: Credentials = this.parseToken(token); const credentials: Credentials = this.parseToken(token);
const musicLibrary: MusicLibrary = { const musicLibrary: MusicLibrary = {
artists: (q: ArtistQuery): Promise<Result<ArtistSummary>> => artists: (q: ArtistQuery): Promise<Result<ArtistSummary>> =>
navidrome subsonic
.getArtists(credentials) .getArtists(credentials)
.then(slice2(q)) .then(slice2(q))
.then(([page, total]) => ({ .then(([page, total]) => ({
@@ -521,15 +523,15 @@ export class Subsonic implements MusicService {
results: page.map((it) => ({ id: it.id, name: it.name })), results: page.map((it) => ({ id: it.id, name: it.name })),
})), })),
artist: async (id: string): Promise<Artist> => artist: async (id: string): Promise<Artist> =>
navidrome.getArtistWithInfo(credentials, id), subsonic.getArtistWithInfo(credentials, id),
albums: async (q: AlbumQuery): Promise<Result<AlbumSummary>> => albums: async (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
Promise.all([ Promise.all([
navidrome subsonic
.getArtists(credentials) .getArtists(credentials)
.then((it) => .then((it) =>
_.inject(it, (total, artist) => total + artist.albumCount, 0) _.inject(it, (total, artist) => total + artist.albumCount, 0)
), ),
navidrome subsonic
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", { .getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList2", {
type: q.type, type: q.type,
...(q.genre ? { genre: b64Decode(q.genre) } : {}), ...(q.genre ? { genre: b64Decode(q.genre) } : {}),
@@ -537,15 +539,14 @@ export class Subsonic implements MusicService {
offset: q._index, offset: q._index,
}) })
.then((response) => response.albumList2.album || []) .then((response) => response.albumList2.album || [])
.then(navidrome.toAlbumSummary), .then(subsonic.toAlbumSummary),
]).then(([total, albums]) => ({ ]).then(([total, albums]) => ({
results: albums.slice(0, q._count), results: albums.slice(0, q._count),
total: albums.length == 500 ? total : q._index + albums.length, total: albums.length == 500 ? total : q._index + albums.length,
})), })),
album: (id: string): Promise<Album> => album: (id: string): Promise<Album> => subsonic.getAlbum(credentials, id),
navidrome.getAlbum(credentials, id),
genres: () => genres: () =>
navidrome subsonic
.getJSON<GetGenresResponse>(credentials, "/rest/getGenres") .getJSON<GetGenresResponse>(credentials, "/rest/getGenres")
.then((it) => .then((it) =>
pipe( pipe(
@@ -557,7 +558,7 @@ export class Subsonic implements MusicService {
) )
), ),
tracks: (albumId: string) => tracks: (albumId: string) =>
navidrome subsonic
.getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", { .getJSON<GetAlbumResponse>(credentials, "/rest/getAlbum", {
id: albumId, id: albumId,
}) })
@@ -565,7 +566,7 @@ export class Subsonic implements MusicService {
.then((album) => .then((album) =>
(album.song || []).map((song) => asTrack(asAlbum(album), song)) (album.song || []).map((song) => asTrack(asAlbum(album), song))
), ),
track: (trackId: string) => navidrome.getTrack(credentials, trackId), track: (trackId: string) => subsonic.getTrack(credentials, trackId),
stream: async ({ stream: async ({
trackId, trackId,
range, range,
@@ -573,8 +574,8 @@ export class Subsonic implements MusicService {
trackId: string; trackId: string;
range: string | undefined; range: string | undefined;
}) => }) =>
navidrome.getTrack(credentials, trackId).then((track) => subsonic.getTrack(credentials, trackId).then((track) =>
navidrome subsonic
.get( .get(
credentials, credentials,
`/rest/stream`, `/rest/stream`,
@@ -611,51 +612,69 @@ export class Subsonic implements MusicService {
coverArt: async (coverArt: string, size?: number) => { coverArt: async (coverArt: string, size?: number) => {
const [type, id] = splitCoverArtId(coverArt); const [type, id] = splitCoverArtId(coverArt);
if (type == "coverArt") { if (type == "coverArt") {
return navidrome.getCoverArt(credentials, id, size).then((res) => ({ return subsonic
contentType: res.headers["content-type"], .getCoverArt(credentials, id, size)
data: Buffer.from(res.data, "binary"), .then((res) => ({
})); contentType: res.headers["content-type"],
} else { data: Buffer.from(res.data, "binary"),
return navidrome.getArtistWithInfo(credentials, id).then((artist) => { }))
const albumsWithCoverArt = artist.albums.filter(it => it.coverArt); .catch((e) => {
if (artist.image.large) { logger.error(`Failed getting coverArt ${coverArt}: ${e}`);
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 undefined; 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) => scrobble: async (id: string) =>
navidrome subsonic
.get(credentials, `/rest/scrobble`, { .get(credentials, `/rest/scrobble`, {
id, id,
submission: true, submission: true,
@@ -663,7 +682,7 @@ export class Subsonic implements MusicService {
.then((_) => true) .then((_) => true)
.catch(() => false), .catch(() => false),
nowPlaying: async (id: string) => nowPlaying: async (id: string) =>
navidrome subsonic
.get(credentials, `/rest/scrobble`, { .get(credentials, `/rest/scrobble`, {
id, id,
submission: false, submission: false,
@@ -671,7 +690,7 @@ export class Subsonic implements MusicService {
.then((_) => true) .then((_) => true)
.catch(() => false), .catch(() => false),
searchArtists: async (query: string) => searchArtists: async (query: string) =>
navidrome subsonic
.search3(credentials, { query, artistCount: 20 }) .search3(credentials, { query, artistCount: 20 })
.then(({ artists }) => .then(({ artists }) =>
artists.map((artist) => ({ artists.map((artist) => ({
@@ -680,26 +699,26 @@ export class Subsonic implements MusicService {
})) }))
), ),
searchAlbums: async (query: string) => searchAlbums: async (query: string) =>
navidrome subsonic
.search3(credentials, { query, albumCount: 20 }) .search3(credentials, { query, albumCount: 20 })
.then(({ albums }) => navidrome.toAlbumSummary(albums)), .then(({ albums }) => subsonic.toAlbumSummary(albums)),
searchTracks: async (query: string) => searchTracks: async (query: string) =>
navidrome subsonic
.search3(credentials, { query, songCount: 20 }) .search3(credentials, { query, songCount: 20 })
.then(({ songs }) => .then(({ songs }) =>
Promise.all( Promise.all(
songs.map((it) => navidrome.getTrack(credentials, it._id)) songs.map((it) => subsonic.getTrack(credentials, it._id))
) )
), ),
playlists: async () => playlists: async () =>
navidrome subsonic
.getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists") .getJSON<GetPlaylistsResponse>(credentials, "/rest/getPlaylists")
.then((it) => it.playlists.playlist || []) .then((it) => it.playlists.playlist || [])
.then((playlists) => .then((playlists) =>
playlists.map((it) => ({ id: it._id, name: it._name })) playlists.map((it) => ({ id: it._id, name: it._name }))
), ),
playlist: async (id: string) => playlist: async (id: string) =>
navidrome subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", { .getJSON<GetPlaylistResponse>(credentials, "/rest/getPlaylist", {
id, id,
}) })
@@ -724,7 +743,7 @@ export class Subsonic implements MusicService {
genre: maybeAsGenre(entry._genre), genre: maybeAsGenre(entry._genre),
artistName: entry._artist, artistName: entry._artist,
artistId: entry._artistId, artistId: entry._artistId,
coverArt: maybeAsCoverArt(entry._coverArt) coverArt: maybeAsCoverArt(entry._coverArt),
}, },
artist: { artist: {
id: entry._artistId, id: entry._artistId,
@@ -734,34 +753,34 @@ export class Subsonic implements MusicService {
}; };
}), }),
createPlaylist: async (name: string) => createPlaylist: async (name: string) =>
navidrome subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/createPlaylist", { .getJSON<GetPlaylistResponse>(credentials, "/rest/createPlaylist", {
name, name,
}) })
.then((it) => it.playlist) .then((it) => it.playlist)
.then((it) => ({ id: it._id, name: it._name })), .then((it) => ({ id: it._id, name: it._name })),
deletePlaylist: async (id: string) => deletePlaylist: async (id: string) =>
navidrome subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", { .getJSON<GetPlaylistResponse>(credentials, "/rest/deletePlaylist", {
id, id,
}) })
.then((_) => true), .then((_) => true),
addToPlaylist: async (playlistId: string, trackId: string) => addToPlaylist: async (playlistId: string, trackId: string) =>
navidrome subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", { .getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", {
playlistId, playlistId,
songIdToAdd: trackId, songIdToAdd: trackId,
}) })
.then((_) => true), .then((_) => true),
removeFromPlaylist: async (playlistId: string, indicies: number[]) => removeFromPlaylist: async (playlistId: string, indicies: number[]) =>
navidrome subsonic
.getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", { .getJSON<GetPlaylistResponse>(credentials, "/rest/updatePlaylist", {
playlistId, playlistId,
songIndexToRemove: indicies, songIndexToRemove: indicies,
}) })
.then((_) => true), .then((_) => true),
similarSongs: async (id: string) => similarSongs: async (id: string) =>
navidrome subsonic
.getJSON<GetSimilarSongsResponse>( .getJSON<GetSimilarSongsResponse>(
credentials, credentials,
"/rest/getSimilarSongs2", "/rest/getSimilarSongs2",
@@ -771,15 +790,15 @@ export class Subsonic implements MusicService {
.then((songs) => .then((songs) =>
Promise.all( Promise.all(
songs.map((song) => songs.map((song) =>
navidrome subsonic
.getAlbum(credentials, song._albumId) .getAlbum(credentials, song._albumId)
.then((album) => asTrack(album, song)) .then((album) => asTrack(album, song))
) )
) )
), ),
topSongs: async (artistId: string) => topSongs: async (artistId: string) =>
navidrome.getArtist(credentials, artistId).then(({ name }) => subsonic.getArtist(credentials, artistId).then(({ name }) =>
navidrome subsonic
.getJSON<GetTopSongsResponse>(credentials, "/rest/getTopSongs", { .getJSON<GetTopSongsResponse>(credentials, "/rest/getTopSongs", {
artist: name, artist: name,
count: 50, count: 50,
@@ -788,7 +807,7 @@ export class Subsonic implements MusicService {
.then((songs) => .then((songs) =>
Promise.all( Promise.all(
songs.map((song) => songs.map((song) =>
navidrome subsonic
.getAlbum(credentials, song._albumId) .getAlbum(credentials, song._albumId)
.then((album) => asTrack(album, song)) .then((album) => asTrack(album, song))
) )

View File

@@ -186,7 +186,7 @@ describe("server", () => {
bonobUrl, bonobUrl,
new InMemoryMusicService(), new InMemoryMusicService(),
{ {
version: "v123.456" version: "v123.456",
} }
); );
@@ -233,8 +233,7 @@ describe("server", () => {
const fakeSonos: Sonos = { const fakeSonos: Sonos = {
devices: () => Promise.resolve([]), devices: () => Promise.resolve([]),
services: () => services: () => Promise.resolve([]),
Promise.resolve([]),
remove: () => Promise.resolve(false), remove: () => Promise.resolve(false),
register: () => Promise.resolve(false), register: () => Promise.resolve(false),
}; };
@@ -397,7 +396,8 @@ describe("server", () => {
const fakeSonos: Sonos = { const fakeSonos: Sonos = {
devices: () => Promise.resolve([device1, device2]), devices: () => Promise.resolve([device1, device2]),
services: () => Promise.resolve([service1, service2, bonobService]), services: () =>
Promise.resolve([service1, service2, bonobService]),
remove: () => Promise.resolve(false), remove: () => Promise.resolve(false),
register: () => Promise.resolve(false), register: () => Promise.resolve(false),
}; };
@@ -755,13 +755,14 @@ describe("server", () => {
it("should return a 401", async () => { it("should return a 401", async () => {
now = now.add(1, "day"); now = now.add(1, "day");
const res = await request(server) const res = await request(server).head(
.head( bonobUrl
bonobUrl .append({
.append({ pathname: `/stream/track/${trackId}` }) pathname: `/stream/track/${trackId}`,
.path() searchParams: { bat: accessToken },
) })
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .path()
);
expect(res.status).toEqual(401); expect(res.status).toEqual(401);
}); });
@@ -786,10 +787,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.head( .head(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}` }) .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path() .path()
) );
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(trackStream.status); expect(res.status).toEqual(trackStream.status);
expect(res.headers["content-type"]).toEqual( expect(res.headers["content-type"]).toEqual(
@@ -812,8 +812,10 @@ describe("server", () => {
musicLibrary.stream.mockResolvedValue(trackStream); musicLibrary.stream.mockResolvedValue(trackStream);
const res = await request(server) const res = await request(server)
.head(`/stream/track/${trackId}`) .head(bonobUrl
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path()
);
expect(res.status).toEqual(404); expect(res.status).toEqual(404);
expect(res.body).toEqual({}); expect(res.body).toEqual({});
@@ -840,10 +842,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}` }) .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path() .path()
) );
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(401); expect(res.status).toEqual(401);
}); });
@@ -863,10 +864,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}` }) .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path() .path()
) );
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(404); expect(res.status).toEqual(404);
@@ -894,10 +894,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}` }) .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path() .path()
) );
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(stream.status);
expect(res.headers["content-type"]).toEqual( expect(res.headers["content-type"]).toEqual(
@@ -933,10 +932,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}` }) .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path() .path()
) );
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(stream.status);
expect(res.headers["content-type"]).toEqual( expect(res.headers["content-type"]).toEqual(
@@ -970,10 +968,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}` }) .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path() .path()
) );
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(stream.status);
expect(res.header["content-type"]).toEqual( expect(res.header["content-type"]).toEqual(
@@ -1008,10 +1005,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}` }) .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path() .path()
) );
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(stream.status);
expect(res.header["content-type"]).toEqual( expect(res.header["content-type"]).toEqual(
@@ -1051,10 +1047,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}` }) .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path() .path()
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
.set("Range", requestedRange); .set("Range", requestedRange);
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(stream.status);
@@ -1093,10 +1088,9 @@ describe("server", () => {
const res = await request(server) const res = await request(server)
.get( .get(
bonobUrl bonobUrl
.append({ pathname: `/stream/track/${trackId}` }) .append({ pathname: `/stream/track/${trackId}`, searchParams: { bat: accessToken } })
.path() .path()
) )
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken)
.set("Range", "4000-5000"); .set("Range", "4000-5000");
expect(res.status).toEqual(stream.status); expect(res.status).toEqual(stream.status);
@@ -1242,11 +1236,24 @@ describe("server", () => {
}); });
describe("fetching multiple images as a collage", () => { 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", () => { describe("fetching a collage of 4 when all are available", () => {
it("should return the image and a 200", async () => { 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); musicService.login.mockResolvedValue(musicLibrary);
@@ -1258,10 +1265,11 @@ describe("server", () => {
); );
}); });
const res = await request(server) const res = await request(server)
.get( .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); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
@@ -1270,10 +1278,7 @@ describe("server", () => {
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);
ids.forEach((id) => { ids.forEach((id) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith( expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 200);
id,
200
);
}); });
const image = await Image.load(res.body); const image = await Image.load(res.body);
@@ -1294,7 +1299,7 @@ describe("server", () => {
musicLibrary.coverArt.mockResolvedValueOnce( musicLibrary.coverArt.mockResolvedValueOnce(
coverArtResponse({ coverArtResponse({
data: png, data: png,
contentType: "image/some-mime-type" contentType: "image/some-mime-type",
}) })
); );
@@ -1307,7 +1312,9 @@ describe("server", () => {
.set(BONOB_ACCESS_TOKEN_HEADER, accessToken); .set(BONOB_ACCESS_TOKEN_HEADER, accessToken);
expect(res.status).toEqual(200); 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"
);
}); });
}); });
@@ -1335,7 +1342,17 @@ describe("server", () => {
describe("fetching a collage of 9 when all are available", () => { describe("fetching a collage of 9 when all are available", () => {
it("should return the image and a 200", async () => { 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); musicService.login.mockResolvedValue(musicLibrary);
@@ -1360,10 +1377,7 @@ describe("server", () => {
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);
ids.forEach((id) => { ids.forEach((id) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith( expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 180);
id,
180
);
}); });
const image = await Image.load(res.body); const image = await Image.load(res.body);
@@ -1374,7 +1388,17 @@ describe("server", () => {
describe("fetching a collage of 9 when only 2 are available", () => { describe("fetching a collage of 9 when only 2 are available", () => {
it("should still return an image and a 200", async () => { 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); musicService.login.mockResolvedValue(musicLibrary);
@@ -1409,10 +1433,7 @@ describe("server", () => {
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);
ids.forEach((id) => { ids.forEach((id) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith( expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 180);
id,
180
);
}); });
const image = await Image.load(res.body); const image = await Image.load(res.body);
@@ -1423,7 +1444,19 @@ describe("server", () => {
describe("fetching a collage of 11", () => { describe("fetching a collage of 11", () => {
it("should still return an image and a 200, though will only display 9", async () => { 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); musicService.login.mockResolvedValue(musicLibrary);
@@ -1448,10 +1481,7 @@ describe("server", () => {
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);
ids.forEach((id) => { ids.forEach((id) => {
expect(musicLibrary.coverArt).toHaveBeenCalledWith( expect(musicLibrary.coverArt).toHaveBeenCalledWith(id, 180);
id,
180
);
}); });
const image = await Image.load(res.body); const image = await Image.load(res.body);
@@ -1692,7 +1722,13 @@ describe("server", () => {
expect(svg).toContain(`fill="brightpink"`); 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 () => { it(`should return a ${theme} icon on ${date}`, async () => {
const response = await request( const response = await request(
server({ now: () => dayjs(date) }) server({ now: () => dayjs(date) })
@@ -1706,14 +1742,50 @@ describe("server", () => {
}); });
} }
itShouldBeFestive("christmas '22", "2022/12/25", "christmas", "red", "green") itShouldBeFestive(
itShouldBeFestive("christmas '23", "2023/12/25", "christmas", "red", "green") "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(
itShouldBeFestive("halloween", "2023/10/31", "halloween", "black", "orange") "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(
itShouldBeFestive("cny '23", "2023/01/22", "yoRabbit", "red", "yellow") "cny '22",
"2022/02/01",
"yoTiger",
"red",
"yellow"
);
itShouldBeFestive(
"cny '23",
"2023/01/22",
"yoRabbit",
"red",
"yellow"
);
}); });
}); });
}); });

View File

@@ -2471,12 +2471,9 @@ describe("api", () => {
getMediaURIResult: bonobUrl getMediaURIResult: bonobUrl
.append({ .append({
pathname: `/stream/track/${trackId}`, pathname: `/stream/track/${trackId}`,
searchParams: { "bat": accessToken }
}) })
.href(), .href(),
httpHeaders: {
header: "bat",
value: accessToken,
},
}); });
expect(musicService.login).toHaveBeenCalledWith(authToken); expect(musicService.login).toHaveBeenCalledWith(authToken);

View File

@@ -184,7 +184,8 @@ const getArtistInfoXml = (
</artistInfo2> </artistInfo2>
</subsonic-response>`; </subsonic-response>`;
const maybeIdFromCoverArtId = (coverArt: string | undefined) => coverArt ? splitCoverArtId(coverArt)[1] : ""; const maybeIdFromCoverArtId = (coverArt: string | undefined) =>
coverArt ? splitCoverArtId(coverArt)[1] : "";
const albumXml = ( const albumXml = (
artist: Artist, artist: Artist,
@@ -437,15 +438,21 @@ const PING_OK = `<subsonic-response xmlns="http://subsonic.org/restapi" status="
describe("splitCoverArtId", () => { describe("splitCoverArtId", () => {
it("should split correctly", () => { it("should split correctly", () => {
expect(splitCoverArtId("foo:bar")).toEqual(["foo", "bar"]) expect(splitCoverArtId("foo:bar")).toEqual(["foo", "bar"]);
expect(splitCoverArtId("foo:bar:car:jar")).toEqual(["foo", "bar:car:jar"]) expect(splitCoverArtId("foo:bar:car:jar")).toEqual(["foo", "bar:car:jar"]);
}); });
it("should blow up when the id is invalid", () => { it("should blow up when the id is invalid", () => {
expect(() => splitCoverArtId("")).toThrow(`'' 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(
expect(() => splitCoverArtId("foo:")).toThrow(`'foo:' is an invalid coverArt id`) `'foo:' is an invalid coverArt id`
expect(() => splitCoverArtId(":dog")).toThrow(`':dog' 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 // the artists have 5 albums in the getArtists endpoint
const artist1 = anArtist({ const artist1 = anArtist({
albums: [ albums: [album1, album2, album3, album4],
album1,
album2,
album3,
album4,
],
}); });
const artist2 = anArtist({ const artist2 = anArtist({
albums: [ albums: [album5],
album5,
],
}); });
const artists = [artist1, artist2]; const artists = [artist1, artist2];
@@ -1727,12 +1727,7 @@ describe("Subsonic", () => {
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
results: [ results: [album1, album2, album3, album5],
album1,
album2,
album3,
album5,
],
total: 4, total: 4,
}); });
@@ -1741,15 +1736,18 @@ describe("Subsonic", () => {
headers, headers,
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { expect(axios.get).toHaveBeenCalledWith(
params: asURLSearchParams({ `${url}/rest/getAlbumList2`,
...authParams, {
type: "alphabeticalByArtist", params: asURLSearchParams({
size: 500, ...authParams,
offset: q._index, type: "alphabeticalByArtist",
}), size: 500,
headers, offset: q._index,
}); }),
headers,
}
);
}); });
}); });
@@ -1789,10 +1787,7 @@ describe("Subsonic", () => {
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
results: [ results: [album1, album2],
album1,
album2,
],
total: 4, total: 4,
}); });
@@ -1801,15 +1796,18 @@ describe("Subsonic", () => {
headers, headers,
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { expect(axios.get).toHaveBeenCalledWith(
params: asURLSearchParams({ `${url}/rest/getAlbumList2`,
...authParams, {
type: "alphabeticalByArtist", params: asURLSearchParams({
size: 500, ...authParams,
offset: q._index, type: "alphabeticalByArtist",
}), size: 500,
headers, offset: q._index,
}); }),
headers,
}
);
}); });
}); });
@@ -1848,10 +1846,7 @@ describe("Subsonic", () => {
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
results: [ results: [album3, album5],
album3,
album5,
],
total: 4, total: 4,
}); });
@@ -1860,15 +1855,18 @@ describe("Subsonic", () => {
headers, headers,
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { expect(axios.get).toHaveBeenCalledWith(
params: asURLSearchParams({ `${url}/rest/getAlbumList2`,
...authParams, {
type: "alphabeticalByArtist", params: asURLSearchParams({
size: 500, ...authParams,
offset: q._index, type: "alphabeticalByArtist",
}), size: 500,
headers, offset: q._index,
}); }),
headers,
}
);
}); });
}); });
}); });
@@ -1879,11 +1877,15 @@ describe("Subsonic", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getArtistsXml([ Promise.resolve(
// artist1 has lost 2 albums on the getArtists end point ok(
{ ...artist1, albums: [album1, album2] }, getArtistsXml([
artist2, // artist1 has lost 2 albums on the getArtists end point
]))) { ...artist1, albums: [album1, album2] },
artist2,
])
)
)
) )
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve( Promise.resolve(
@@ -1913,13 +1915,7 @@ describe("Subsonic", () => {
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
results: [ results: [album1, album2, album3, album4, album5],
album1,
album2,
album3,
album4,
album5,
],
total: 5, total: 5,
}); });
@@ -1928,15 +1924,18 @@ describe("Subsonic", () => {
headers, headers,
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { expect(axios.get).toHaveBeenCalledWith(
params: asURLSearchParams({ `${url}/rest/getAlbumList2`,
...authParams, {
type: "alphabeticalByArtist", params: asURLSearchParams({
size: 500, ...authParams,
offset: q._index, type: "alphabeticalByArtist",
}), size: 500,
headers, offset: q._index,
}); }),
headers,
}
);
}); });
}); });
@@ -1945,12 +1944,16 @@ describe("Subsonic", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getArtistsXml([ Promise.resolve(
// artist1 has lost 2 albums on the getArtists end point ok(
{ ...artist1, albums: [album1, album2] }, getArtistsXml([
artist2, // artist1 has lost 2 albums on the getArtists end point
]))) { ...artist1, albums: [album1, album2] },
) artist2,
])
)
)
)
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve( Promise.resolve(
ok( ok(
@@ -1979,10 +1982,7 @@ describe("Subsonic", () => {
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
results: [ results: [album1, album2],
album1,
album2,
],
total: 5, total: 5,
}); });
@@ -1991,15 +1991,18 @@ describe("Subsonic", () => {
headers, headers,
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { expect(axios.get).toHaveBeenCalledWith(
params: asURLSearchParams({ `${url}/rest/getAlbumList2`,
...authParams, {
type: "alphabeticalByArtist", params: asURLSearchParams({
size: 500, ...authParams,
offset: q._index, type: "alphabeticalByArtist",
}), size: 500,
headers, offset: q._index,
}); }),
headers,
}
);
}); });
}); });
@@ -2008,12 +2011,16 @@ describe("Subsonic", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getArtistsXml([ Promise.resolve(
// artist1 has lost 2 albums on the getArtists end point ok(
{ ...artist1, albums: [album1, album2] }, getArtistsXml([
artist2, // artist1 has lost 2 albums on the getArtists end point
]))) { ...artist1, albums: [album1, album2] },
) artist2,
])
)
)
)
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve( Promise.resolve(
ok( ok(
@@ -2040,11 +2047,7 @@ describe("Subsonic", () => {
.then((it) => it.albums(q)); .then((it) => it.albums(q));
expect(result).toEqual({ expect(result).toEqual({
results: [ results: [album3, album4, album5],
album3,
album4,
album5,
],
total: 5, total: 5,
}); });
@@ -2053,19 +2056,21 @@ describe("Subsonic", () => {
headers, headers,
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList2`, { expect(axios.get).toHaveBeenCalledWith(
params: asURLSearchParams({ `${url}/rest/getAlbumList2`,
...authParams, {
type: "alphabeticalByArtist", params: asURLSearchParams({
size: 500, ...authParams,
offset: q._index, type: "alphabeticalByArtist",
}), size: 500,
headers, 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", () => { describe("fetching artist art", () => {
@@ -2798,6 +2822,38 @@ describe("Subsonic", () => {
responseType: "arraybuffer", 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", () => { describe("when the artist doest not have a valid artist uri", () => {
@@ -2818,140 +2874,6 @@ describe("Subsonic", () => {
data: Buffer.from("the image", "ascii"), 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", () => { describe("no albums have coverArt", () => {
it("should return undefined", async () => { it("should return undefined", async () => {
const album1 = anAlbum({ coverArt: undefined }); const album1 = anAlbum({ coverArt: undefined });
@@ -2971,7 +2893,9 @@ describe("Subsonic", () => {
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(getArtistInfoXml(artist))) Promise.resolve(ok(getArtistInfoXml(artist)))
) )
.mockImplementationOnce(() => Promise.resolve(streamResponse)); .mockImplementationOnce(() =>
Promise.resolve(streamResponse)
);
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
@@ -2981,13 +2905,16 @@ describe("Subsonic", () => {
expect(result).toEqual(undefined); expect(result).toEqual(undefined);
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { expect(axios.get).toHaveBeenCalledWith(
params: asURLSearchParams({ `${url}/rest/getArtist`,
...authParams, {
id: artistId, params: asURLSearchParams({
}), ...authParams,
headers, id: artistId,
}); }),
headers,
}
);
expect(axios.get).toHaveBeenCalledWith( expect(axios.get).toHaveBeenCalledWith(
`${url}/rest/getArtistInfo2`, `${url}/rest/getArtistInfo2`,
@@ -3001,7 +2928,194 @@ describe("Subsonic", () => {
headers, 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"), data: Buffer.from("the image", "ascii"),
}; };
const album1 = anAlbum({ id: "album1Id", coverArt: "coverArt:album1CoverArt" }); const album1 = anAlbum({
const album2 = anAlbum({ id: "album2Id", coverArt: "coverArt:album2CoverArt" }); id: "album1Id",
coverArt: "coverArt:album1CoverArt",
});
const album2 = anAlbum({
id: "album2Id",
coverArt: "coverArt:album2CoverArt",
});
const artist = anArtist({ const artist = anArtist({
id: artistId, id: artistId,
@@ -4046,23 +4166,31 @@ describe("Subsonic", () => {
const id = uuid(); const id = uuid();
const name = "Great Playlist"; const name = "Great Playlist";
const artist1 = anArtist(); 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({ const track1 = aTrack({
genre: POP, genre: POP,
number: 66, number: 66,
coverArt: album1.coverArt, coverArt: album1.coverArt,
artist: artistToArtistSummary(artist1), artist: artistToArtistSummary(artist1),
album: albumToAlbumSummary(album1) album: albumToAlbumSummary(album1),
}); });
const artist2 = anArtist(); 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({ const track2 = aTrack({
genre: ROCK, genre: ROCK,
number: 77, number: 77,
coverArt: album2.coverArt, coverArt: album2.coverArt,
artist: artistToArtistSummary(artist2), artist: artistToArtistSummary(artist2),
album: albumToAlbumSummary(album2) album: albumToAlbumSummary(album2),
}); });
mockGET mockGET
@@ -4487,7 +4615,7 @@ describe("Subsonic", () => {
const track1 = aTrack({ const track1 = aTrack({
artist: artistToArtistSummary(artist), artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album1), album: albumToAlbumSummary(album1),
genre: POP genre: POP,
}); });
const track2 = aTrack({ const track2 = aTrack({