Similar songs (#18)

* Support for getting similarSongs from navidrome

* Ability to load topSongs from navidrome

* Load artists not in library from navidrome
This commit is contained in:
Simon J
2021-08-14 09:13:29 +10:00
committed by GitHub
parent 66b6f24e61
commit 43c335ecfc
7 changed files with 576 additions and 100 deletions

View File

@@ -39,10 +39,12 @@ export const NO_IMAGES: Images = {
large: undefined, large: undefined,
}; };
export type SimilarArtist = ArtistSummary & { inLibrary: boolean };
export type Artist = ArtistSummary & { export type Artist = ArtistSummary & {
image: Images image: Images
albums: AlbumSummary[]; albums: AlbumSummary[];
similarArtists: ArtistSummary[] similarArtists: SimilarArtist[]
}; };
export type AlbumSummary = { export type AlbumSummary = {
@@ -179,4 +181,6 @@ export interface MusicLibrary {
deletePlaylist(id: string): Promise<boolean> deletePlaylist(id: string): Promise<boolean>
addToPlaylist(playlistId: string, trackId: string): Promise<boolean> addToPlaylist(playlistId: string, trackId: string): Promise<boolean>
removeFromPlaylist(playlistId: string, indicies: number[]): Promise<boolean> removeFromPlaylist(playlistId: string, indicies: number[]): Promise<boolean>
similarSongs(id: string): Promise<Track[]>;
topSongs(artistId: string): Promise<Track[]>;
} }

View File

@@ -126,7 +126,7 @@ export type artistInfo = {
export type ArtistInfo = { export type ArtistInfo = {
image: Images; image: Images;
similarArtist: { id: string; name: string }[]; similarArtist: (ArtistSummary & { inLibrary: boolean })[];
}; };
export type GetArtistInfoResponse = SubsonicResponse & { export type GetArtistInfoResponse = SubsonicResponse & {
@@ -196,6 +196,14 @@ export type GetPlaylistsResponse = {
playlists: { playlist: playlist[] }; playlists: { playlist: playlist[] };
}; };
export type GetSimilarSongsResponse = {
similarSongs: { song: song[] }
}
export type GetTopSongsResponse = {
topSongs: { song: song[] }
}
export type GetSongResponse = { export type GetSongResponse = {
song: song; song: song;
}; };
@@ -240,7 +248,7 @@ const asTrack = (album: Album, song: song) => ({
album, album,
artist: { artist: {
id: song._artistId, id: song._artistId,
name: song._artist, name: song._artist
}, },
}); });
@@ -351,6 +359,8 @@ export class Navidrome implements MusicService {
"subsonic-response.searchResult3.album", "subsonic-response.searchResult3.album",
"subsonic-response.searchResult3.artist", "subsonic-response.searchResult3.artist",
"subsonic-response.searchResult3.song", "subsonic-response.searchResult3.song",
"subsonic-response.similarSongs.song",
"subsonic-response.topSongs.song",
], ],
}).xml2js(response.data) as SubconicEnvelope }).xml2js(response.data) as SubconicEnvelope
) )
@@ -392,6 +402,7 @@ export class Navidrome implements MusicService {
this.getJSON<GetArtistInfoResponse>(credentials, "/rest/getArtistInfo", { this.getJSON<GetArtistInfoResponse>(credentials, "/rest/getArtistInfo", {
id, id,
count: 50, count: 50,
includeNotPresent: true
}).then((it) => ({ }).then((it) => ({
image: { image: {
small: validate(it.artistInfo.smallImageUrl), small: validate(it.artistInfo.smallImageUrl),
@@ -401,6 +412,7 @@ export class Navidrome implements MusicService {
similarArtist: (it.artistInfo.similarArtist || []).map((artist) => ({ similarArtist: (it.artistInfo.similarArtist || []).map((artist) => ({
id: artist._id, id: artist._id,
name: artist._name, name: artist._name,
inLibrary: artist._id != "-1",
})), })),
})); }));
@@ -698,7 +710,7 @@ export class Navidrome implements MusicService {
}, },
artist: { artist: {
id: entry._artistId, id: entry._artistId,
name: entry._artist, name: entry._artist
}, },
})), })),
}; };
@@ -730,6 +742,24 @@ export class Navidrome implements MusicService {
songIndexToRemove: indicies, songIndexToRemove: indicies,
}) })
.then((_) => true), .then((_) => true),
similarSongs: async (id: string) => navidrome
.getJSON<GetSimilarSongsResponse>(credentials, "/rest/getSimilarSongs", { id, count: 50 })
.then((it) => (it.similarSongs.song || []))
.then(songs =>
Promise.all(
songs.map((song) => navidrome.getAlbum(credentials, song._albumId).then(album => asTrack(album, song)))
)
),
topSongs: async (artistId: string) => navidrome
.getArtist(credentials, artistId)
.then(({ name }) => navidrome
.getJSON<GetTopSongsResponse>(credentials, "/rest/getTopSongs", { artist: name, count: 50 })
.then((it) => (it.topSongs.song || []))
.then(songs =>
Promise.all(
songs.map((song) => navidrome.getAlbum(credentials, song._albumId).then(album => asTrack(album, song)))
)
))
}; };
return Promise.resolve(musicLibrary); return Promise.resolve(musicLibrary);

View File

@@ -458,7 +458,7 @@ function bindSmapiSoapServiceToExpress(
album(urlWithToken(accessToken), it) album(urlWithToken(accessToken), it)
), ),
relatedBrowse: relatedBrowse:
artist.similarArtists.length > 0 artist.similarArtists.filter(it => it.inLibrary).length > 0
? [ ? [
{ {
id: `relatedArtists:${artist.id}`, id: `relatedArtists:${artist.id}`,
@@ -715,6 +715,7 @@ function bindSmapiSoapServiceToExpress(
return musicLibrary return musicLibrary
.artist(typeId!) .artist(typeId!)
.then((artist) => artist.similarArtists) .then((artist) => artist.similarArtists)
.then(similarArtists => similarArtists.filter(it => it.inLibrary))
.then(slice2(paging)) .then(slice2(paging))
.then(([page, total]) => { .then(([page, total]) => {
return getMetadataResult({ return getMetadataResult({

View File

@@ -98,8 +98,9 @@ export function anArtist(fields: Partial<Artist> = {}): Artist {
large: `/artist/art/${id}/large`, large: `/artist/art/${id}/large`,
}, },
similarArtists: [ similarArtists: [
{ id: uuid(), name: "Similar artist1" }, { id: uuid(), name: "Similar artist1", inLibrary: true },
{ id: uuid(), name: "Similar artist2" }, { id: uuid(), name: "Similar artist2", inLibrary: true },
{ id: "-1", name: "Artist not in library", inLibrary: false },
], ],
...fields, ...fields,
}; };

View File

@@ -143,6 +143,8 @@ export class InMemoryMusicService implements MusicService {
deletePlaylist: async (_: string) => Promise.reject("Unsupported operation"), deletePlaylist: async (_: string) => Promise.reject("Unsupported operation"),
addToPlaylist: async (_: string) => Promise.reject("Unsupported operation"), addToPlaylist: async (_: string) => Promise.reject("Unsupported operation"),
removeFromPlaylist: async (_: string, _2: number[]) => Promise.reject("Unsupported operation"), removeFromPlaylist: async (_: string, _2: number[]) => Promise.reject("Unsupported operation"),
similarSongs: async (_: string) => Promise.resolve([]),
topSongs: async (_: string) => Promise.resolve([]),
}); });
} }

View File

@@ -34,7 +34,7 @@ import {
AlbumQuery, AlbumQuery,
PlaylistSummary, PlaylistSummary,
Playlist, Playlist,
ArtistSummary, SimilarArtist,
} from "../src/music_service"; } from "../src/music_service";
import { import {
anAlbum, anAlbum,
@@ -151,7 +151,7 @@ describe("asURLSearchParams", () => {
expect(asURLSearchParams(q)).toEqual(expected); expect(asURLSearchParams(q)).toEqual(expected);
}); });
}); });
}); });
const ok = (data: string) => ({ const ok = (data: string) => ({
@@ -159,8 +159,13 @@ const ok = (data: string) => ({
data, data,
}); });
const similarArtistXml = (artistSummary: ArtistSummary) => const similarArtistXml = (similarArtist: SimilarArtist) => {
`<similarArtist id="${artistSummary.id}" name="${artistSummary.name}" albumCount="3"></similarArtist>`; if(similarArtist.inLibrary)
return `<similarArtist id="${similarArtist.id}" name="${similarArtist.name}" albumCount="3"></similarArtist>`
else
return `<similarArtist id="-1" name="${similarArtist.name}" albumCount="3"></similarArtist>`
}
const getArtistInfoXml = ( const getArtistInfoXml = (
artist: Artist artist: Artist
@@ -222,19 +227,18 @@ const albumListXml = (
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"> ) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<albumList> <albumList>
${albums ${albums
.map(([artist, album]) => albumXml(artist, album)) .map(([artist, album]) => albumXml(artist, album))
.join("")} .join("")}
</albumList> </albumList>
</subsonic-response>`; </subsonic-response>`;
const artistXml = (artist: Artist) => `<artist id="${artist.id}" name="${ const artistXml = (artist: Artist) => `<artist id="${artist.id}" name="${artist.name
artist.name }" albumCount="${artist.albums.length}" artistImageUrl="....">
}" albumCount="${artist.albums.length}" artistImageUrl="....">
${artist.albums ${artist.albums
.map((album) => .map((album) =>
albumXml(artist, album) albumXml(artist, album)
) )
.join("")} .join("")}
</artist>`; </artist>`;
const getArtistXml = ( const getArtistXml = (
@@ -260,20 +264,32 @@ const getAlbumXml = (
tracks: Track[] tracks: Track[]
) => `<subsonic-response status="ok" version="1.8.0"> ) => `<subsonic-response status="ok" version="1.8.0">
${albumXml( ${albumXml(
artist, artist,
album, album,
tracks tracks
)} )}
</subsonic-response>`; </subsonic-response>`;
const getSongXml = ( const getSongXml = (
track: Track track: Track
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)"> ) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
${songXml( ${songXml(
track track
)} )}
</subsonic-response>`; </subsonic-response>`;
const similarSongsXml = (tracks: Track[]) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<similarSongs>
${tracks.map(songXml).join("")}
</similarSongs>
</subsonic-response>`
const topSongsXml = (tracks: Track[]) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.40.0 (8799358a)">
<topSongs>
${tracks.map(songXml).join("")}
</topSongs>
</subsonic-response>`
export type ArtistWithAlbum = { export type ArtistWithAlbum = {
artist: Artist; artist: Artist;
album: Album; album: Album;
@@ -291,7 +307,10 @@ const getPlayLists = (
</subsonic-response>`; </subsonic-response>`;
const error = (code: string, message: string) => const error = (code: string, message: string) =>
`<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1" type="navidrome" serverVersion="0.42.0 (f1bd736b)"><error code="${code}" message="${message}"></error></subsonic-response>`; `<subsonic-response xmlns="http://subsonic.org/restapi" status="failed" version="1.16.1" type="navidrome" serverVersion="0.42.0 (f1bd736b)">
<error code="${code}" message="${message}">
</error>
</subsonic-response>`;
const createPlayList = (playlist: PlaylistSummary) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.42.0 (f1bd736b)"> const createPlayList = (playlist: PlaylistSummary) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.42.0 (f1bd736b)">
${playlistXml(playlist)} ${playlistXml(playlist)}
@@ -300,9 +319,8 @@ const createPlayList = (playlist: PlaylistSummary) => `<subsonic-response xmlns=
const getPlayList = ( const getPlayList = (
playlist: Playlist playlist: Playlist
) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.42.0 (f1bd736b)"> ) => `<subsonic-response xmlns="http://subsonic.org/restapi" status="ok" version="1.16.1" type="navidrome" serverVersion="0.42.0 (f1bd736b)">
<playlist id="${playlist.id}" name="${playlist.name}" songCount="${ <playlist id="${playlist.id}" name="${playlist.name}" songCount="${playlist.entries.length
playlist.entries.length }" duration="627" public="true" owner="bob" created="2021-05-06T02:07:30.460465988Z" changed="2021-05-06T02:40:04Z">
}" duration="627" public="true" owner="bob" created="2021-05-06T02:07:30.460465988Z" changed="2021-05-06T02:40:04Z">
${playlist.entries ${playlist.entries
.map( .map(
(it) => `<entry (it) => `<entry
@@ -500,8 +518,10 @@ describe("Navidrome", () => {
large: `http://localhost:80/${DODGY_IMAGE_NAME}`, large: `http://localhost:80/${DODGY_IMAGE_NAME}`,
}, },
similarArtists: [ similarArtists: [
{ id: "similar1.id", name: "similar1" }, { id: "similar1.id", name: "similar1", inLibrary: true },
{ id: "similar2.id", name: "similar2" }, { id: "-1", name: "similar2", inLibrary: false },
{ id: "similar3.id", name: "similar3", inLibrary: true },
{ id: "-1", name: "similar4", inLibrary: false },
], ],
}); });
@@ -536,7 +556,7 @@ describe("Navidrome", () => {
}); });
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, {
params:asURLSearchParams( { params: asURLSearchParams({
...authParams, ...authParams,
id: artist.id, id: artist.id,
}), }),
@@ -547,14 +567,15 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artist.id, id: artist.id,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
}); });
}); });
}); });
describe("and has one similar artists", () => { describe("and has one similar artist", () => {
const album1: Album = anAlbum({ genre: asGenre("G1") }); const album1: Album = anAlbum({ genre: asGenre("G1") });
const album2: Album = anAlbum({ genre: asGenre("G2") }); const album2: Album = anAlbum({ genre: asGenre("G2") });
@@ -566,7 +587,7 @@ describe("Navidrome", () => {
medium: `http://localhost:80/${DODGY_IMAGE_NAME}`, medium: `http://localhost:80/${DODGY_IMAGE_NAME}`,
large: `http://localhost:80/${DODGY_IMAGE_NAME}`, large: `http://localhost:80/${DODGY_IMAGE_NAME}`,
}, },
similarArtists: [{ id: "similar1.id", name: "similar1" }], similarArtists: [{ id: "similar1.id", name: "similar1", inLibrary: true }],
}); });
beforeEach(() => { beforeEach(() => {
@@ -611,7 +632,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artist.id, id: artist.id,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
}); });
@@ -675,7 +697,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artist.id, id: artist.id,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
}); });
@@ -739,7 +762,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artist.id, id: artist.id,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
}); });
@@ -794,7 +818,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artist.id, id: artist.id,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
}); });
@@ -847,7 +872,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artist.id, id: artist.id,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
}); });
@@ -898,7 +924,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artist.id, id: artist.id,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
}); });
@@ -2087,7 +2114,7 @@ describe("Navidrome", () => {
expect(streamClientApplication).toHaveBeenCalledWith(track); expect(streamClientApplication).toHaveBeenCalledWith(track);
expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, {
params:asURLSearchParams( { params: asURLSearchParams({
...authParams, ...authParams,
id: trackId, id: trackId,
c: clientApplication, c: clientApplication,
@@ -2231,7 +2258,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artistId, id: artistId,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
} }
@@ -2307,7 +2335,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artistId, id: artistId,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
} }
@@ -2383,7 +2412,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artistId, id: artistId,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
} }
@@ -2452,7 +2482,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artistId, id: artistId,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
} }
@@ -2531,7 +2562,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artistId, id: artistId,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
} }
@@ -2608,7 +2640,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artistId, id: artistId,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
} }
@@ -2680,7 +2713,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artistId, id: artistId,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
} }
@@ -2757,7 +2791,8 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id: artistId, id: artistId,
count: 50 count: 50,
includeNotPresent: true
}), }),
headers, headers,
} }
@@ -3343,7 +3378,7 @@ describe("Navidrome", () => {
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(error("70", "not there"))) Promise.resolve(ok(error("70", "data not found")))
); );
return expect( return expect(
@@ -3352,7 +3387,7 @@ describe("Navidrome", () => {
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.playlist(id)) .then((it) => it.playlist(id))
).rejects.toEqual("not there"); ).rejects.toEqual("data not found");
}); });
}); });
@@ -3447,26 +3482,26 @@ describe("Navidrome", () => {
const id = uuid(); const id = uuid();
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(createPlayList({id, name}))) Promise.resolve(ok(createPlayList({ id, name })))
); );
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.createPlaylist(name)); .then((it) => it.createPlaylist(name));
expect(result).toEqual({ id, name }); expect(result).toEqual({ id, name });
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/createPlaylist`, { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/createPlaylist`, {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
name, name,
}), }),
headers, headers,
}); });
}); });
}); });
@@ -3475,26 +3510,26 @@ describe("Navidrome", () => {
const id = "id-to-delete"; const id = "id-to-delete";
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() => .mockImplementationOnce(() =>
Promise.resolve(ok(EMPTY)) Promise.resolve(ok(EMPTY))
); );
const result = await navidrome const result = await navidrome
.generateToken({ username, password }) .generateToken({ username, password })
.then((it) => it as AuthSuccess) .then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken)) .then((it) => navidrome.login(it.authToken))
.then((it) => it.deletePlaylist(id)); .then((it) => it.deletePlaylist(id));
expect(result).toEqual(true); expect(result).toEqual(true);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/deletePlaylist`, { expect(mockGET).toHaveBeenCalledWith(`${url}/rest/deletePlaylist`, {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
id, id,
}), }),
headers, headers,
}); });
}); });
}); });
@@ -3522,7 +3557,7 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
playlistId, playlistId,
songIdToAdd: trackId, songIdToAdd: trackId,
}), }),
headers, headers,
}); });
@@ -3532,7 +3567,7 @@ describe("Navidrome", () => {
describe("removing a track from a playlist", () => { describe("removing a track from a playlist", () => {
it("should remove it", async () => { it("should remove it", async () => {
const playlistId = uuid(); const playlistId = uuid();
const indicies =[6, 100, 33]; const indicies = [6, 100, 33];
mockGET mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
@@ -3552,12 +3587,334 @@ describe("Navidrome", () => {
params: asURLSearchParams({ params: asURLSearchParams({
...authParams, ...authParams,
playlistId, playlistId,
songIndexToRemove: indicies, songIndexToRemove: indicies,
}), }),
headers, headers,
}); });
}); });
}); });
}); });
});
describe("similarSongs", () => {
describe("when there is one similar songs", () => {
it("should return it", async () => {
const id = "idWithTracks";
const pop = asGenre("Pop");
const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop });
const artist1 = anArtist({
id: "artist1",
name: "Bob Marley",
albums: [album1],
});
const track1 = aTrack({
id: "track1",
artist: artistToArtistSummary(artist1),
album: albumToAlbumSummary(album1),
genre: pop
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(similarSongsXml([track1])))
).mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist1, album1, [])))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.similarSongs(id));
expect(result).toEqual([track1]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs`, {
params: asURLSearchParams({
...authParams,
id,
count: 50,
}),
headers,
});
});
});
describe("when there are similar songs", () => {
it("should return them", async () => {
const id = "idWithTracks";
const pop = asGenre("Pop");
const album1 = anAlbum({ id: "album1", name: "Burnin", genre: pop });
const artist1 = anArtist({
id: "artist1",
name: "Bob Marley",
albums: [album1],
});
const album2 = anAlbum({ id: "album2", name: "Walking", genre: pop });
const artist2 = anArtist({
id: "artist2",
name: "Bob Jane",
albums: [album2],
});
const track1 = aTrack({
id: "track1",
artist: artistToArtistSummary(artist1),
album: albumToAlbumSummary(album1),
genre: pop
});
const track2 = aTrack({
id: "track2",
artist: artistToArtistSummary(artist2),
album: albumToAlbumSummary(album2),
genre: pop
});
const track3 = aTrack({
id: "track3",
artist: artistToArtistSummary(artist1),
album: albumToAlbumSummary(album1),
genre: pop
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(similarSongsXml([track1, track2, track3])))
).mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist1, album1, [])))
).mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist2, album2, [])))
).mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist1, album1, [])))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.similarSongs(id));
expect(result).toEqual([track1, track2, track3]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs`, {
params: asURLSearchParams({
...authParams,
id,
count: 50,
}),
headers,
});
});
});
describe("when there are no similar songs", () => {
it("should return []", async () => {
const id = "idWithNoTracks";
const xml = similarSongsXml([]);
console.log(`xml = ${xml}`)
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(xml))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.similarSongs(id));
expect(result).toEqual([]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getSimilarSongs`, {
params: asURLSearchParams({
...authParams,
id,
count: 50,
}),
headers,
});
});
});
describe("when there id doesnt exist", () => {
it("should fail", async () => {
const id = "idThatHasAnError";
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(error("70", "data not found")))
);
return expect(navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.similarSongs(id))).rejects.toEqual("data not found");
});
});
});
describe("topSongs", () => {
describe("when there is one top song", () => {
it("should return it", async () => {
const artistId = "bobMarleyId";
const artistName = "Bob Marley";
const pop = asGenre("Pop");
const album1 = anAlbum({ name: "Burnin", genre: pop });
const artist = anArtist({
id: artistId,
name: artistName,
albums: [album1],
});
const track1 = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album1),
genre: pop
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(getArtistXml(artist)))
).mockImplementationOnce(() =>
Promise.resolve(ok(topSongsXml([track1])))
).mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album1, [])))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.topSongs(artistId));
expect(result).toEqual([track1]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, {
params: asURLSearchParams({
...authParams,
artist: artistName,
count: 50,
}),
headers,
});
});
});
describe("when there are many top songs", () => {
it("should return them", async () => {
const artistId = "bobMarleyId";
const artistName = "Bob Marley";
const pop = asGenre("Pop");
const album1 = anAlbum({ name: "Burnin", genre: pop });
const album2 = anAlbum({ name: "Churning", genre: pop });
const artist = anArtist({
id: artistId,
name: artistName,
albums: [album1, album2],
});
const track1 = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album1),
genre: pop
});
const track2 = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album2),
genre: pop
});
const track3 = aTrack({
artist: artistToArtistSummary(artist),
album: albumToAlbumSummary(album1),
genre: pop
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(getArtistXml(artist)))
).mockImplementationOnce(() =>
Promise.resolve(ok(topSongsXml([track1, track2, track3])))
).mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album1, [])))
).mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album2, [])))
).mockImplementationOnce(() =>
Promise.resolve(ok(getAlbumXml(artist, album1, [])))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.topSongs(artistId));
expect(result).toEqual([track1, track2, track3]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, {
params: asURLSearchParams({
...authParams,
artist: artistName,
count: 50,
}),
headers,
});
});
});
describe("when there are no similar songs", () => {
it("should return []", async () => {
const artistId = "bobMarleyId";
const artistName = "Bob Marley";
const pop = asGenre("Pop");
const album1 = anAlbum({ name: "Burnin", genre: pop });
const artist = anArtist({
id: artistId,
name: artistName,
albums: [album1],
});
mockGET
.mockImplementationOnce(() => Promise.resolve(ok(PING_OK)))
.mockImplementationOnce(() =>
Promise.resolve(ok(getArtistXml(artist)))
).mockImplementationOnce(() =>
Promise.resolve(ok(topSongsXml([])))
);
const result = await navidrome
.generateToken({ username, password })
.then((it) => it as AuthSuccess)
.then((it) => navidrome.login(it.authToken))
.then((it) => it.topSongs(artistId));
expect(result).toEqual([]);
expect(mockGET).toHaveBeenCalledWith(`${url}/rest/getTopSongs`, {
params: asURLSearchParams({
...authParams,
artist: artistName,
count: 50,
}),
headers,
});
});
});
}); });
}); });

View File

@@ -1132,18 +1132,22 @@ describe("api", () => {
}); });
describe("asking for relatedArtists", () => { describe("asking for relatedArtists", () => {
describe("when the artist has many", () => { describe("when the artist has many, some in the library and some not", () => {
const relatedArtist1 = anArtist(); const relatedArtist1 = anArtist();
const relatedArtist2 = anArtist(); const relatedArtist2 = anArtist();
const relatedArtist3 = anArtist(); const relatedArtist3 = anArtist();
const relatedArtist4 = anArtist(); const relatedArtist4 = anArtist();
const relatedArtist5 = anArtist();
const relatedArtist6 = anArtist();
const artist = anArtist({ const artist = anArtist({
similarArtists: [ similarArtists: [
relatedArtist1, { ...relatedArtist1, inLibrary: true },
relatedArtist2, { ...relatedArtist2, inLibrary: true },
relatedArtist3, { ...relatedArtist3, inLibrary: false },
relatedArtist4, { ...relatedArtist4, inLibrary: true },
{ ...relatedArtist5, inLibrary: false },
{ ...relatedArtist6, inLibrary: true },
], ],
}); });
@@ -1163,8 +1167,8 @@ describe("api", () => {
mediaCollection: [ mediaCollection: [
relatedArtist1, relatedArtist1,
relatedArtist2, relatedArtist2,
relatedArtist3,
relatedArtist4, relatedArtist4,
relatedArtist6,
].map((it) => ({ ].map((it) => ({
itemType: "artist", itemType: "artist",
id: `artist:${it.id}`, id: `artist:${it.id}`,
@@ -1193,7 +1197,7 @@ describe("api", () => {
}); });
expect(result[0]).toEqual( expect(result[0]).toEqual(
getMetadataResult({ getMetadataResult({
mediaCollection: [relatedArtist2, relatedArtist3].map( mediaCollection: [relatedArtist2, relatedArtist4].map(
(it) => ({ (it) => ({
itemType: "artist", itemType: "artist",
id: `artist:${it.id}`, id: `artist:${it.id}`,
@@ -1238,6 +1242,44 @@ describe("api", () => {
expect(accessTokens.mint).toHaveBeenCalledWith(authToken); expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
}); });
}); });
describe("when the artist some however none are in the library", () => {
const relatedArtist1 = anArtist();
const relatedArtist2 = anArtist();
const artist = anArtist({
similarArtists: [
{
...relatedArtist1,
inLibrary: false,
},
{
...relatedArtist2,
inLibrary: false,
},
],
});
beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist);
});
it("should return an empty list", async () => {
const result = await ws.getMetadataAsync({
id: `relatedArtists:${artist.id}`,
index: 0,
count: 100,
});
expect(result[0]).toEqual(
getMetadataResult({
index: 0,
total: 0,
})
);
expect(musicLibrary.artist).toHaveBeenCalledWith(artist.id);
expect(accessTokens.mint).toHaveBeenCalledWith(authToken);
});
});
}); });
describe("asking for albums", () => { describe("asking for albums", () => {
@@ -1947,12 +1989,19 @@ describe("api", () => {
}); });
}); });
describe("when it has similar artists", () => { describe("when it has similar artists, some in the library and some not", () => {
const similar1 = anArtist(); const similar1 = anArtist();
const similar2 = anArtist(); const similar2 = anArtist();
const similar3 = anArtist();
const similar4 = anArtist();
const artist = anArtist({ const artist = anArtist({
similarArtists: [similar1, similar2], similarArtists: [
{ ...similar1, inLibrary: true },
{ ...similar2, inLibrary: false },
{ ...similar3, inLibrary: false },
{ ...similar4, inLibrary: true },
],
albums: [], albums: [],
}); });
@@ -2014,6 +2063,38 @@ describe("api", () => {
}); });
}); });
}); });
describe("when none of the similar artists are in the library", () => {
const relatedArtist1 = anArtist();
const relatedArtist2 = anArtist();
const artist = anArtist({
similarArtists: [
{ ...relatedArtist1, inLibrary: false },
{ ...relatedArtist2, inLibrary: false },
],
albums: [],
});
beforeEach(() => {
musicLibrary.artist.mockResolvedValue(artist);
});
it("should not return a RELATED_ARTISTS browse option", async () => {
const root = await ws.getExtendedMetadataAsync({
id: `artist:${artist.id}`,
index: 0,
count: 100,
});
expect(root[0]).toEqual({
getExtendedMetadataResult: {
// artist has no albums
count: "0",
index: "0",
total: "0",
},
});
});
});
}); });
describe("asking for a track", () => { describe("asking for a track", () => {