diff --git a/src/music_service.ts b/src/music_service.ts index 6af4ab7..ee8b277 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -35,11 +35,14 @@ export type Images = { } export type Artist = ArtistSummary & { + albums: Album[] }; export type Album = { id: string; name: string; + year: string | undefined; + genre: string | undefined; }; export type Paging = { diff --git a/src/navidrome.ts b/src/navidrome.ts index a4fe7b5..143fc1b 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -30,6 +30,9 @@ export const t_and_s = (password: string) => { }; }; +export const isDodgyImage = (url: string) => + url.endsWith("2a96cbd8b46e442fc41c2b86b821562f.png"); + export type SubconicEnvelope = { "subsonic-response": SubsonicResponse; }; @@ -38,11 +41,20 @@ export type SubsonicResponse = { _status: string; }; +export type album = { + _id: string; + _name: string; + _genre: string | undefined; + _year: string | undefined; + _coverArt: string; +}; + export type artist = { _id: string; _name: string; _albumCount: string; _artistImageUrl: string | undefined; + album: album[]; }; export type GetArtistsResponse = SubsonicResponse & { @@ -163,11 +175,25 @@ export class Navidrome implements MusicService { }, })); - getArtist = (credentials: Credentials, id: string): Promise => + getArtist = ( + credentials: Credentials, + id: string + ): Promise => this.get(credentials, "/rest/getArtist", { id, - }).then((it) => ({ id: it.artist._id, name: it.artist._name })); - + }) + .then((it) => it.artist) + .then((it) => ({ + id: it._id, + name: it._name, + albums: it.album.map((album) => ({ + id: album._id, + name: album._name, + year: album._year, + genre: album._genre, + })), + })); + async login(token: string) { const navidrome = this; const credentials: Credentials = this.parseToken(token); @@ -208,6 +234,7 @@ export class Navidrome implements MusicService { id: artist.id, name: artist.name, image: artistInfo.image, + albums: artist.albums, })), albums: (_: AlbumQuery): Promise> => { return Promise.resolve({ results: [], total: 0 }); diff --git a/tests/builders.ts b/tests/builders.ts index 17e5332..f506ce3 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -3,7 +3,7 @@ import { v4 as uuid } from "uuid"; import { Credentials } from "../src/smapi"; import { Service, Device } from "../src/sonos"; -import { Album, Artist } from "../src/music_service"; +import { Artist } from "../src/music_service"; const randomInt = (max: number) => Math.floor(Math.random() * max); const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`; @@ -68,40 +68,46 @@ export function someCredentials(token: string): Credentials { }; } -export type ArtistWithAlbums = Artist & { - albums: Album[]; -}; - -export const BOB_MARLEY: ArtistWithAlbums = { +export const BOB_MARLEY: Artist = { id: uuid(), name: "Bob Marley", albums: [ - { id: uuid(), name: "Burin'" }, - { id: uuid(), name: "Exodus" }, - { id: uuid(), name: "Kaya" }, + { id: uuid(), name: "Burin'", year: "1973", genre: "Reggae" }, + { id: uuid(), name: "Exodus", year: "1977", genre: "Reggae" }, + { id: uuid(), name: "Kaya", year: "1978", genre: "Ska" }, ], image: { small: "http://localhost/BOB_MARLEY/sml", medium: "http://localhost/BOB_MARLEY/med", large: "http://localhost/BOB_MARLEY/lge", - } + }, }; -export const BLONDIE: ArtistWithAlbums = { +export const BLONDIE: Artist = { id: uuid(), name: "Blondie", albums: [ - { id: uuid(), name: "Blondie" }, - { id: uuid(), name: "Parallel Lines" }, + { + id: uuid(), + name: "Blondie", + year: "1976", + genre: "New Wave", + }, + { + id: uuid(), + name: "Parallel Lines", + year: "1978", + genre: "Pop Rock", + }, ], image: { small: undefined, medium: undefined, large: undefined, - } + }, }; -export const MADONNA: ArtistWithAlbums = { +export const MADONNA: Artist = { id: uuid(), name: "Madonna", albums: [], @@ -109,27 +115,31 @@ export const MADONNA: ArtistWithAlbums = { small: "http://localhost/MADONNA/sml", medium: undefined, large: "http://localhost/MADONNA/lge", - } + }, }; -export const METALLICA: ArtistWithAlbums = { +export const METALLICA: Artist = { id: uuid(), name: "Metallica", albums: [ { id: uuid(), name: "Ride the Lightening", + year: "1984", + genre: "Heavy Metal", }, { id: uuid(), name: "Master of Puppets", + year: "1986", + genre: "Heavy Metal", }, ], image: { small: "http://localhost/METALLICA/sml", medium: "http://localhost/METALLICA/med", large: "http://localhost/METALLICA/lge", - } + }, }; export const ALL_ALBUMS = [ @@ -137,4 +147,4 @@ export const ALL_ALBUMS = [ ...BLONDIE.albums, ...MADONNA.albums, ...METALLICA.albums, -]; \ No newline at end of file +]; diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index f07e22a..a9438cb 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -1,7 +1,6 @@ import { InMemoryMusicService, - artistWithAlbumsToArtist, - artistWithAlbumsToArtistSummary, + artistToArtistSummary, } from "./in_memory_music_service"; import { AuthSuccess, MusicLibrary } from "../src/music_service"; import { v4 as uuid } from "uuid"; @@ -47,19 +46,9 @@ describe("InMemoryMusicService", () => { }); }); - describe("artistWithAlbumsToArtist", () => { + describe("artistToArtistSummary", () => { it("should map fields correctly", () => { - expect(artistWithAlbumsToArtist(BOB_MARLEY)).toEqual({ - id: BOB_MARLEY.id, - name: BOB_MARLEY.name, - image: BOB_MARLEY.image, - }); - }); - }); - - describe("artistWithAlbumsToArtistSummary", () => { - it("should map fields correctly", () => { - expect(artistWithAlbumsToArtistSummary(BOB_MARLEY)).toEqual({ + expect(artistToArtistSummary(BOB_MARLEY)).toEqual({ id: BOB_MARLEY.id, name: BOB_MARLEY.name, image: BOB_MARLEY.image, @@ -85,10 +74,10 @@ describe("InMemoryMusicService", () => { describe("fetching all", () => { it("should provide an array of artists", async () => { const artists = [ - artistWithAlbumsToArtistSummary(BOB_MARLEY), - artistWithAlbumsToArtistSummary(MADONNA), - artistWithAlbumsToArtistSummary(BLONDIE), - artistWithAlbumsToArtistSummary(METALLICA), + artistToArtistSummary(BOB_MARLEY), + artistToArtistSummary(MADONNA), + artistToArtistSummary(BLONDIE), + artistToArtistSummary(METALLICA), ]; expect( await musicLibrary.artists({ _index: 0, _count: 100 }) @@ -102,8 +91,8 @@ describe("InMemoryMusicService", () => { describe("fetching the second page", () => { it("should provide an array of artists", async () => { const artists = [ - artistWithAlbumsToArtistSummary(BLONDIE), - artistWithAlbumsToArtistSummary(METALLICA), + artistToArtistSummary(BLONDIE), + artistToArtistSummary(METALLICA), ]; expect(await musicLibrary.artists({ _index: 2, _count: 2 })).toEqual({ results: artists, @@ -115,9 +104,9 @@ describe("InMemoryMusicService", () => { describe("fetching the more items than fit on the second page", () => { it("should provide an array of artists", async () => { const artists = [ - artistWithAlbumsToArtistSummary(MADONNA), - artistWithAlbumsToArtistSummary(BLONDIE), - artistWithAlbumsToArtistSummary(METALLICA), + artistToArtistSummary(MADONNA), + artistToArtistSummary(BLONDIE), + artistToArtistSummary(METALLICA), ]; expect( await musicLibrary.artists({ _index: 1, _count: 50 }) @@ -130,10 +119,10 @@ describe("InMemoryMusicService", () => { describe("when it exists", () => { it("should provide an artist", async () => { expect(await musicLibrary.artist(MADONNA.id)).toEqual( - artistWithAlbumsToArtist(MADONNA) + MADONNA ); expect(await musicLibrary.artist(BLONDIE.id)).toEqual( - artistWithAlbumsToArtist(BLONDIE) + BLONDIE ); }); }); diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 74a7dd5..3121132 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -1,7 +1,7 @@ import { option as O } from "fp-ts"; import { pipe } from "fp-ts/lib/function"; -import { ArtistWithAlbums } from "./builders"; + import { MusicService, Credentials, @@ -16,18 +16,14 @@ import { ArtistSummary, } from "../src/music_service"; -export const artistWithAlbumsToArtistSummary = ( - it: ArtistWithAlbums +export const artistToArtistSummary = ( + it: Artist ): ArtistSummary => ({ id: it.id, name: it.name, image: it.image, }); -export const artistWithAlbumsToArtist = (it: ArtistWithAlbums): Artist => ({ - ...artistWithAlbumsToArtistSummary(it), -}); - type P = (t: T) => boolean; const all: P = (_: any) => true; const artistWithId = (id: string): P => (artist: Artist) => @@ -35,7 +31,7 @@ const artistWithId = (id: string): P => (artist: Artist) => export class InMemoryMusicService implements MusicService { users: Record = {}; - artists: ArtistWithAlbums[] = []; + artists: Artist[] = []; generateToken({ username, @@ -62,14 +58,13 @@ export class InMemoryMusicService implements MusicService { return Promise.reject("Invalid auth token"); return Promise.resolve({ artists: (q: ArtistQuery) => - Promise.resolve(this.artists.map(artistWithAlbumsToArtistSummary)) + Promise.resolve(this.artists.map(artistToArtistSummary)) .then(slice2(q)) .then(asResult), artist: (id: string) => pipe( this.artists.find((it) => it.id === id), O.fromNullable, - O.map(artistWithAlbumsToArtist), O.map(it => Promise.resolve(it)), O.getOrElse(() => Promise.reject(`No artist with id '${id}'`)) ), @@ -99,7 +94,7 @@ export class InMemoryMusicService implements MusicService { return this; } - hasArtists(...newArtists: ArtistWithAlbums[]) { + hasArtists(...newArtists: Artist[]) { this.artists = [...this.artists, ...newArtists]; return this; } diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index fcacbe3..8e097ec 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -1,13 +1,13 @@ import { Md5 } from "ts-md5/dist/md5"; -import { Navidrome, t } from "../src/navidrome"; +import { isDodgyImage, Navidrome, t } from "../src/navidrome"; import encryption from "../src/encryption"; import axios from "axios"; jest.mock("axios"); import randomString from "../src/random_string"; -import { Artist, AuthSuccess, Images } from "../src/music_service"; +import { Album, Artist, AuthSuccess, Images } from "../src/music_service"; jest.mock("../src/random_string"); describe("t", () => { @@ -18,6 +18,26 @@ describe("t", () => { }); }); +describe("isDodgyImage", () => { + describe("when ends with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { + it("is dodgy", () => { + expect( + isDodgyImage("http://something/2a96cbd8b46e442fc41c2b86b821562f.png") + ).toEqual(true); + }); + }); + describe("when does not end with 2a96cbd8b46e442fc41c2b86b821562f.png", () => { + it("is dodgy", () => { + expect(isDodgyImage("http://something/somethingelse.png")).toEqual(false); + expect( + isDodgyImage( + "http://something/2a96cbd8b46e442fc41c2b86b821562f.png?withsomequerystring=true" + ) + ).toEqual(false); + }); + }); +}); + const ok = (data: string) => ({ status: 200, data, @@ -36,6 +56,31 @@ const artistInfoXml = ( `; +const albumXml = (artist: Artist, album: Album) => ``; + +const artistXml = ( + artist: Artist +) => ` + + ${artist.albums.map((album) => albumXml(artist, album))} + + `; + const PING_OK = ``; describe("Navidrome", () => { @@ -101,50 +146,64 @@ describe("Navidrome", () => { }); describe("getArtist", () => { - const artistId = "someUUID_123"; - const artistName = "BananaMan"; + const album1: Album = { + id: "album1", + name: "super album", + year: "2001", + genre: "Pop", + }; - const artistXml = ` - - - `; + const album2: Album = { + id: "album2", + name: "bad album", + year: "2002", + genre: "Rock", + }; - const getArtistInfoXml = artistInfoXml({ - small: "sml1", - medium: "med1", - large: "lge1", - }); + const artist: Artist = { + id: "someUUID_123", + name: "BananaMan", + image: { + small: "sml1", + medium: "med1", + large: "lge1", + }, + albums: [album1, album2], + }; beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(artistXml))) - .mockImplementationOnce(() => Promise.resolve(ok(getArtistInfoXml))); + .mockImplementationOnce(() => Promise.resolve(ok(artistXml(artist)))) + .mockImplementationOnce(() => + Promise.resolve(ok(artistInfoXml(artist.image))) + ); }); - it("should do it", async () => { - const artist = await navidrome + it.only("should do it", async () => { + const result: Artist = await navidrome .generateToken({ username, password }) .then((it) => it as AuthSuccess) .then((it) => navidrome.login(it.authToken)) - .then((it) => it.artist(artistId)); + .then((it) => it.artist(artist.id)); - expect(artist).toEqual({ - id: artistId, - name: artistName, + expect(result).toEqual({ + id: artist.id, + name: artist.name, image: { small: "sml1", medium: "med1", large: "lge1" }, + albums: [album1, album2] }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { params: { - id: artistId, + id: artist.id, ...authParams, }, }); expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { params: { - id: artistId, + id: artist.id, ...authParams, }, }); @@ -152,42 +211,6 @@ describe("Navidrome", () => { }); describe("getArtists", () => { - const artist1: Artist = { - id: "artist1.id", - name: "artist1.name", - image: { small: "s1", medium: "m1", large: "l1" }, - }; - const artist2: Artist = { - id: "artist2.id", - name: "artist2.name", - image: { small: "s2", medium: "m2", large: "l2" }, - }; - const artist3: Artist = { - id: "artist3.id", - name: "artist3.name", - image: { small: "s3", medium: "m3", large: "l3" }, - }; - const artist4: Artist = { - id: "artist4.id", - name: "artist4.name", - image: { small: "s4", medium: "m4", large: "l4" }, - }; - - const getArtistsXml = ` - - - - - - - - - - - - - `; - describe("when there are no results", () => { beforeEach(() => { mockGET @@ -222,103 +245,159 @@ describe("Navidrome", () => { }); }); - describe("when no paging is in effect", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(getArtistsXml))) - .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist1.image))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist2.image))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist3.image))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist4.image))) + describe("when there are artists", () => { + const artist1: Artist = { + id: "artist1.id", + name: "artist1.name", + image: { small: "s1", medium: "m1", large: "l1" }, + albums: [], + }; + const artist2: Artist = { + id: "artist2.id", + name: "artist2.name", + image: { small: "s2", medium: "m2", large: "l2" }, + albums: [], + }; + const artist3: Artist = { + id: "artist3.id", + name: "artist3.name", + image: { small: "s3", medium: "m3", large: "l3" }, + albums: [], + }; + const artist4: Artist = { + id: "artist4.id", + name: "artist4.name", + image: { small: "s4", medium: "m4", large: "l4" }, + albums: [], + }; + + const getArtistsXml = ` + + + + + + + + + + + + + `; + + describe("when no paging is in effect", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => Promise.resolve(ok(getArtistsXml))) + .mockImplementationOnce(() => + Promise.resolve(ok(artistInfoXml(artist1.image))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(artistInfoXml(artist2.image))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(artistInfoXml(artist3.image))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(artistInfoXml(artist4.image))) + ); + }); + + it("should return all the artists", async () => { + const artists = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.artists({ _index: 0, _count: 100 })); + + const expectedResults = [artist1, artist2, artist3, artist4].map( + (it) => ({ + id: it.id, + name: it.name, + image: it.image, + }) ); + + expect(artists).toEqual({ + results: expectedResults, + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: authParams, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: artist1.id, + ...authParams, + }, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: artist2.id, + ...authParams, + }, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: artist3.id, + ...authParams, + }, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: artist4.id, + ...authParams, + }, + }); + }); }); - it("should return all the artists", async () => { - const artists = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.artists({ _index: 0, _count: 100 })); - - expect(artists).toEqual({ - results: [artist1, artist2, artist3, artist4], - total: 4, + describe("when paging specified", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => Promise.resolve(ok(getArtistsXml))) + .mockImplementationOnce(() => + Promise.resolve(ok(artistInfoXml(artist2.image))) + ) + .mockImplementationOnce(() => + Promise.resolve(ok(artistInfoXml(artist3.image))) + ); }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: authParams, - }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist1.id, - ...authParams, - }, - }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist2.id, - ...authParams, - }, - }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist3.id, - ...authParams, - }, - }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist4.id, - ...authParams, - }, - }); - }); - }); + it("should return only the correct page of artists", async () => { + const artists = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.artists({ _index: 1, _count: 2 })); - describe("when paging specified", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(getArtistsXml))) - .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist2.image))) - ) - .mockImplementationOnce(() => - Promise.resolve(ok(artistInfoXml(artist3.image))) - ); - }); + const expectedResults = [artist2, artist3].map((it) => ({ + id: it.id, + name: it.name, + image: it.image, + })); - it("should return only the correct page of artists", async () => { - const artists = await navidrome - .generateToken({ username, password }) - .then((it) => it as AuthSuccess) - .then((it) => navidrome.login(it.authToken)) - .then((it) => it.artists({ _index: 1, _count: 2 })); + expect(artists).toEqual({ results: expectedResults, total: 4 }); - expect(artists).toEqual({ results: [artist2, artist3], total: 4 }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: authParams, - }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist2.id, - ...authParams, - }, - }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist3.id, - ...authParams, - }, + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: authParams, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: artist2.id, + ...authParams, + }, + }); + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: artist3.id, + ...authParams, + }, + }); }); }); });