From 1e5d020a75b5da93b1f75bd86f29b60aef93452a Mon Sep 17 00:00:00 2001 From: simojenki Date: Sat, 6 Mar 2021 20:06:08 +1100 Subject: [PATCH] Query albums by genre --- src/music_service.ts | 21 +- src/navidrome.ts | 52 +++- src/smapi.ts | 23 +- tests/builders.ts | 27 +- tests/in_memory_music_service.test.ts | 361 ++++++++++++++++++++------ tests/in_memory_music_service.ts | 64 ++--- tests/navidrome.test.ts | 299 ++++++++++++++++----- 7 files changed, 639 insertions(+), 208 deletions(-) diff --git a/src/music_service.ts b/src/music_service.ts index 6a838c6..16cbc72 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -74,7 +74,26 @@ export type ArtistQuery = Paging export type AlbumQuery = Paging & { artistId?: string + genre?: string } + +export const artistToArtistSummary = ( + it: Artist +): ArtistSummary => ({ + id: it.id, + name: it.name, + image: it.image, +}); + +export const albumToAlbumSummary = ( + it: Album +): AlbumSummary => ({ + id: it.id, + name: it.name, + year: it.year, + genre: it.genre, +}); + export interface MusicService { generateToken(credentials: Credentials): Promise; login(authToken: string): Promise; @@ -84,5 +103,5 @@ export interface MusicLibrary { artists(q: ArtistQuery): Promise>; artist(id: string): Promise; albums(q: AlbumQuery): Promise>; - // album(id: string): Promise; + album(id: string): Promise; } diff --git a/src/navidrome.ts b/src/navidrome.ts index e084400..f3d1e35 100644 --- a/src/navidrome.ts +++ b/src/navidrome.ts @@ -1,3 +1,5 @@ +import { option as O } from "fp-ts"; +import { pipe } from "fp-ts/lib/function"; import { Md5 } from "ts-md5/dist/md5"; import { Credentials, @@ -19,6 +21,7 @@ import axios from "axios"; import { Encryption } from "./encryption"; import randomString from "./random_string"; + export const t = (password: string, s: string) => Md5.hashStr(`${password}${s}`); @@ -66,6 +69,12 @@ export type GetArtistsResponse = SubsonicResponse & { }; }; +export type GetAlbumListResponse = SubsonicResponse & { + albumList: { + album: album[]; + }; +}; + export type SubsonicError = SubsonicResponse & { error: { _code: string; @@ -105,6 +114,17 @@ export type IdName = { name: string; }; +export type getAlbumListParams = { + type: string, + size?: number; + offet?: number; + fromYear?: string, + toYear?: string, + genre?: string +} + +const MAX_ALBUM_LIST = 500; + export class Navidrome implements MusicService { url: string; encryption: Encryption; @@ -235,8 +255,36 @@ export class Navidrome implements MusicService { image: artistInfo.image, albums: artist.albums, })), - albums: (_: AlbumQuery): Promise> => { - return Promise.resolve({ results: [], total: 0 }); + albums: (q: AlbumQuery): Promise> => { + const p = pipe( + O.fromNullable(q.genre), + O.map(genre => ({ type: "byGenre", genre })), + O.getOrElse(() => ({ type: "alphabeticalByArtist" })), + ) + + return navidrome + .get(credentials, "/rest/getAlbumList", { + ...p, + size: MAX_ALBUM_LIST, + offset: 0, + }) + .then((response) => response.albumList.album) + .then((albumList) => + albumList.map((album) => ({ + id: album._id, + name: album._name, + year: album._year, + genre: album._genre, + })) + ) + .then(slice2(q)) + .then(([page, total]) => ({ + results: page, + total: Math.min(MAX_ALBUM_LIST, total), + })); + }, + album: (_: string): Promise => { + return Promise.reject("not implemented"); }, }; diff --git a/src/smapi.ts b/src/smapi.ts index ba4be4d..d67e419 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -6,7 +6,12 @@ import path from "path"; import logger from "./logger"; import { LinkCodes } from "./link_codes"; -import { AlbumSummary, MusicLibrary, MusicService, slice2 } from "./music_service"; +import { + AlbumSummary, + MusicLibrary, + MusicService, + slice2, +} from "./music_service"; export const LOGIN_ROUTE = "/login"; export const SOAP_PATH = "/ws/sonos"; @@ -250,14 +255,14 @@ function bindSmapiSoapServiceToExpress( total: result.total, }) ); - case "albums": - return await musicLibrary.albums(paging).then((result) => - getMetadataResult({ - mediaCollection: result.results.map(album), - index: paging._index, - total: result.total, - }) - ); + case "albums": + return await musicLibrary.albums(paging).then((result) => + getMetadataResult({ + mediaCollection: result.results.map(album), + index: paging._index, + total: result.total, + }) + ); case "artist": return await musicLibrary .artist(typeId!) diff --git a/tests/builders.ts b/tests/builders.ts index 3288bc7..3816c22 100644 --- a/tests/builders.ts +++ b/tests/builders.ts @@ -5,7 +5,7 @@ import { Credentials } from "../src/smapi"; import { Service, Device } from "../src/sonos"; import { Album, Artist } from "../src/music_service"; -const randomInt = (max: number) => Math.floor(Math.random() * max); +const randomInt = (max: number) => Math.floor(Math.random() * Math.floor(max)); const randomIpAddress = () => `127.0.${randomInt(255)}.${randomInt(255)}`; export const aService = (fields: Partial = {}): Service => ({ @@ -73,25 +73,26 @@ export function anArtist(fields: Partial = {}): Artist { return { id, name: `Artist ${id}`, - albums: [], + albums: [anAlbum(), anAlbum(), anAlbum()], image: { - small: undefined, - medium: undefined, - large: undefined + small: `/artist/art/${id}/small`, + medium: `/artist/art/${id}/small`, + large: `/artist/art/${id}/large`, }, - ...fields - } + ...fields, + }; } export function anAlbum(fields: Partial = {}): Album { + const genres = ["Metal", "Pop", "Rock", "Hip-Hop"]; const id = uuid(); return { id, name: `Album ${id}`, - genre: "Metal", - year: "1900", - ...fields - } + genre: genres[randomInt(genres.length)], + year: `19${randomInt(99)}`, + ...fields, + }; } export const BLONDIE: Artist = { @@ -168,6 +169,6 @@ export const METALLICA: Artist = { }, }; -export const ALL_ARTISTS = [BOB_MARLEY, BLONDIE, MADONNA, METALLICA] +export const ALL_ARTISTS = [BOB_MARLEY, BLONDIE, MADONNA, METALLICA]; -export const ALL_ALBUMS = ALL_ARTISTS.flatMap(it => it.albums || []); +export const ALL_ALBUMS = ALL_ARTISTS.flatMap((it) => it.albums || []); diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index a9438cb..cd3eb20 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -1,16 +1,12 @@ +import { InMemoryMusicService } from "./in_memory_music_service"; import { - InMemoryMusicService, + AuthSuccess, + MusicLibrary, artistToArtistSummary, -} from "./in_memory_music_service"; -import { AuthSuccess, MusicLibrary } from "../src/music_service"; + albumToAlbumSummary, +} from "../src/music_service"; import { v4 as uuid } from "uuid"; -import { - BOB_MARLEY, - MADONNA, - BLONDIE, - METALLICA, - ALL_ALBUMS, -} from "./builders"; +import { anArtist, anAlbum } from "./builders"; describe("InMemoryMusicService", () => { const service = new InMemoryMusicService(); @@ -48,10 +44,19 @@ describe("InMemoryMusicService", () => { describe("artistToArtistSummary", () => { it("should map fields correctly", () => { - expect(artistToArtistSummary(BOB_MARLEY)).toEqual({ - id: BOB_MARLEY.id, - name: BOB_MARLEY.name, - image: BOB_MARLEY.image, + const artist = anArtist({ + id: uuid(), + name: "The Artist", + image: { + small: "/path/to/small/jpg", + medium: "/path/to/medium/jpg", + large: "/path/to/large/jpg", + }, + }); + expect(artistToArtistSummary(artist)).toEqual({ + id: artist.id, + name: artist.name, + image: artist.image, }); }); }); @@ -63,7 +68,6 @@ describe("InMemoryMusicService", () => { beforeEach(async () => { service.clear(); - service.hasArtists(BOB_MARLEY, MADONNA, BLONDIE, METALLICA); service.hasUser(user); const token = (await service.generateToken(user)) as AuthSuccess; @@ -71,59 +75,67 @@ describe("InMemoryMusicService", () => { }); describe("artists", () => { - describe("fetching all", () => { + const artist1 = anArtist(); + const artist2 = anArtist(); + const artist3 = anArtist(); + const artist4 = anArtist(); + const artist5 = anArtist(); + + beforeEach(() => { + service.hasArtists(artist1, artist2, artist3, artist4, artist5); + }); + + describe("fetching all in one page", () => { it("should provide an array of artists", async () => { - const artists = [ - artistToArtistSummary(BOB_MARLEY), - artistToArtistSummary(MADONNA), - artistToArtistSummary(BLONDIE), - artistToArtistSummary(METALLICA), - ]; expect( await musicLibrary.artists({ _index: 0, _count: 100 }) ).toEqual({ - results: artists, - total: 4, + results: [ + artistToArtistSummary(artist1), + artistToArtistSummary(artist2), + artistToArtistSummary(artist3), + artistToArtistSummary(artist4), + artistToArtistSummary(artist5), + ], + total: 5, }); }); }); describe("fetching the second page", () => { it("should provide an array of artists", async () => { - const artists = [ - artistToArtistSummary(BLONDIE), - artistToArtistSummary(METALLICA), - ]; expect(await musicLibrary.artists({ _index: 2, _count: 2 })).toEqual({ - results: artists, - total: 4, + results: [ + artistToArtistSummary(artist3), + artistToArtistSummary(artist4), + ], + total: 5, }); }); }); - describe("fetching the more items than fit on the second page", () => { + describe("fetching the last page", () => { it("should provide an array of artists", async () => { - const artists = [ - artistToArtistSummary(MADONNA), - artistToArtistSummary(BLONDIE), - artistToArtistSummary(METALLICA), - ]; - expect( - await musicLibrary.artists({ _index: 1, _count: 50 }) - ).toEqual({ results: artists, total: 4 }); + expect(await musicLibrary.artists({ _index: 4, _count: 2 })).toEqual({ + results: [artistToArtistSummary(artist5)], + total: 5, + }); }); }); }); describe("artist", () => { + const artist1 = anArtist({ id: uuid(), name: "Artist 1" }); + const artist2 = anArtist({ id: uuid(), name: "Artist 2" }); + + beforeEach(() => { + service.hasArtists(artist1, artist2); + }); + describe("when it exists", () => { it("should provide an artist", async () => { - expect(await musicLibrary.artist(MADONNA.id)).toEqual( - MADONNA - ); - expect(await musicLibrary.artist(BLONDIE.id)).toEqual( - BLONDIE - ); + expect(await musicLibrary.artist(artist1.id)).toEqual(artist1); + expect(await musicLibrary.artist(artist2.id)).toEqual(artist2); }); }); @@ -136,36 +148,177 @@ describe("InMemoryMusicService", () => { }); }); - describe("albums", () => { - describe("fetching with no filtering", () => { - it("should return all the albums for all the artists", async () => { - expect(await musicLibrary.albums({ _index: 0, _count: 100 })).toEqual( - { - results: ALL_ALBUMS, - total: ALL_ALBUMS.length, - } + describe("album", () => { + describe("when it exists", () => { + const albumToLookFor = anAlbum({ id: "albumToLookFor" }); + const artist1 = anArtist({ albums: [anAlbum(), anAlbum(), anAlbum()] }); + const artist2 = anArtist({ + albums: [anAlbum(), albumToLookFor, anAlbum()], + }); + + beforeEach(() => { + service.hasArtists(artist1, artist2); + }); + + it("should provide an artist", async () => { + expect(await musicLibrary.album(albumToLookFor.id)).toEqual( + albumToLookFor ); }); }); - describe("fetching for a single artist", () => { - it("should return them all if the artist has some", async () => { - expect( - await musicLibrary.albums({ - artistId: BLONDIE.id, - _index: 0, - _count: 100, - }) - ).toEqual({ - results: BLONDIE.albums, - total: BLONDIE.albums.length, + describe("when it doesnt exist", () => { + it("should blow up", async () => { + return expect(musicLibrary.album("-1")).rejects.toEqual( + "No album with id '-1'" + ); + }); + }); + }); + + describe("albums", () => { + const artist1_album1 = anAlbum({ genre: "Pop" }); + const artist1_album2 = anAlbum({ genre: "Rock" }); + const artist1_album3 = anAlbum({ genre: "Metal" }); + const artist1_album4 = anAlbum({ genre: "Pop" }); + const artist1_album5 = anAlbum({ genre: "Pop" }); + + const artist2_album1 = anAlbum({ genre: "Metal" }); + + const artist3_album1 = anAlbum({ genre: "Hip-Hop" }); + const artist3_album2 = anAlbum({ genre: "Pop" }); + + const totalAlbumCount = 8; + + const artist1 = anArtist({ + albums: [ + artist1_album1, + artist1_album2, + artist1_album3, + artist1_album4, + artist1_album5, + ], + }); + const artist2 = anArtist({ albums: [artist2_album1] }); + const artist3 = anArtist({ albums: [artist3_album1, artist3_album2] }); + const artistWithNoAlbums = anArtist({ albums: [] }); + + beforeEach(() => { + service.hasArtists(artist1, artist2, artist3, artistWithNoAlbums); + }); + + describe("with no filtering", () => { + describe("fetching all on one page", () => { + it("should return all the albums for all the artists", async () => { + expect( + await musicLibrary.albums({ _index: 0, _count: 100 }) + ).toEqual({ + results: [ + albumToAlbumSummary(artist1_album1), + albumToAlbumSummary(artist1_album2), + albumToAlbumSummary(artist1_album3), + albumToAlbumSummary(artist1_album4), + albumToAlbumSummary(artist1_album5), + + albumToAlbumSummary(artist2_album1), + + albumToAlbumSummary(artist3_album1), + albumToAlbumSummary(artist3_album2), + ], + total: totalAlbumCount, + }); }); }); - it("should return empty list of the artists does not have any", async () => { + describe("fetching a page", () => { + it("should return only that page", async () => { + expect(await musicLibrary.albums({ _index: 4, _count: 3 })).toEqual( + { + results: [ + albumToAlbumSummary(artist1_album5), + albumToAlbumSummary(artist2_album1), + albumToAlbumSummary(artist3_album1), + ], + total: totalAlbumCount, + } + ); + }); + }); + + describe("fetching the last page", () => { + it("should return only that page", async () => { + expect( + await musicLibrary.albums({ _index: 6, _count: 100 }) + ).toEqual({ + results: [ + albumToAlbumSummary(artist3_album1), + albumToAlbumSummary(artist3_album2), + ], + total: totalAlbumCount, + }); + }); + }); + }); + + describe("filtering by artist", () => { + describe("fetching all", () => { + it("should return all artist albums", async () => { + expect( + await musicLibrary.albums({ + artistId: artist3.id, + _index: 0, + _count: 100, + }) + ).toEqual({ + results: [ + albumToAlbumSummary(artist3_album1), + albumToAlbumSummary(artist3_album2), + ], + total: artist3.albums.length, + }); + }); + }); + + describe("when the artist has more albums than a single page", () => { + describe("can fetch a single page", () => { + it("should return only the albums for that page", async () => { + expect( + await musicLibrary.albums({ + artistId: artist1.id, + _index: 1, + _count: 3, + }) + ).toEqual({ + results: [ + albumToAlbumSummary(artist1_album2), + albumToAlbumSummary(artist1_album3), + albumToAlbumSummary(artist1_album4), + ], + total: artist1.albums.length, + }); + }); + }); + + describe("can fetch the last page", () => { + it("should return only the albums for the last page", async () => { + expect( + await musicLibrary.albums({ + artistId: artist1.id, + _index: 4, + _count: 100, + }) + ).toEqual({ + results: [albumToAlbumSummary(artist1_album5)], + total: artist1.albums.length, + }); + }); + }); + }); + + it("should return empty list if the artists does not have any", async () => { expect( await musicLibrary.albums({ - artistId: MADONNA.id, + artistId: artistWithNoAlbums.id, _index: 0, _count: 100, }) @@ -189,25 +342,71 @@ describe("InMemoryMusicService", () => { }); }); - describe("fetching with index and count", () => { - it("should be able to return the first page", async () => { - const albums = [BOB_MARLEY.albums[0], BOB_MARLEY.albums[1]]; - expect(await musicLibrary.albums({ _index: 0, _count: 2 })).toEqual({ - results: albums, - total: ALL_ALBUMS.length, + describe("filtering by genre", () => { + describe("fetching all on one page", () => { + it.only("should return all the albums of that genre for all the artists", async () => { + expect( + await musicLibrary.albums({ _index: 0, _count: 100, genre: "Pop" }) + ).toEqual({ + results: [ + albumToAlbumSummary(artist1_album1), + albumToAlbumSummary(artist1_album4), + albumToAlbumSummary(artist1_album5), + albumToAlbumSummary(artist3_album2), + ], + total: 4, + }); }); }); - it("should be able to return the second page", async () => { - const albums = [BOB_MARLEY.albums[2], BLONDIE.albums[0]]; - expect(await musicLibrary.albums({ _index: 2, _count: 2 })).toEqual({ - results: albums, - total: ALL_ALBUMS.length, + + describe("when the genre has more albums than a single page", () => { + describe("can fetch a single page", () => { + it("should return only the albums for that page", async () => { + expect( + await musicLibrary.albums({ + genre: "Pop", + _index: 1, + _count: 3, + }) + ).toEqual({ + results: [ + albumToAlbumSummary(artist1_album1), + albumToAlbumSummary(artist1_album4), + albumToAlbumSummary(artist1_album5), + albumToAlbumSummary(artist3_album2), + ], + total: 4, + }); + }); }); + + describe("can fetch the last page", () => { + it("should return only the albums for the last page", async () => { + expect( + await musicLibrary.albums({ + artistId: artist1.id, + _index: 4, + _count: 100, + }) + ).toEqual({ + results: [albumToAlbumSummary(artist1_album5)], + total: artist1.albums.length, + }); + }); + }); + }); - it("should be able to return the last page", async () => { - expect(await musicLibrary.albums({ _index: 5, _count: 2 })).toEqual({ - results: METALLICA.albums, - total: ALL_ALBUMS.length, + + it("should return empty list if there are no albums for the genre", async () => { + expect( + await musicLibrary.albums({ + genre: "genre with no albums", + _index: 0, + _count: 100, + }) + ).toEqual({ + results: [], + total: 0, }); }); }); diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 668cd5b..6caa641 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -1,7 +1,6 @@ import { option as O } from "fp-ts"; import { pipe } from "fp-ts/lib/function"; - import { MusicService, Credentials, @@ -13,33 +12,20 @@ import { AlbumQuery, slice2, asResult, - ArtistSummary, + artistToArtistSummary, + albumToAlbumSummary, Album, - AlbumSummary } from "../src/music_service"; -export const artistToArtistSummary = ( - it: Artist -): ArtistSummary => ({ - id: it.id, - name: it.name, - image: it.image, -}); - -export const albumToAlbumSummary = ( - it: Album -): AlbumSummary => ({ - id: it.id, - name: it.name, - year: it.year, - genre: it.genre, -}); - type P = (t: T) => boolean; const all: P = (_: any) => true; -const artistWithId = (id: string): P => (artist: Artist) => + +const albumByArtist = (id: string): P<[Artist, Album]> => ([artist, _]) => artist.id === id; +const albumWithGenre = (genre: string): P<[Artist, Album]> => ([_, album]) => + album.genre === genre; + export class InMemoryMusicService implements MusicService { users: Record = {}; artists: Artist[] = []; @@ -76,24 +62,40 @@ export class InMemoryMusicService implements MusicService { pipe( this.artists.find((it) => it.id === id), O.fromNullable, - O.map(it => Promise.resolve(it)), + O.map((it) => Promise.resolve(it)), O.getOrElse(() => Promise.reject(`No artist with id '${id}'`)) ), albums: (q: AlbumQuery) => Promise.resolve( - this.artists.filter( - pipe( - O.fromNullable(q.artistId), - O.map(artistWithId), - O.getOrElse(() => all) + this.artists + .flatMap((artist) => artist.albums.map((album) => [artist, album])) + .filter( + pipe( + pipe( + O.fromNullable(q.artistId), + O.map(albumByArtist) + ), + O.alt(() => + pipe( + O.fromNullable(q.genre), + O.map(albumWithGenre) + ) + ), + O.getOrElse(() => all) + ) ) - ) ) - .then((artists) => artists.flatMap((it) => it.albums)) - .then(it => it.map(albumToAlbumSummary)) + .then((matches) => matches.map(([_, album]) => album as Album)) + .then((it) => it.map(albumToAlbumSummary)) .then(slice2(q)) .then(asResult), - // album: (id: albumId) => Promise.resolve(this.artists.flatMap(it => it.albums).find(it => it.id === id)) + album: (id: string) => + pipe( + this.artists.flatMap((it) => it.albums).find((it) => it.id === id), + O.fromNullable, + O.map((it) => Promise.resolve(it)), + O.getOrElse(() => Promise.reject(`No album with id '${id}'`)) + ), }); } diff --git a/tests/navidrome.test.ts b/tests/navidrome.test.ts index 8e097ec..a017c02 100644 --- a/tests/navidrome.test.ts +++ b/tests/navidrome.test.ts @@ -7,7 +7,15 @@ import axios from "axios"; jest.mock("axios"); import randomString from "../src/random_string"; -import { Album, Artist, AuthSuccess, Images } from "../src/music_service"; +import { + Album, + Artist, + AuthSuccess, + Images, + albumToAlbumSummary, +} from "../src/music_service"; +import { anAlbum, anArtist } from "./builders"; + jest.mock("../src/random_string"); describe("t", () => { @@ -71,6 +79,16 @@ const albumXml = (artist: Artist, album: Album) => ``; +const albumListXml = ( + albums: [Artist, Album][] +) => ` + + ${albums.map(([artist, album]) => + albumXml(artist, album) + )} + + `; + const artistXml = ( artist: Artist ) => ` @@ -145,31 +163,14 @@ describe("Navidrome", () => { }); }); - describe("getArtist", () => { - const album1: Album = { - id: "album1", - name: "super album", - year: "2001", - genre: "Pop", - }; + describe("getting an artist", () => { + const album1: Album = anAlbum(); - const album2: Album = { - id: "album2", - name: "bad album", - year: "2002", - genre: "Rock", - }; + const album2: Album = anAlbum(); - const artist: Artist = { - id: "someUUID_123", - name: "BananaMan", - image: { - small: "sml1", - medium: "med1", - large: "lge1", - }, + const artist: Artist = anArtist({ albums: [album1, album2], - }; + }); beforeEach(() => { mockGET @@ -180,37 +181,39 @@ describe("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(artist.id)); + describe("when the artist exists", () => { + it("should return 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(artist.id)); - 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: { + expect(result).toEqual({ id: artist.id, - ...authParams, - }, - }); + name: artist.name, + image: artist.image, + albums: artist.albums, + }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { - params: { - id: artist.id, - ...authParams, - }, + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtist`, { + params: { + id: artist.id, + ...authParams, + }, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtistInfo`, { + params: { + id: artist.id, + ...authParams, + }, + }); }); }); }); - describe("getArtists", () => { + describe("getting artists", () => { describe("when there are no results", () => { beforeEach(() => { mockGET @@ -246,30 +249,10 @@ describe("Navidrome", () => { }); 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 artist1 = anArtist(); + const artist2 = anArtist(); + const artist3 = anArtist(); + const artist4 = anArtist(); const getArtistsXml = ` @@ -402,4 +385,178 @@ describe("Navidrome", () => { }); }); }); + + const range = (size: number) => [...Array(size).keys()]; + + const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] => + artists.flatMap((artist) => + artist.albums.map((album) => [artist, album] as [Artist, Album]) + ); + + describe("getting albums", () => { + describe("filtering", () => { + const album1 = anAlbum({ genre: "Pop" }); + const album2 = anAlbum({ genre: "Rock" }); + const album3 = anAlbum({ genre: "Pop" }); + + const artist = anArtist({ albums: [album1, album2, album3]}); + + describe("by genre", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(albumListXml([[artist, album1], [artist, album3]]))) + ); + }); + + it("should pass the filter to navidrome", async () => { + const q = { _index: 0, _count: 500, genre: "Pop" }; + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.albums(q)); + + expect(result).toEqual({ + results: [album1, album3].map(albumToAlbumSummary), + total: 2, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { + params: { + type: "byGenre", + genre: "Pop", + size: 500, + offset: 0, + ...authParams, + }, + }); + }); + }); + + }); + + describe("when there are less than 500 albums", () => { + const artist1 = anArtist({ + name: "abba", + albums: [anAlbum(), anAlbum(), anAlbum()], + }); + const artist2 = anArtist({ + name: "babba", + albums: [anAlbum(), anAlbum(), anAlbum()], + }); + const artists = [artist1, artist2]; + const albums = artists.flatMap((artist) => artist.albums); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(albumListXml(asArtistAlbumPairs(artists)))) + ); + }); + + describe("querying for all of them", () => { + it("should return all of them with corrent paging information", async () => { + const paging = { _index: 0, _count: 500 }; + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.albums(paging)); + + expect(result).toEqual({ + results: albums.map(albumToAlbumSummary), + total: 6, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { + params: { + type: "alphabeticalByArtist", + size: 500, + offset: 0, + ...authParams, + }, + }); + }); + }); + + describe("querying for a page of them", () => { + it("should return the page with the corrent paging information", async () => { + const paging = { _index: 2, _count: 2 }; + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.albums(paging)); + + expect(result).toEqual({ + results: [albums[2], albums[3]], + total: 6, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { + params: { + type: "alphabeticalByArtist", + size: 500, + offset: 0, + ...authParams, + }, + }); + }); + }); + }); + + describe("when there are more than 500 albums", () => { + const first500Albums = range(500).map((i) => + anAlbum({ name: `album ${i}` }) + ); + const artist = anArtist({ + name: "> 500 albums", + albums: [...first500Albums, anAlbum(), anAlbum(), anAlbum()], + }); + + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve( + ok( + albumListXml( + first500Albums.map( + (album) => [artist, album] as [Artist, Album] + ) + ) + ) + ) + ); + }); + + describe("querying for all of them", () => { + it("will return only the first 500 with the correct paging information", async () => { + const paging = { _index: 0, _count: 1000 }; + const result = await navidrome + .generateToken({ username, password }) + .then((it) => it as AuthSuccess) + .then((it) => navidrome.login(it.authToken)) + .then((it) => it.albums(paging)); + + expect(result).toEqual({ + results: first500Albums.map(albumToAlbumSummary), + total: 500, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getAlbumList`, { + params: { + type: "alphabeticalByArtist", + size: 500, + offset: 0, + ...authParams, + }, + }); + }); + }); + }); + }); });