Add similar artists to Artist

This commit is contained in:
simojenki
2021-03-13 18:56:46 +11:00
parent d07efd97cf
commit 439c2eae87
4 changed files with 414 additions and 187 deletions

View File

@@ -42,6 +42,7 @@ export const NO_IMAGES: Images = {
export type Artist = ArtistSummary & {
image: Images
albums: AlbumSummary[];
similarArtists: ArtistSummary[]
};
export type AlbumSummary = {

View File

@@ -24,6 +24,7 @@ import sharp from "sharp";
import axios, { AxiosRequestConfig } from "axios";
import { Encryption } from "./encryption";
import randomString from "./random_string";
import { fold } from "fp-ts/lib/Option";
export const BROWSER_HEADERS = {
accept:
@@ -117,10 +118,12 @@ export type artistInfo = {
smallImageUrl: string | undefined;
mediumImageUrl: string | undefined;
largeImageUrl: string | undefined;
similarArtist: artistSummary[]
};
export type ArtistInfo = {
image: Images;
similarArtist: {id:string, name:string}[]
};
export type GetArtistInfoResponse = SubsonicResponse & {
@@ -255,6 +258,7 @@ export class Navidrome implements MusicService {
"subsonic-response.albumList.album",
"subsonic-response.album.song",
"subsonic-response.genres.genre",
"subsonic-response.artistInfo.similarArtist"
],
}).xml2js(response.data) as SubconicEnvelope
)
@@ -301,6 +305,7 @@ export class Navidrome implements MusicService {
medium: validate(it.artistInfo.mediumImageUrl),
large: validate(it.artistInfo.largeImageUrl),
},
similarArtist: (it.artistInfo.similarArtist || []).map(artist => ({id: artist._id, name: artist._name}))
}));
getAlbum = (credentials: Credentials, id: string): Promise<Album> =>
@@ -341,6 +346,7 @@ export class Navidrome implements MusicService {
name: artist.name,
image: artistInfo.image,
albums: artist.albums,
similarArtists: artistInfo.similarArtist
}));
getCoverArt = (credentials: Credentials, id: string, size?: number) =>
@@ -372,16 +378,15 @@ export class Navidrome implements MusicService {
albums: (q: AlbumQuery): Promise<Result<AlbumSummary>> =>
navidrome
.getJSON<GetAlbumListResponse>(credentials, "/rest/getAlbumList", {
...pipe(
O.fromNullable(q.genre),
O.map<string, getAlbumListParams>((genre) => ({
...fold(
() => ({
type: "alphabeticalByArtist",
}),
(genre) => ({
type: "byGenre",
genre,
})),
O.getOrElse<getAlbumListParams>(() => ({
type: "alphabeticalByArtist",
}))
),
})
)(O.fromNullable(q.genre)),
size: MAX_ALBUM_LIST,
offset: 0,
})

View File

@@ -79,6 +79,10 @@ export function anArtist(fields: Partial<Artist> = {}): Artist {
medium: `/artist/art/${id}/small`,
large: `/artist/art/${id}/large`,
},
similarArtists: [
{ id: uuid(), name: "Similar artist1"},
{ id: uuid(), name: "Similar artist2"},
],
...fields,
};
}
@@ -134,6 +138,7 @@ export const BLONDIE: Artist = {
medium: undefined,
large: undefined,
},
similarArtists: []
};
export const BOB_MARLEY: Artist = {
@@ -149,6 +154,7 @@ export const BOB_MARLEY: Artist = {
medium: "http://localhost/BOB_MARLEY/med",
large: "http://localhost/BOB_MARLEY/lge",
},
similarArtists: []
};
export const MADONNA: Artist = {
@@ -160,6 +166,7 @@ export const MADONNA: Artist = {
medium: undefined,
large: "http://localhost/MADONNA/lge",
},
similarArtists: []
};
export const METALLICA: Artist = {
@@ -184,6 +191,7 @@ export const METALLICA: Artist = {
medium: "http://localhost/METALLICA/med",
large: "http://localhost/METALLICA/lge",
},
similarArtists: []
};
export const ALL_ARTISTS = [BOB_MARLEY, BLONDIE, MADONNA, METALLICA];

View File

@@ -68,15 +68,16 @@ const ok = (data: string) => ({
});
const artistInfoXml = (
images: Images
artist: Artist,
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<artistInfo>
<biography></biography>
<musicBrainzId></musicBrainzId>
<lastFmUrl></lastFmUrl>
<smallImageUrl>${images.small || ""}</smallImageUrl>
<mediumImageUrl>${images.medium || ""}</mediumImageUrl>
<largeImageUrl>${images.large || ""}</largeImageUrl>
<smallImageUrl>${artist.image.small || ""}</smallImageUrl>
<mediumImageUrl>${artist.image.medium || ""}</mediumImageUrl>
<largeImageUrl>${artist.image.large || ""}</largeImageUrl>
${artist.similarArtists.map(it => `<similarArtist id="${it.id}" name="${it.name}" albumCount="3"></similarArtist>`)}
</artistInfo>
</subsonic-response>`;
@@ -296,7 +297,8 @@ describe("Navidrome", () => {
});
describe("getting an artist", () => {
describe("when the artist exists and has dodgy looking artist image uris", () => {
describe("when the artist exists", () => {
describe("and has many similar artists", () => {
const album1: Album = anAlbum();
const album2: Album = anAlbum();
@@ -308,14 +310,206 @@ describe("Navidrome", () => {
medium: `http://localhost:80/${DODGY_IMAGE_NAME}`,
large: `http://localhost:80/${DODGY_IMAGE_NAME}`,
},
similarArtists: [{ id: "similar1.id", name: "similar1" }, { id: "similar2.id", name: "similar2" }],
});
beforeEach(() => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist.image)))
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
);
});
it("should return the similar artists", async () => {
const result: Artist = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.artist(artist.id));
expect(result).toEqual({
id: artist.id,
name: artist.name,
image: {
small: undefined,
medium: undefined,
large: undefined,
},
albums: artist.albums,
similarArtists: artist.similarArtists
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
params: {
id: artist.id,
...authParams,
},
headers,
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
params: {
id: artist.id,
...authParams,
},
headers,
});
});
});
describe("and has one similar artists", () => {
const album1: Album = anAlbum();
const album2: Album = anAlbum();
const artist: Artist = anArtist({
albums: [album1, album2],
image: {
small: `http://localhost:80/${DODGY_IMAGE_NAME}`,
medium: `http://localhost:80/${DODGY_IMAGE_NAME}`,
large: `http://localhost:80/${DODGY_IMAGE_NAME}`,
},
similarArtists: [{ id: "similar1.id", name: "similar1" }],
});
beforeEach(() => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
);
});
it("should return the similar artists", async () => {
const result: Artist = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.artist(artist.id));
expect(result).toEqual({
id: artist.id,
name: artist.name,
image: {
small: undefined,
medium: undefined,
large: undefined,
},
albums: artist.albums,
similarArtists: artist.similarArtists
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
params: {
id: artist.id,
...authParams,
},
headers,
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
params: {
id: artist.id,
...authParams,
},
headers,
});
});
});
describe("and has no similar artists", () => {
const album1: Album = anAlbum();
const album2: Album = anAlbum();
const artist: Artist = anArtist({
albums: [album1, album2],
image: {
small: `http://localhost:80/${DODGY_IMAGE_NAME}`,
medium: `http://localhost:80/${DODGY_IMAGE_NAME}`,
large: `http://localhost:80/${DODGY_IMAGE_NAME}`,
},
similarArtists: [],
});
beforeEach(() => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
);
});
it("should return the similar artists", async () => {
const result: Artist = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.artist(artist.id));
expect(result).toEqual({
id: artist.id,
name: artist.name,
image: {
small: undefined,
medium: undefined,
large: undefined,
},
albums: artist.albums,
similarArtists: artist.similarArtists
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
params: {
id: artist.id,
...authParams,
},
headers,
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, {
params: {
id: artist.id,
...authParams,
},
headers,
});
});
});
describe("and has dodgy looking artist image uris", () => {
const album1: Album = anAlbum();
const album2: Album = anAlbum();
const artist: Artist = anArtist({
albums: [album1, album2],
image: {
small: `http://localhost:80/${DODGY_IMAGE_NAME}`,
medium: `http://localhost:80/${DODGY_IMAGE_NAME}`,
large: `http://localhost:80/${DODGY_IMAGE_NAME}`,
},
similarArtists: [],
});
beforeEach(() => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
);
});
@@ -335,6 +529,7 @@ describe("Navidrome", () => {
large: undefined,
},
albums: artist.albums,
similarArtists: []
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -355,21 +550,24 @@ describe("Navidrome", () => {
});
});
describe("when the artist exists and has multiple albums", () => {
describe("and has multiple albums", () => {
const album1: Album = anAlbum();
const album2: Album = anAlbum();
const artist: Artist = anArtist({
albums: [album1, album2],
similarArtists: []
});
beforeEach(() => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist.image)))
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
);
});
@@ -385,6 +583,7 @@ describe("Navidrome", () => {
name: artist.name,
image: artist.image,
albums: artist.albums,
similarArtists: []
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -405,19 +604,22 @@ describe("Navidrome", () => {
});
});
describe("when the artist exists and has only 1 album", () => {
describe("and has only 1 album", () => {
const album: Album = anAlbum();
const artist: Artist = anArtist({
albums: [album],
similarArtists: []
});
beforeEach(() => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist.image)))
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
);
});
@@ -433,6 +635,7 @@ describe("Navidrome", () => {
name: artist.name,
image: artist.image,
albums: artist.albums,
similarArtists: []
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -453,17 +656,20 @@ describe("Navidrome", () => {
});
});
describe("when the artist exists and has no albums", () => {
describe("and has no albums", () => {
const artist: Artist = anArtist({
albums: [],
similarArtists: []
});
beforeEach(() => {
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist))))
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist.image)))
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(artist)))
);
});
@@ -479,6 +685,7 @@ describe("Navidrome", () => {
name: artist.name,
image: artist.image,
albums: [],
similarArtists: []
});
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
@@ -499,6 +706,7 @@ describe("Navidrome", () => {
});
});
});
});
describe("getting artists", () => {
describe("when there are no results", () => {
@@ -1388,7 +1596,7 @@ describe("Navidrome", () => {
data: Buffer.from("the image", "ascii"),
};
const artist = anArtist({ id: artistId });
const artist = anArtist({ id: artistId, image: images });
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
@@ -1396,7 +1604,7 @@ describe("Navidrome", () => {
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(images)))
Promise.resolve(ok(artistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -1454,6 +1662,7 @@ describe("Navidrome", () => {
const artist = anArtist({
id: artistId,
albums: [album1, album2],
image: images
});
mockGET
@@ -1462,7 +1671,7 @@ describe("Navidrome", () => {
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(images)))
Promise.resolve(ok(artistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -1528,7 +1737,7 @@ describe("Navidrome", () => {
data: Buffer.from("the image", "ascii"),
};
const artist = anArtist({ id: artistId, albums: [] });
const artist = anArtist({ id: artistId, albums: [], image: images });
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
@@ -1536,7 +1745,7 @@ describe("Navidrome", () => {
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(images)))
Promise.resolve(ok(artistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -1595,7 +1804,7 @@ describe("Navidrome", () => {
data: originalImage,
};
const artist = anArtist({ id: artistId });
const artist = anArtist({ id: artistId, image: images });
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
@@ -1603,13 +1812,15 @@ describe("Navidrome", () => {
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(images)))
Promise.resolve(ok(artistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
const resize = jest.fn();
(sharp as unknown as jest.Mock).mockReturnValue({ resize });
resize.mockReturnValue({ toBuffer: () => Promise.resolve(resizedImage) })
((sharp as unknown) as jest.Mock).mockReturnValue({ resize });
resize.mockReturnValue({
toBuffer: () => Promise.resolve(resizedImage),
});
const result = await navidrome
.generateToken({ username, password })
@@ -1668,6 +1879,7 @@ describe("Navidrome", () => {
const artist = anArtist({
id: artistId,
albums: [album1, album2],
image: images
});
mockGET
@@ -1676,7 +1888,7 @@ describe("Navidrome", () => {
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(images)))
Promise.resolve(ok(artistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -1743,7 +1955,7 @@ describe("Navidrome", () => {
data: Buffer.from("the image", "ascii"),
};
const artist = anArtist({ id: artistId, albums: [] });
const artist = anArtist({ id: artistId, albums: [], image: images });
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
@@ -1751,7 +1963,7 @@ describe("Navidrome", () => {
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(images)))
Promise.resolve(ok(artistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -1810,6 +2022,7 @@ describe("Navidrome", () => {
const artist = anArtist({
id: artistId,
albums: [album1, album2],
image: images
});
mockGET
@@ -1818,7 +2031,7 @@ describe("Navidrome", () => {
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(images)))
Promise.resolve(ok(artistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));
@@ -1885,7 +2098,7 @@ describe("Navidrome", () => {
data: Buffer.from("the image", "ascii"),
};
const artist = anArtist({ id: artistId, albums: [] });
const artist = anArtist({ id: artistId, albums: [], image: images });
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
@@ -1893,7 +2106,7 @@ describe("Navidrome", () => {
Promise.resolve(ok(artistXml(artist)))
)
.mockImplementationOnce(() =>
Promise.resolve(ok(artistInfoXml(images)))
Promise.resolve(ok(artistInfoXml(artist)))
)
.mockImplementationOnce(() => Promise.resolve(streamResponse));