diff --git a/src/music_service.ts b/src/music_service.ts index 23b86e1..193b675 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -26,6 +26,11 @@ export type AuthFailure = { export type Artist = { id: string; name: string; + image: { + small: string | undefined, + medium: string | undefined, + large: string | undefined, + } }; export type Album = { diff --git a/src/navidrome.ts b/src/navidrome.ts index 10c7148..a0542e0 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -38,7 +38,12 @@ export type SubsonicResponse = { export type GetArtistsResponse = SubsonicResponse & { artists: { index: { - artist: { _id: string; _name: string; _albumCount: string }[]; + artist: { + _id: string; + _name: string; + _albumCount: string; + _artistImageUrl: string | undefined; + }[]; _name: string; }[]; }; @@ -51,6 +56,19 @@ export type SubsonicError = SubsonicResponse & { }; }; +export type artistInfo = { + biography: string | undefined; + musicBrainzId: string | undefined; + lastFmUrl: string | undefined; + smallImageUrl: string | undefined; + mediumImageUrl: string | undefined; + largeImageUrl: string | undefined; +}; + +export type GetArtistInfoResponse = { + artistInfo: artistInfo; +}; + export function isError( subsonicResponse: SubsonicResponse ): subsonicResponse is SubsonicError { @@ -89,13 +107,15 @@ export class Navidrome implements MusicService { }); generateToken = async (credentials: Credentials) => - this.get(credentials, "/rest/ping.view").then(() => ({ - authToken: Buffer.from( - JSON.stringify(this.encryption.encrypt(JSON.stringify(credentials))) - ).toString("base64"), - userId: credentials.username, - nickname: credentials.username, - })).catch(e => ({ message: `${e}` })); + this.get(credentials, "/rest/ping.view") + .then(() => ({ + authToken: Buffer.from( + JSON.stringify(this.encryption.encrypt(JSON.stringify(credentials))) + ).toString("base64"), + userId: credentials.username, + nickname: credentials.username, + })) + .catch((e) => ({ message: `${e}` })); parseToken = (token: string): Credentials => JSON.parse( @@ -113,13 +133,33 @@ export class Navidrome implements MusicService { .get(credentials, "/rest/getArtists") .then((it) => it.artists.index.flatMap((it) => it.artist)) .then((artists) => - artists.map((it) => ({ id: it._id, name: it._name })) + Promise.all( + artists.map((artist) => + navidrome + .get( + credentials, + "/rest/getArtistInfo", + { id: artist._id } + ) + .then((it) => it.artistInfo) + .then((artistInfo) => ({ + id: artist._id, + name: artist._name, + image: { + small: artistInfo.smallImageUrl, + medium: artistInfo.mediumImageUrl, + large: artistInfo.largeImageUrl, + }, + })) + ) + ) ) .then(slice2(q)) .then(asResult), artist: (id: string) => ({ id, name: id, + image: { small: undefined, medium: undefined, large: undefined }, }), albums: (_: AlbumQuery): Promise> => { return Promise.resolve({ results: [], total: 0 }); diff --git a/src/smapi.ts b/src/smapi.ts index f83d0ab..adca06b 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -6,7 +6,7 @@ import path from "path"; import logger from "./logger"; import { LinkCodes } from "./link_codes"; -import { Artist, MusicLibrary, MusicService } from "./music_service"; +import { Album, Artist, MusicLibrary, MusicService } from "./music_service"; export const LOGIN_ROUTE = "/login"; export const SOAP_PATH = "/ws/sonos"; @@ -218,8 +218,8 @@ function bindSmapiSoapServiceToExpress( case "root": return getMetadataResult({ mediaCollection: [ - container({ id: "artists", title: "Artists" }), - container({ id: "albums", title: "Albums" }), + { itemType: "container", id: "artists", title: "Artists" }, + { itemType: "container", id: "albums", title: "Albums" }, ], index: 0, total: 2, @@ -230,9 +230,12 @@ function bindSmapiSoapServiceToExpress( .then(({ results, total }: { results: Artist[], total: number}) => getMetadataResult({ mediaCollection: results.map((it) => - container({ + ({ + itemType: "artist", id: `artist:${it.id}`, + artistId: it.id, title: it.name, + albumArtURI: it.image.small }) ), index: paging._index, @@ -242,7 +245,7 @@ function bindSmapiSoapServiceToExpress( case "albums": return await musicLibrary .albums(paging) - .then(({ results, total }: { results: Artist[], total: number}) => + .then(({ results, total }: { results: Album[], total: number}) => getMetadataResult({ mediaCollection: results.map((it) => container({ diff --git a/tests/builders.ts b/tests/builders.ts index 1716768..17e5332 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -80,6 +80,11 @@ export const BOB_MARLEY: ArtistWithAlbums = { { id: uuid(), name: "Exodus" }, { id: uuid(), name: "Kaya" }, ], + image: { + small: "http://localhost/BOB_MARLEY/sml", + medium: "http://localhost/BOB_MARLEY/med", + large: "http://localhost/BOB_MARLEY/lge", + } }; export const BLONDIE: ArtistWithAlbums = { @@ -89,12 +94,22 @@ export const BLONDIE: ArtistWithAlbums = { { id: uuid(), name: "Blondie" }, { id: uuid(), name: "Parallel Lines" }, ], + image: { + small: undefined, + medium: undefined, + large: undefined, + } }; export const MADONNA: ArtistWithAlbums = { id: uuid(), name: "Madonna", albums: [], + image: { + small: "http://localhost/MADONNA/sml", + medium: undefined, + large: "http://localhost/MADONNA/lge", + } }; export const METALLICA: ArtistWithAlbums = { @@ -110,6 +125,11 @@ export const METALLICA: ArtistWithAlbums = { name: "Master of Puppets", }, ], + image: { + small: "http://localhost/METALLICA/sml", + medium: "http://localhost/METALLICA/med", + large: "http://localhost/METALLICA/lge", + } }; export const ALL_ALBUMS = [ diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index b6c3b77..14a6885 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -1,4 +1,4 @@ -import { InMemoryMusicService } from "./in_memory_music_service"; +import { InMemoryMusicService, artistWithAlbumsToArtist } from "./in_memory_music_service"; import { AuthSuccess, MusicLibrary } from "../src/music_service"; import { v4 as uuid } from "uuid"; import { @@ -43,6 +43,16 @@ describe("InMemoryMusicService", () => { }); }); + describe("artistWithAlbumsToArtist", () => { + it("should map fields correctly", () => { + expect(artistWithAlbumsToArtist(BOB_MARLEY)).toEqual({ + id: BOB_MARLEY.id, + name: BOB_MARLEY.name, + image: BOB_MARLEY.image + }) + }); + }); + describe("Music Library", () => { const user = { username: "user100", password: "password100" }; let musicLibrary: MusicLibrary; @@ -61,10 +71,10 @@ describe("InMemoryMusicService", () => { describe("fetching all", () => { it("should provide an array of artists", async () => { const artists = [ - { id: BOB_MARLEY.id, name: BOB_MARLEY.name }, - { id: MADONNA.id, name: MADONNA.name }, - { id: BLONDIE.id, name: BLONDIE.name }, - { id: METALLICA.id, name: METALLICA.name }, + artistWithAlbumsToArtist(BOB_MARLEY), + artistWithAlbumsToArtist(MADONNA), + artistWithAlbumsToArtist(BLONDIE), + artistWithAlbumsToArtist(METALLICA), ]; expect(await musicLibrary.artists({ _index: 0, _count: 100 })).toEqual({ results: artists, @@ -76,8 +86,8 @@ describe("InMemoryMusicService", () => { describe("fetching the second page", () => { it("should provide an array of artists", async () => { const artists = [ - { id: BLONDIE.id, name: BLONDIE.name }, - { id: METALLICA.id, name: METALLICA.name }, + artistWithAlbumsToArtist(BLONDIE), + artistWithAlbumsToArtist(METALLICA), ]; expect(await musicLibrary.artists({ _index: 2, _count: 2 })).toEqual({ results: artists, @@ -89,9 +99,9 @@ describe("InMemoryMusicService", () => { describe("fetching the more items than fit on the second page", () => { it("should provide an array of artists", async () => { const artists = [ - { id: MADONNA.id, name: MADONNA.name }, - { id: BLONDIE.id, name: BLONDIE.name }, - { id: METALLICA.id, name: METALLICA.name }, + artistWithAlbumsToArtist(MADONNA), + artistWithAlbumsToArtist(BLONDIE), + artistWithAlbumsToArtist(METALLICA), ]; expect( await musicLibrary.artists({ _index: 1, _count: 50 }) @@ -103,14 +113,8 @@ describe("InMemoryMusicService", () => { describe("artist", () => { describe("when it exists", () => { it("should provide an artist", () => { - expect(musicLibrary.artist(MADONNA.id)).toEqual({ - id: MADONNA.id, - name: MADONNA.name, - }); - expect(musicLibrary.artist(BLONDIE.id)).toEqual({ - id: BLONDIE.id, - name: BLONDIE.name, - }); + expect(musicLibrary.artist(MADONNA.id)).toEqual(artistWithAlbumsToArtist(MADONNA)); + expect(musicLibrary.artist(BLONDIE.id)).toEqual(artistWithAlbumsToArtist(BLONDIE)); }); }); diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 00defc5..2282b1c 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -15,9 +15,10 @@ import { asResult, } from "../src/music_service"; -const artistWithAlbumsToArtist = (it: ArtistWithAlbums): Artist => ({ +export const artistWithAlbumsToArtist = (it: ArtistWithAlbums): Artist => ({ id: it.id, name: it.name, + image: it.image }); const getOrThrow = (message: string) => diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index e3226a6..df7066c 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -1,6 +1,6 @@ import { Md5 } from "ts-md5/dist/md5"; -import { Navidrome, t } from "../src/navidrome"; +import { Navidrome, t, artistInfo } from "../src/navidrome"; import encryption from "../src/encryption"; import axios from "axios"; @@ -18,6 +18,28 @@ describe("t", () => { }); }); +const ok = (data: string) => ({ + status: 200, + data, +}); + +const artistInfoXml = ( + artistInfo: Partial +) => ` + + + + + ${artistInfo.smallImageUrl || ""} + ${ + artistInfo.mediumImageUrl || "" + } + ${artistInfo.largeImageUrl || ""} + + `; + +const PING_OK = ``; + describe("navidrome", () => { const url = "http://127.0.0.22:4567"; const username = "user1"; @@ -27,11 +49,12 @@ describe("navidrome", () => { const navidrome = new Navidrome(url, encryption("secret")); const mockedRandomString = (randomString as unknown) as jest.Mock; + const mockGET = jest.fn() beforeEach(() => { jest.clearAllMocks(); - axios.get = jest.fn(); + axios.get = mockGET; mockedRandomString.mockReturnValue(salt); }); @@ -47,13 +70,12 @@ describe("navidrome", () => { describe("generateToken", () => { describe("when the credentials are valid", () => { it("should be able to generate a token and then login using it", async () => { - (axios.get as jest.Mock).mockResolvedValue({ - status: 200, - data: ` - `, - }); + (axios.get as jest.Mock).mockResolvedValue(ok(PING_OK)); - const token = await navidrome.generateToken({ username, password }) as AuthSuccess; + const token = (await navidrome.generateToken({ + username, + password, + })) as AuthSuccess; expect(token.authToken).toBeDefined(); expect(token.nickname).toEqual(username); @@ -75,51 +97,117 @@ describe("navidrome", () => { }); const token = await navidrome.generateToken({ username, password }); - expect(token).toEqual({ message: "Wrong username or password" }) + expect(token).toEqual({ message: "Wrong username or password" }); }); }); }); describe("getArtists", () => { + const getArtistsXml = ` + + + + + + + + + + + + + `; + + const artist1_getArtistInfoXml = artistInfoXml({ + smallImageUrl: "sml1", + mediumImageUrl: "med1", + largeImageUrl: "lge1", + }); + const artist2_getArtistInfoXml = artistInfoXml({ + smallImageUrl: "sml2", + mediumImageUrl: undefined, + largeImageUrl: "lge2", + }); + const artist3_getArtistInfoXml = artistInfoXml({ + smallImageUrl: undefined, + mediumImageUrl: "med3", + largeImageUrl: undefined, + }); + const artist4_getArtistInfoXml = artistInfoXml({ + smallImageUrl: "sml4", + mediumImageUrl: "med4", + largeImageUrl: "lge4", + }); + beforeEach(() => { - (axios.get as jest.Mock).mockResolvedValue({ - status: 200, - data: ` - - - - - - - - - - - - - `, - }); + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => Promise.resolve(ok(getArtistsXml))) + .mockImplementationOnce(() => Promise.resolve(ok(artist1_getArtistInfoXml))) + .mockImplementationOnce(() => Promise.resolve(ok(artist2_getArtistInfoXml))) + .mockImplementationOnce(() => Promise.resolve(ok(artist3_getArtistInfoXml))) + .mockImplementationOnce(() => Promise.resolve(ok(artist4_getArtistInfoXml))); }); describe("when no paging is in effect", () => { it("should return all the artists", async () => { const artists = await navidrome .generateToken({ username, password }) - .then(it => it as AuthSuccess) + .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artists({ _index: 0, _count: 100 })); const expectedArtists = [ - { id: "2911b2d67a6b11eb804dd360a6225680", name: "10 Planets" }, - { id: "3c0b9d7a7a6b11eb9773f398e6236ad6", name: "1200 Ounces" }, - { id: "3c5113007a6b11eb87173bfb9b07f9b1", name: "AAAB" }, - { id: "3ca781c27a6b11eb897ebbb5773603ad", name: "BAAB" }, + { + id: "2911b2d67a6b11eb804dd360a6225680", + name: "artist1", + image: { small: "sml1", medium: "med1", large: "lge1" }, + }, + { + id: "3c0b9d7a7a6b11eb9773f398e6236ad6", + name: "artist2", + image: { small: "sml2", medium: "", large: "lge2" }, + }, + { + id: "3c5113007a6b11eb87173bfb9b07f9b1", + name: "artist3", + image: { small: "", medium: "med3", large: "" }, + }, + { + id: "3ca781c27a6b11eb897ebbb5773603ad", + name: "artist4", + image: { small: "sml4", medium: "med4", large: "lge4" }, + }, ]; expect(artists).toEqual({ results: expectedArtists, total: 4 }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { params: authParams, }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: "2911b2d67a6b11eb804dd360a6225680", + ...authParams, + }, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: "3c0b9d7a7a6b11eb9773f398e6236ad6", + ...authParams, + }, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: "3c5113007a6b11eb87173bfb9b07f9b1", + ...authParams, + }, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: "3ca781c27a6b11eb897ebbb5773603ad", + ...authParams, + }, + }); }); }); @@ -127,19 +215,51 @@ describe("navidrome", () => { it("should return only the correct page of artists", async () => { const artists = await navidrome .generateToken({ username, password }) - .then(it => it as AuthSuccess) + .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) .then((it) => it.artists({ _index: 1, _count: 2 })); - const expectedArtists = [ - { id: "3c0b9d7a7a6b11eb9773f398e6236ad6", name: "1200 Ounces" }, - { id: "3c5113007a6b11eb87173bfb9b07f9b1", name: "AAAB" }, - ]; - expect(artists).toEqual({ results: expectedArtists, total: 4 }); + const expectedArtists = [ + { + id: "3c0b9d7a7a6b11eb9773f398e6236ad6", + name: "artist2", + image: { small: "sml2", medium: "", large: "lge2" }, + }, + { + id: "3c5113007a6b11eb87173bfb9b07f9b1", + name: "artist3", + image: { small: "", medium: "med3", large: "" }, + }, + ]; + expect(artists).toEqual({ results: expectedArtists, total: 4 }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { params: authParams, }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: "2911b2d67a6b11eb804dd360a6225680", + ...authParams, + }, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: "3c0b9d7a7a6b11eb9773f398e6236ad6", + ...authParams, + }, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: "3c5113007a6b11eb87173bfb9b07f9b1", + ...authParams, + }, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: "3ca781c27a6b11eb897ebbb5773603ad", + ...authParams, + }, + }); }); }); }); diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index d59a825..fee22c2 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -377,9 +377,15 @@ describe("api", () => { }); expect(artists[0]).toEqual( getMetadataResult({ - mediaCollection: [BLONDIE, BOB_MARLEY].map((it) => container({ id: `artist:${it.id}`, title: it.name })), + mediaCollection: [BLONDIE, BOB_MARLEY].map((it) => ({ + itemType: "artist", + id: `artist:${it.id}`, + artistId: it.id, + title: it.name, + albumArtURI: it.image.small, + })), index: 0, - total: 2 + total: 2, }) ); }); @@ -403,7 +409,7 @@ describe("api", () => { container({ id: `album:${it.id}`, title: it.name }) ), index: 0, - total: BLONDIE.albums.length + BOB_MARLEY.albums.length + total: BLONDIE.albums.length + BOB_MARLEY.albums.length, }) ); }); @@ -434,7 +440,7 @@ describe("api", () => { }), ], index: 2, - total: BLONDIE.albums.length + BOB_MARLEY.albums.length + total: BLONDIE.albums.length + BOB_MARLEY.albums.length, }) ); });