From 43c335ecfcc6c9f6663d264992246a1ad8528c27 Mon Sep 17 00:00:00 2001 From: Simon J Date: Sat, 14 Aug 2021 09:13:29 +1000 Subject: [PATCH] Similar songs (#18) * Support for getting similarSongs from navidrome * Ability to load topSongs from navidrome * Load artists not in library from navidrome --- src/music_service.ts | 6 +- src/navidrome.ts | 36 ++- src/smapi.ts | 3 +- tests/builders.ts | 5 +- tests/in_memory_music_service.ts | 2 + tests/navidrome.test.ts | 525 ++++++++++++++++++++++++++----- tests/smapi.test.ts | 99 +++++- 7 files changed, 576 insertions(+), 100 deletions(-) diff --git a/src/music_service.ts b/src/music_service.ts index 382826d..6c33dcb 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -39,10 +39,12 @@ export const NO_IMAGES: Images = { large: undefined, }; +export type SimilarArtist = ArtistSummary & { inLibrary: boolean }; + export type Artist = ArtistSummary & { image: Images albums: AlbumSummary[]; - similarArtists: ArtistSummary[] + similarArtists: SimilarArtist[] }; export type AlbumSummary = { @@ -179,4 +181,6 @@ export interface MusicLibrary { deletePlaylist(id: string): Promise addToPlaylist(playlistId: string, trackId: string): Promise removeFromPlaylist(playlistId: string, indicies: number[]): Promise + similarSongs(id: string): Promise; + topSongs(artistId: string): Promise; } diff --git a/src/navidrome.ts b/src/navidrome.ts index 9916b53..7e01c1b 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -126,7 +126,7 @@ export type artistInfo = { export type ArtistInfo = { image: Images; - similarArtist: { id: string; name: string }[]; + similarArtist: (ArtistSummary & { inLibrary: boolean })[]; }; export type GetArtistInfoResponse = SubsonicResponse & { @@ -196,6 +196,14 @@ export type GetPlaylistsResponse = { playlists: { playlist: playlist[] }; }; +export type GetSimilarSongsResponse = { + similarSongs: { song: song[] } +} + +export type GetTopSongsResponse = { + topSongs: { song: song[] } +} + export type GetSongResponse = { song: song; }; @@ -240,7 +248,7 @@ const asTrack = (album: Album, song: song) => ({ album, artist: { 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.artist", "subsonic-response.searchResult3.song", + "subsonic-response.similarSongs.song", + "subsonic-response.topSongs.song", ], }).xml2js(response.data) as SubconicEnvelope ) @@ -392,6 +402,7 @@ export class Navidrome implements MusicService { this.getJSON(credentials, "/rest/getArtistInfo", { id, count: 50, + includeNotPresent: true }).then((it) => ({ image: { small: validate(it.artistInfo.smallImageUrl), @@ -401,6 +412,7 @@ export class Navidrome implements MusicService { similarArtist: (it.artistInfo.similarArtist || []).map((artist) => ({ id: artist._id, name: artist._name, + inLibrary: artist._id != "-1", })), })); @@ -698,7 +710,7 @@ export class Navidrome implements MusicService { }, artist: { id: entry._artistId, - name: entry._artist, + name: entry._artist }, })), }; @@ -730,6 +742,24 @@ export class Navidrome implements MusicService { songIndexToRemove: indicies, }) .then((_) => true), + similarSongs: async (id: string) => navidrome + .getJSON(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(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); diff --git a/src/smapi.ts b/src/smapi.ts index bddad26..75ba96c 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -458,7 +458,7 @@ function bindSmapiSoapServiceToExpress( album(urlWithToken(accessToken), it) ), relatedBrowse: - artist.similarArtists.length > 0 + artist.similarArtists.filter(it => it.inLibrary).length > 0 ? [ { id: `relatedArtists:${artist.id}`, @@ -715,6 +715,7 @@ function bindSmapiSoapServiceToExpress( return musicLibrary .artist(typeId!) .then((artist) => artist.similarArtists) + .then(similarArtists => similarArtists.filter(it => it.inLibrary)) .then(slice2(paging)) .then(([page, total]) => { return getMetadataResult({ diff --git a/tests/builders.ts b/tests/builders.ts index 8c5cf90..7bd327a 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -98,8 +98,9 @@ export function anArtist(fields: Partial = {}): Artist { large: `/artist/art/${id}/large`, }, similarArtists: [ - { id: uuid(), name: "Similar artist1" }, - { id: uuid(), name: "Similar artist2" }, + { id: uuid(), name: "Similar artist1", inLibrary: true }, + { id: uuid(), name: "Similar artist2", inLibrary: true }, + { id: "-1", name: "Artist not in library", inLibrary: false }, ], ...fields, }; diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index da0b12f..5039790 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -143,6 +143,8 @@ export class InMemoryMusicService implements MusicService { deletePlaylist: async (_: string) => Promise.reject("Unsupported operation"), addToPlaylist: async (_: string) => Promise.reject("Unsupported operation"), removeFromPlaylist: async (_: string, _2: number[]) => Promise.reject("Unsupported operation"), + similarSongs: async (_: string) => Promise.resolve([]), + topSongs: async (_: string) => Promise.resolve([]), }); } diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index f51adbe..87e4d84 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -34,7 +34,7 @@ import { AlbumQuery, PlaylistSummary, Playlist, - ArtistSummary, + SimilarArtist, } from "../src/music_service"; import { anAlbum, @@ -151,7 +151,7 @@ describe("asURLSearchParams", () => { expect(asURLSearchParams(q)).toEqual(expected); }); - }); + }); }); const ok = (data: string) => ({ @@ -159,8 +159,13 @@ const ok = (data: string) => ({ data, }); -const similarArtistXml = (artistSummary: ArtistSummary) => - ``; +const similarArtistXml = (similarArtist: SimilarArtist) => { + if(similarArtist.inLibrary) + return `` + else + return `` +} + const getArtistInfoXml = ( artist: Artist @@ -222,19 +227,18 @@ const albumListXml = ( ) => ` ${albums - .map(([artist, album]) => albumXml(artist, album)) - .join("")} + .map(([artist, album]) => albumXml(artist, album)) + .join("")} `; -const artistXml = (artist: Artist) => ` +const artistXml = (artist: Artist) => ` ${artist.albums - .map((album) => - albumXml(artist, album) - ) - .join("")} + .map((album) => + albumXml(artist, album) + ) + .join("")} `; const getArtistXml = ( @@ -260,20 +264,32 @@ const getAlbumXml = ( tracks: Track[] ) => ` ${albumXml( - artist, - album, - tracks - )} + artist, + album, + tracks +)} `; const getSongXml = ( track: Track ) => ` ${songXml( - track - )} + track +)} `; +const similarSongsXml = (tracks: Track[]) => ` + + ${tracks.map(songXml).join("")} + + ` + +const topSongsXml = (tracks: Track[]) => ` + + ${tracks.map(songXml).join("")} + + ` + export type ArtistWithAlbum = { artist: Artist; album: Album; @@ -291,7 +307,10 @@ const getPlayLists = ( `; const error = (code: string, message: string) => - ``; + ` + + + `; const createPlayList = (playlist: PlaylistSummary) => ` ${playlistXml(playlist)} @@ -300,9 +319,8 @@ const createPlayList = (playlist: PlaylistSummary) => ` ` - + ${playlist.entries .map( (it) => ` { large: `http://localhost:80/${DODGY_IMAGE_NAME}`, }, similarArtists: [ - { id: "similar1.id", name: "similar1" }, - { id: "similar2.id", name: "similar2" }, + { id: "similar1.id", name: "similar1", inLibrary: true }, + { 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`, { - params:asURLSearchParams( { + params: asURLSearchParams({ ...authParams, id: artist.id, }), @@ -547,14 +567,15 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artist.id, - count: 50 + count: 50, + includeNotPresent: true }), headers, }); }); }); - describe("and has one similar artists", () => { + describe("and has one similar artist", () => { const album1: Album = anAlbum({ genre: asGenre("G1") }); const album2: Album = anAlbum({ genre: asGenre("G2") }); @@ -566,7 +587,7 @@ describe("Navidrome", () => { medium: `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(() => { @@ -611,7 +632,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artist.id, - count: 50 + count: 50, + includeNotPresent: true }), headers, }); @@ -675,7 +697,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artist.id, - count: 50 + count: 50, + includeNotPresent: true }), headers, }); @@ -739,7 +762,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artist.id, - count: 50 + count: 50, + includeNotPresent: true }), headers, }); @@ -794,7 +818,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artist.id, - count: 50 + count: 50, + includeNotPresent: true }), headers, }); @@ -847,7 +872,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artist.id, - count: 50 + count: 50, + includeNotPresent: true }), headers, }); @@ -898,7 +924,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artist.id, - count: 50 + count: 50, + includeNotPresent: true }), headers, }); @@ -2087,7 +2114,7 @@ describe("Navidrome", () => { expect(streamClientApplication).toHaveBeenCalledWith(track); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/stream`, { - params:asURLSearchParams( { + params: asURLSearchParams({ ...authParams, id: trackId, c: clientApplication, @@ -2231,7 +2258,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artistId, - count: 50 + count: 50, + includeNotPresent: true }), headers, } @@ -2307,7 +2335,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artistId, - count: 50 + count: 50, + includeNotPresent: true }), headers, } @@ -2383,7 +2412,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artistId, - count: 50 + count: 50, + includeNotPresent: true }), headers, } @@ -2452,7 +2482,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artistId, - count: 50 + count: 50, + includeNotPresent: true }), headers, } @@ -2531,7 +2562,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artistId, - count: 50 + count: 50, + includeNotPresent: true }), headers, } @@ -2608,7 +2640,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artistId, - count: 50 + count: 50, + includeNotPresent: true }), headers, } @@ -2680,7 +2713,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artistId, - count: 50 + count: 50, + includeNotPresent: true }), headers, } @@ -2757,7 +2791,8 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, id: artistId, - count: 50 + count: 50, + includeNotPresent: true }), headers, } @@ -3343,7 +3378,7 @@ describe("Navidrome", () => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(error("70", "not there"))) + Promise.resolve(ok(error("70", "data not found"))) ); return expect( @@ -3352,7 +3387,7 @@ describe("Navidrome", () => { .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.playlist(id)) - ).rejects.toEqual("not there"); + ).rejects.toEqual("data not found"); }); }); @@ -3447,26 +3482,26 @@ describe("Navidrome", () => { const id = uuid(); mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(createPlayList({id, name}))) - ); + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(createPlayList({ id, name }))) + ); - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.createPlaylist(name)); + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.createPlaylist(name)); - expect(result).toEqual({ id, name }); + expect(result).toEqual({ id, name }); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/createPlaylist`, { - params: asURLSearchParams({ - ...authParams, - name, - }), - headers, - }); + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/createPlaylist`, { + params: asURLSearchParams({ + ...authParams, + name, + }), + headers, + }); }); }); @@ -3475,26 +3510,26 @@ describe("Navidrome", () => { const id = "id-to-delete"; mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve(ok(EMPTY)) - ); + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(EMPTY)) + ); - const result = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.deletePlaylist(id)); + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.deletePlaylist(id)); - expect(result).toEqual(true); + expect(result).toEqual(true); - expect(mockGET).toHaveBeenCalledWith(`${url}/rest/deletePlaylist`, { - params: asURLSearchParams({ - ...authParams, - id, - }), - headers, - }); + expect(mockGET).toHaveBeenCalledWith(`${url}/rest/deletePlaylist`, { + params: asURLSearchParams({ + ...authParams, + id, + }), + headers, + }); }); }); @@ -3522,7 +3557,7 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, playlistId, - songIdToAdd: trackId, + songIdToAdd: trackId, }), headers, }); @@ -3532,7 +3567,7 @@ describe("Navidrome", () => { describe("removing a track from a playlist", () => { it("should remove it", async () => { const playlistId = uuid(); - const indicies =[6, 100, 33]; + const indicies = [6, 100, 33]; mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) @@ -3552,12 +3587,334 @@ describe("Navidrome", () => { params: asURLSearchParams({ ...authParams, playlistId, - songIndexToRemove: indicies, + songIndexToRemove: indicies, }), 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, + }); + }); + }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index b2318b5..3afe9e3 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -1132,18 +1132,22 @@ describe("api", () => { }); 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 relatedArtist2 = anArtist(); const relatedArtist3 = anArtist(); const relatedArtist4 = anArtist(); + const relatedArtist5 = anArtist(); + const relatedArtist6 = anArtist(); const artist = anArtist({ similarArtists: [ - relatedArtist1, - relatedArtist2, - relatedArtist3, - relatedArtist4, + { ...relatedArtist1, inLibrary: true }, + { ...relatedArtist2, inLibrary: true }, + { ...relatedArtist3, inLibrary: false }, + { ...relatedArtist4, inLibrary: true }, + { ...relatedArtist5, inLibrary: false }, + { ...relatedArtist6, inLibrary: true }, ], }); @@ -1163,8 +1167,8 @@ describe("api", () => { mediaCollection: [ relatedArtist1, relatedArtist2, - relatedArtist3, relatedArtist4, + relatedArtist6, ].map((it) => ({ itemType: "artist", id: `artist:${it.id}`, @@ -1193,7 +1197,7 @@ describe("api", () => { }); expect(result[0]).toEqual( getMetadataResult({ - mediaCollection: [relatedArtist2, relatedArtist3].map( + mediaCollection: [relatedArtist2, relatedArtist4].map( (it) => ({ itemType: "artist", id: `artist:${it.id}`, @@ -1238,6 +1242,44 @@ describe("api", () => { 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", () => { @@ -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 similar2 = anArtist(); + const similar3 = anArtist(); + const similar4 = anArtist(); const artist = anArtist({ - similarArtists: [similar1, similar2], + similarArtists: [ + { ...similar1, inLibrary: true }, + { ...similar2, inLibrary: false }, + { ...similar3, inLibrary: false }, + { ...similar4, inLibrary: true }, + ], 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", () => {