diff --git a/src/music_service.ts b/src/music_service.ts index d7a5065..8827ad0 100644 --- a/src/music_service.ts +++ b/src/music_service.ts @@ -65,8 +65,8 @@ export type Track = { }; export type Paging = { - _index: number; - _count: number; + _index: number | undefined; + _count: number | undefined; }; export type Result = { @@ -74,9 +74,10 @@ export type Result = { total: number; }; -export function slice2({ _index, _count }: Paging) { +export function slice2({ _index, _count }: Partial = {}) { + const i = _index || 0; return (things: T[]): [T[], number] => [ - things.slice(_index, _index + _count), + _count ? things.slice(i, i + _count) : things.slice(i), things.length, ]; } @@ -138,6 +139,10 @@ export type Playlist = PlaylistSummary & { entries: Track[] } +export type Sortable = { + sortName: string +} + export const range = (size: number) => [...Array(size).keys()]; export const asArtistAlbumPairs = (artists: Artist[]): [Artist, Album][] => @@ -152,7 +157,7 @@ export interface MusicService { } export interface MusicLibrary { - artists(q: ArtistQuery): Promise>; + artists(q: ArtistQuery): Promise>; artist(id: string): Promise; albums(q: AlbumQuery): Promise>; album(id: string): Promise; diff --git a/src/smapi.ts b/src/smapi.ts index d06c6d8..8f4e605 100644 --- a/src/smapi.ts +++ b/src/smapi.ts @@ -19,6 +19,7 @@ import { Playlist, Rating, slice2, + Sortable, Track, } from "./music_service"; import { APITokens } from "./api_tokens"; @@ -366,7 +367,7 @@ export const artist = (bonobUrl: URLBuilder, artist: ArtistSummary) => ({ albumArtURI: defaultArtistArtURI(bonobUrl, artist).href(), }); -export const scrollIndicesFrom = (artists: ArtistSummary[]) => { +export const scrollIndicesFrom = (things: Sortable[]) => { const indicies: Record = { "A":undefined, "B":undefined, @@ -395,9 +396,9 @@ export const scrollIndicesFrom = (artists: ArtistSummary[]) => { "Y":undefined, "Z":undefined, } - const sortedNames = artists.map(artist => artist.name.toUpperCase()).sort(); - for(var i = 0; i < sortedNames.length; i++) { - const char = sortedNames[i]![0]!; + const upperNames = things.map(thing => thing.sortName.toUpperCase()); + for(var i = 0; i < upperNames.length; i++) { + const char = upperNames[i]![0]!; if(Object.keys(indicies).includes(char) && indicies[char] == undefined) { indicies[char] = i; } @@ -1002,7 +1003,7 @@ function bindSmapiSoapServiceToExpress( switch(id) { case "artists": { return login(soapyHeaders?.credentials) - .then(({ musicLibrary }) => musicLibrary.artists({ _index: 0, _count: 999999999 })) + .then(({ musicLibrary }) => musicLibrary.artists({ _index: 0, _count: undefined })) .then((artists) => ({ getScrollIndicesResult: scrollIndicesFrom(artists.results) })) diff --git a/src/subsonic.ts b/src/subsonic.ts index 2bfa3d9..89440f8 100644 --- a/src/subsonic.ts +++ b/src/subsonic.ts @@ -20,6 +20,7 @@ import { AlbumQueryType, Artist, AuthFailure, + Sortable, } from "./music_service"; import sharp from "sharp"; import _ from "underscore"; @@ -230,6 +231,13 @@ export function isError( return (subsonicResponse as SubsonicError).error !== undefined; } +export type NDArtist = { + id: string; + name: string; + orderArtistName: string | undefined; + largeImageUrl: string | undefined; +}; + type IdName = { id: string; name: string; @@ -243,6 +251,18 @@ const coverArtURN = (coverArt: string | undefined): BUrn | undefined => O.getOrElseW(() => undefined) ); +export const artistSummaryFromNDArtist = ( + artist: NDArtist +): ArtistSummary & Sortable => ({ + id: artist.id, + name: artist.name, + sortName: artist.orderArtistName || artist.name, + image: artistImageURN({ + artistId: artist.id, + artistImageURL: artist.largeImageUrl, + }), +}); + export const artistImageURN = ( spec: Partial<{ artistId: string | undefined; @@ -394,7 +414,7 @@ const AlbumQueryTypeToSubsonicType: Record = { const artistIsInLibrary = (artistId: string | undefined) => artistId != undefined && artistId != "-1"; -type SubsonicCredentials = Credentials & { +export type SubsonicCredentials = Credentials & { type: string; bearer: string | undefined; }; @@ -481,7 +501,7 @@ export class Subsonic implements MusicService { TE.chain(({ type }) => pipe( TE.tryCatch( - () => this.libraryFor({ ...credentials, type }), + () => this.libraryFor({ ...credentials, type, bearer: undefined }), () => new AuthFailure("Failed to get library") ), TE.map((library) => ({ type, library })) @@ -664,7 +684,7 @@ export class Subsonic implements MusicService { .then(this.toAlbumSummary), ]).then(([total, albums]) => ({ results: albums.slice(0, q._count), - total: albums.length == 500 ? total : q._index + albums.length, + total: albums.length == 500 ? total : (q._index || 0) + albums.length, })); // getStarred2 = (credentials: Credentials): Promise<{ albums: Album[] }> => @@ -677,14 +697,14 @@ export class Subsonic implements MusicService { login = async (token: string) => this.libraryFor(parseToken(token)); private libraryFor = ( - credentials: Credentials & { type: string } + credentials: SubsonicCredentials ): Promise => { const subsonic = this; const genericSubsonic: SubsonicMusicLibrary = { flavour: () => "subsonic", bearerToken: (_: Credentials) => TE.right(undefined), - artists: (q: ArtistQuery): Promise> => + artists: (q: ArtistQuery): Promise> => subsonic .getArtists(credentials) .then(slice2(q)) @@ -693,6 +713,7 @@ export class Subsonic implements MusicService { results: page.map((it) => ({ id: it.id, name: it.name, + sortName: it.name, image: it.image, })), })), @@ -970,6 +991,43 @@ export class Subsonic implements MusicService { ), TE.map((it) => it.data.token as string | undefined) ), + artists: async (q: ArtistQuery): Promise> => + { + let params: any = { + _sort: "name", + _order: "ASC", + _start: q._index || "0", + }; + if(q._count) { + params = { + ...params, + _end: (q._index || 0) + q._count + } + } + + return axios + .get(`${this.url}/api/artist`, { + params: asURLSearchParams(params), + headers: { + "User-Agent": USER_AGENT, + "x-nd-authorization": `Bearer ${credentials.bearer}`, + }, + }) + .catch((e) => { + throw `Navidrome failed with: ${e}`; + }) + .then((response) => { + if (response.status != 200 && response.status != 206) { + throw `Navidrome failed with a ${ + response.status || "no!" + } status`; + } else return response; + }) + .then((it) => ({ + results: (it.data as NDArtist[]).map(artistSummaryFromNDArtist), + total: Number.parseInt(it.headers["x-total-count"] || "0") + })) + } }); } else { return Promise.resolve(genericSubsonic); diff --git a/tests/in_memory_music_service.test.ts b/tests/in_memory_music_service.test.ts index bddf417..9fa47f0 100644 --- a/tests/in_memory_music_service.test.ts +++ b/tests/in_memory_music_service.test.ts @@ -6,6 +6,7 @@ import { MusicLibrary, artistToArtistSummary, albumToAlbumSummary, + Artist, } from "../src/music_service"; import { v4 as uuid } from "uuid"; import { @@ -78,6 +79,11 @@ describe("InMemoryMusicService", () => { musicLibrary = (await service.login(token.serviceToken)) as MusicLibrary; }); + const artistToArtistSummaryWithSortName = (artist: Artist) => ({ + ...artistToArtistSummary(artist), + sortName: artist.name + }) + describe("artists", () => { const artist1 = anArtist(); const artist2 = anArtist(); @@ -95,11 +101,11 @@ describe("InMemoryMusicService", () => { await musicLibrary.artists({ _index: 0, _count: 100 }) ).toEqual({ results: [ - artistToArtistSummary(artist1), - artistToArtistSummary(artist2), - artistToArtistSummary(artist3), - artistToArtistSummary(artist4), - artistToArtistSummary(artist5), + artistToArtistSummaryWithSortName(artist1), + artistToArtistSummaryWithSortName(artist2), + artistToArtistSummaryWithSortName(artist3), + artistToArtistSummaryWithSortName(artist4), + artistToArtistSummaryWithSortName(artist5), ], total: 5, }); @@ -110,8 +116,8 @@ describe("InMemoryMusicService", () => { it("should provide an array of artists", async () => { expect(await musicLibrary.artists({ _index: 2, _count: 2 })).toEqual({ results: [ - artistToArtistSummary(artist3), - artistToArtistSummary(artist4), + artistToArtistSummaryWithSortName(artist3), + artistToArtistSummaryWithSortName(artist4), ], total: 5, }); @@ -121,7 +127,7 @@ describe("InMemoryMusicService", () => { describe("fetching the last page", () => { it("should provide an array of artists", async () => { expect(await musicLibrary.artists({ _index: 4, _count: 2 })).toEqual({ - results: [artistToArtistSummary(artist5)], + results: [artistToArtistSummaryWithSortName(artist5)], total: 5, }); }); diff --git a/tests/in_memory_music_service.ts b/tests/in_memory_music_service.ts index 6d3a30a..44c306b 100644 --- a/tests/in_memory_music_service.ts +++ b/tests/in_memory_music_service.ts @@ -62,7 +62,7 @@ export class InMemoryMusicService implements MusicService { return Promise.resolve({ artists: (q: ArtistQuery) => - Promise.resolve(this.artists.map(artistToArtistSummary)) + Promise.resolve(this.artists.map(artistToArtistSummary).map(it => ({ ...it, sortName: it.name }))) .then(slice2(q)) .then(asResult), artist: (id: string) => diff --git a/tests/music_service.test.ts b/tests/music_service.test.ts index f3f1d42..e5cba8b 100644 --- a/tests/music_service.test.ts +++ b/tests/music_service.test.ts @@ -1,7 +1,57 @@ import { v4 as uuid } from "uuid"; import { anArtist } from "./builders"; -import { artistToArtistSummary } from "../src/music_service"; +import { artistToArtistSummary, slice2 } from "../src/music_service"; + + +describe("slice2", () => { + const things = ["a", "b", "c", "d", "e", "f", "g", "h", "i"]; + + describe("when slice is a subset of the things", () => { + it("should return the page", () => { + expect(slice2({ _index: 3, _count: 4 })(things)).toEqual([ + ["d", "e", "f", "g"], + things.length + ]) + }); + }); + + describe("when slice goes off the end of the things", () => { + it("should return the page", () => { + expect(slice2({ _index: 5, _count: 100 })(things)).toEqual([ + ["f", "g", "h", "i"], + things.length + ]) + }); + }); + + describe("when no _count is provided", () => { + it("should return from the index", () => { + expect(slice2({ _index: 5 })(things)).toEqual([ + ["f", "g", "h", "i"], + things.length + ]) + }); + }); + + describe("when no _index is provided", () => { + it("should assume from the start", () => { + expect(slice2({ _count: 3 })(things)).toEqual([ + ["a", "b", "c"], + things.length + ]) + }); + }); + + describe("when no _index or _count is provided", () => { + it("should return all the things", () => { + expect(slice2()(things)).toEqual([ + things, + things.length + ]) + }); + }); +}); describe("artistToArtistSummary", () => { it("should map fields correctly", () => { diff --git a/tests/smapi.test.ts b/tests/smapi.test.ts index df5b500..8111c61 100644 --- a/tests/smapi.test.ts +++ b/tests/smapi.test.ts @@ -863,24 +863,49 @@ describe("defaultArtistArtURI", () => { describe("scrollIndicesFrom", () => { describe("artists", () => { - it("should be scroll indicies", () => { - const artistNames = [ - "10,000 Maniacs", - "99 Bacon Sandwiches", - "Aerosmith", - "Bob Marley", - "beatles", // intentionally lower case - "Cans", - "egg heads", // intentionally lower case - "Moon Cakes", - "Moon Boots", - "Numpty", - "Yellow brick road" - ] - const scrollIndicies = scrollIndicesFrom(_.shuffle(artistNames).map(name => anArtist({ name }))) - - expect(scrollIndicies).toEqual("A,2,B,3,C,5,D,5,E,6,F,6,G,6,H,6,I,6,J,6,K,6,L,6,M,7,N,9,O,9,P,9,Q,9,R,9,S,9,T,9,U,9,V,9,W,9,X,9,Y,10,Z,10") + describe("when sortName is the same as name", () => { + it("should be scroll indicies", () => { + const artistNames = [ + "10,000 Maniacs", + "99 Bacon Sandwiches", + "[something with square brackets]", + "Aerosmith", + "Bob Marley", + "beatles", // intentionally lower case + "Cans", + "egg heads", // intentionally lower case + "Moon Cakes", + "Moon Boots", + "Numpty", + "Yellow brick road" + ] + const scrollIndicies = scrollIndicesFrom(artistNames.map(name => ({ name, sortName: name }))) + + expect(scrollIndicies).toEqual("A,3,B,4,C,6,D,6,E,7,F,7,G,7,H,7,I,7,J,7,K,7,L,7,M,8,N,10,O,10,P,10,Q,10,R,10,S,10,T,10,U,10,V,10,W,10,X,10,Y,11,Z,11") + }); }); + + describe("when sortName is different to the name name", () => { + it("should be scroll indicies", () => { + const artistSortNames = [ + "10,000 Maniacs", + "99 Bacon Sandwiches", + "[something with square brackets]", + "Aerosmith", + "Bob Marley", + "beatles", // intentionally lower case + "Cans", + "egg heads", // intentionally lower case + "Moon Cakes", + "Moon Boots", + "Numpty", + "Yellow brick road" + ] + const scrollIndicies = scrollIndicesFrom(artistSortNames.map(name => ({ name: uuid(), sortName: name }))) + + expect(scrollIndicies).toEqual("A,3,B,4,C,6,D,6,E,7,F,7,G,7,H,7,I,7,J,7,K,7,L,7,M,8,N,10,O,10,P,10,Q,10,R,10,S,10,T,10,U,10,V,10,W,10,X,10,Y,11,Z,11") + }); + }) }); }); @@ -3152,6 +3177,9 @@ describe("wsdl api", () => { const artist5 = anArtist({ name: "Metallica" }); const artist6 = anArtist({ name: "Yellow Brick Road" }); + const artists = [artist1, artist2, artist3, artist4, artist5, artist6]; + const artistsWithSortName = artists.map(it => ({ ...it, sortName: it.name })); + beforeEach(async () => { ws = await createClientAsync(`${service.uri}?wsdl`, { endpoint: service.uri, @@ -3159,7 +3187,7 @@ describe("wsdl api", () => { }); setupAuthenticatedRequest(ws); musicLibrary.artists.mockResolvedValue({ - results: [artist1, artist2, artist3, artist4, artist5, artist6], + results: artistsWithSortName, total: 6 }); }); @@ -3170,11 +3198,11 @@ describe("wsdl api", () => { }); expect(root[0]).toEqual({ - getScrollIndicesResult: scrollIndicesFrom([artist1, artist2, artist3, artist4, artist5, artist6]) + getScrollIndicesResult: scrollIndicesFrom(artistsWithSortName) }); expect(musicService.login).toHaveBeenCalledWith(serviceToken); expect(apiTokens.mint).toHaveBeenCalledWith(serviceToken); - expect(musicLibrary.artists).toHaveBeenCalledWith({ _index: 0, _count: 999999999 }); + expect(musicLibrary.artists).toHaveBeenCalledWith({ _index: 0, _count: undefined }); }); }); }); diff --git a/tests/subsonic.test.ts b/tests/subsonic.test.ts index 625f1bf..6e2ca2d 100644 --- a/tests/subsonic.test.ts +++ b/tests/subsonic.test.ts @@ -22,6 +22,8 @@ import { PingResponse, parseToken, asToken, + artistSummaryFromNDArtist, + SubsonicCredentials, } from "../src/subsonic"; import axios from "axios"; @@ -46,7 +48,6 @@ import { Playlist, SimilarArtist, Rating, - Credentials, AuthFailure, } from "../src/music_service"; import { @@ -576,6 +577,78 @@ const pingJson = (pingResponse: Partial = {}) => ({ const PING_OK = pingJson({ status: "ok" }); +describe("artistSummaryFromNDArtist", () => { + describe("when the orderArtistName is undefined", () => { + it("should use name", () => { + const artist = { + id: uuid(), + name: `name ${uuid()}`, + orderArtistName: undefined, + largeImageUrl: 'http://example.com/something.jpg' + } + expect(artistSummaryFromNDArtist(artist)).toEqual({ + id: artist.id, + name: artist.name, + sortName: artist.name, + image: artistImageURN({ artistId: artist.id, artistImageURL: artist.largeImageUrl }) + }) + }); + }); + + describe("when the artist image is valid", () => { + it("should create an ArtistSummary with Sortable", () => { + const artist = { + id: uuid(), + name: `name ${uuid()}`, + orderArtistName: `orderArtistName ${uuid()}`, + largeImageUrl: 'http://example.com/something.jpg' + } + expect(artistSummaryFromNDArtist(artist)).toEqual({ + id: artist.id, + name: artist.name, + sortName: artist.orderArtistName, + image: artistImageURN({ artistId: artist.id, artistImageURL: artist.largeImageUrl }) + }) + }); + }); + + describe("when the artist image is not valid", () => { + it("should create an ArtistSummary with Sortable", () => { + const artist = { + id: uuid(), + name: `name ${uuid()}`, + orderArtistName: `orderArtistName ${uuid()}`, + largeImageUrl: `http://example.com/${DODGY_IMAGE_NAME}` + } + + expect(artistSummaryFromNDArtist(artist)).toEqual({ + id: artist.id, + name: artist.name, + sortName: artist.orderArtistName, + image: artistImageURN({ artistId: artist.id, artistImageURL: artist.largeImageUrl }) + }); + }); + }); + + describe("when the artist image is missing", () => { + it("should create an ArtistSummary with Sortable", () => { + const artist = { + id: uuid(), + name: `name ${uuid()}`, + orderArtistName: `orderArtistName ${uuid()}`, + largeImageUrl: undefined + } + + expect(artistSummaryFromNDArtist(artist)).toEqual({ + id: artist.id, + name: artist.name, + sortName: artist.orderArtistName, + image: artistImageURN({ artistId: artist.id, artistImageURL: artist.largeImageUrl }) + }); + }); + }); +}); + describe("artistURN", () => { describe("when artist URL is", () => { describe("a valid external URL", () => { @@ -731,13 +804,19 @@ describe("Subsonic", () => { "User-Agent": "bonob", }; - const tokenFor = (credentials: Credentials) => pipe( - subsonic.generateToken(credentials), + const tokenFor = (credentials: Partial) => pipe( + subsonic.generateToken({ + username: "some username", + password: "some password", + bearer: undefined, + type: "subsonic", + ...credentials + }), TE.fold(e => { throw e }, T.of) ) - const login = (credentials: Credentials) => tokenFor(credentials)() - .then((it) => subsonic.login(it.serviceToken)) + const login = (credentials: Partial = {}) => tokenFor(credentials)() + .then((it) => subsonic.login(it.serviceToken)) describe("generateToken", () => { describe("when the credentials are valid", () => { @@ -1555,187 +1634,364 @@ describe("Subsonic", () => { }); describe("getting artists", () => { - describe("when there are indexes, but no artists", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - subsonicOK({ - artists: { - index: [ - { - name: "#", - }, - { - name: "A", - }, - { - name: "B", - }, - ], - }, - }) - ) - ) - ); - }); - - it("should return empty", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 0, _count: 100 })); - - expect(artists).toEqual({ - results: [], - total: 0, - }); - }); - }); - - describe("when there no indexes and no artists", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => - Promise.resolve( - ok( - subsonicOK({ - artists: {}, - }) - ) - ) - ); - }); - - it("should return empty", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 0, _count: 100 })); - - expect(artists).toEqual({ - results: [], - total: 0, - }); - }); - }); - - describe("when there is one index and one artist", () => { - const artist1 = anArtist({albums:[anAlbum(), anAlbum(), anAlbum(), anAlbum()]}); - - const asArtistsJson = subsonicOK({ - artists: { - index: [ - { - name: "#", - artist: [ - { - id: artist1.id, - name: artist1.name, - albumCount: artist1.albums.length, - }, - ], - }, - ], - }, - }); - - describe("when it all fits on one page", () => { - beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) - .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson))); - }); - - it("should return the single artist", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 0, _count: 100 })); - - const expectedResults = [{ - id: artist1.id, - image: artist1.image, - name: artist1.name, - }]; - - expect(artists).toEqual({ - results: expectedResults, - total: 1, - }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, - }); - }); - }); - }); - - describe("when there are artists", () => { - const artist1 = anArtist({ name: "A Artist", albums:[anAlbum()] }); - const artist2 = anArtist({ name: "B Artist" }); - const artist3 = anArtist({ name: "C Artist" }); - const artist4 = anArtist({ name: "D Artist" }); - const artists = [artist1, artist2, artist3, artist4]; - - describe("when no paging is in effect", () => { + describe("when subsonic flavour is generic", () => { + describe("when there are indexes, but no artists", () => { beforeEach(() => { mockGET .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) + Promise.resolve( + ok( + subsonicOK({ + artists: { + index: [ + { + name: "#", + }, + { + name: "A", + }, + { + name: "B", + }, + ], + }, + }) + ) + ) ); }); - - it("should return all the artists", async () => { + + it("should return empty", async () => { const artists = await login({ username, password }) .then((it) => it.artists({ _index: 0, _count: 100 })); - - const expectedResults = [artist1, artist2, artist3, artist4].map( - (it) => ({ + + expect(artists).toEqual({ + results: [], + total: 0, + }); + }); + }); + + describe("when there no indexes and no artists", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve( + ok( + subsonicOK({ + artists: {}, + }) + ) + ) + ); + }); + + it("should return empty", async () => { + const artists = await login({ username, password }) + .then((it) => it.artists({ _index: 0, _count: 100 })); + + expect(artists).toEqual({ + results: [], + total: 0, + }); + }); + }); + + describe("when there is one index and one artist", () => { + const artist1 = anArtist({albums:[anAlbum(), anAlbum(), anAlbum(), anAlbum()]}); + + const asArtistsJson = subsonicOK({ + artists: { + index: [ + { + name: "#", + artist: [ + { + id: artist1.id, + name: artist1.name, + albumCount: artist1.albums.length, + }, + ], + }, + ], + }, + }); + + describe("when it all fits on one page", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => Promise.resolve(ok(asArtistsJson))); + }); + + it("should return the single artist", async () => { + const artists = await login({ username, password }) + .then((it) => it.artists({ _index: 0, _count: 100 })); + + const expectedResults = [{ + id: artist1.id, + image: artist1.image, + name: artist1.name, + sortName: artist1.name + }]; + + expect(artists).toEqual({ + results: expectedResults, + total: 1, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + }); + + describe("when there are artists", () => { + const artist1 = anArtist({ name: "A Artist", albums:[anAlbum()] }); + const artist2 = anArtist({ name: "B Artist" }); + const artist3 = anArtist({ name: "C Artist" }); + const artist4 = anArtist({ name: "D Artist" }); + const artists = [artist1, artist2, artist3, artist4]; + + describe("when no paging is in effect", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ); + }); + + it("should return all the artists", async () => { + const artists = await login({ username, password }) + .then((it) => it.artists({ _index: 0, _count: 100 })); + + const expectedResults = [artist1, artist2, artist3, artist4].map( + (it) => ({ + id: it.id, + image: it.image, + name: it.name, + sortName: it.name, + }) + ); + + expect(artists).toEqual({ + results: expectedResults, + total: 4, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + + describe("when paging specified", () => { + beforeEach(() => { + mockGET + .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + .mockImplementationOnce(() => + Promise.resolve(ok(asArtistsJson(artists))) + ); + }); + + it("should return only the correct page of artists", async () => { + const artists = await login({ username, password }) + .then((it) => it.artists({ _index: 1, _count: 2 })); + + const expectedResults = [artist2, artist3].map((it) => ({ id: it.id, image: it.image, name: it.name, - }) - ); + sortName: it.name, + })); + + expect(artists).toEqual({ results: expectedResults, total: 4 }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { + params: asURLSearchParams(authParamsPlusJson), + headers, + }); + }); + }); + }); + }); + + describe("when the subsonic type is navidrome", () => { + const ndArtist1 = { + id: uuid(), + name: "Artist1", + orderArtistName: "Artist1", + largeImageUrl: "http://example.com/artist1/image.jpg" + }; + const ndArtist2 = { + id: uuid(), + name: "Artist2", + orderArtistName: "The Artist2", + largeImageUrl: undefined + }; + const ndArtist3 = { + id: uuid(), + name: "Artist3", + orderArtistName: "An Artist3", + largeImageUrl: `http://example.com/artist3/${DODGY_IMAGE_NAME}` + }; + const ndArtist4 = { + id: uuid(), + name: "Artist4", + orderArtistName: "An Artist4", + largeImageUrl: `http://example.com/artist4/${DODGY_IMAGE_NAME}` + }; + const bearer = `bearer-${uuid()}`; + describe("when no paging is specified", () => { + beforeEach(() => { + (axios.get as jest.Mock) + .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) + .mockImplementationOnce(() => + Promise.resolve({ + status: 200, + data: [ + ndArtist1, + ndArtist2, + ndArtist3, + ndArtist4, + ], + headers: { + "x-total-count": "4" + } + }) + ); + + (axios.post as jest.Mock).mockResolvedValue(ok({ token: bearer })); + }); + + it("should fetch all artists", async () => { + const artists = await login({ username, password, bearer, type: "navidrome" }) + .then((it) => it.artists({ _index: undefined, _count: undefined })); + expect(artists).toEqual({ - results: expectedResults, + results: [ + artistSummaryFromNDArtist(ndArtist1), + artistSummaryFromNDArtist(ndArtist2), + artistSummaryFromNDArtist(ndArtist3), + artistSummaryFromNDArtist(ndArtist4), + ], total: 4, }); - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, + expect(axios.get).toHaveBeenCalledWith(`${url}/api/artist`, { + params: asURLSearchParams({ + _sort: "name", + _order: "ASC", + _start: "0" + }), + headers: { + "User-Agent": "bonob", + "x-nd-authorization": `Bearer ${bearer}`, + }, }); }); }); - describe("when paging specified", () => { + describe("when start index is specified", () => { beforeEach(() => { - mockGET - .mockImplementationOnce(() => Promise.resolve(ok(PING_OK))) + (axios.get as jest.Mock) + .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) .mockImplementationOnce(() => - Promise.resolve(ok(asArtistsJson(artists))) + Promise.resolve({ + status: 200, + data: [ + ndArtist3, + ndArtist4, + ], + headers: { + "x-total-count": "5" + } + }) ); + + (axios.post as jest.Mock).mockResolvedValue(ok({ token: bearer })); }); + + it("should fetch all artists", async () => { + const artists = await login({ username, password, bearer, type: "navidrome" }) + .then((it) => it.artists({ _index: 2, _count: undefined })); + + expect(artists).toEqual({ + results: [ + artistSummaryFromNDArtist(ndArtist3), + artistSummaryFromNDArtist(ndArtist4), + ], + total: 5, + }); - it("should return only the correct page of artists", async () => { - const artists = await login({ username, password }) - .then((it) => it.artists({ _index: 1, _count: 2 })); - - const expectedResults = [artist2, artist3].map((it) => ({ - id: it.id, - image: it.image, - name: it.name, - })); - - expect(artists).toEqual({ results: expectedResults, total: 4 }); - - expect(axios.get).toHaveBeenCalledWith(`${url}/rest/getArtists`, { - params: asURLSearchParams(authParamsPlusJson), - headers, + expect(axios.get).toHaveBeenCalledWith(`${url}/api/artist`, { + params: asURLSearchParams({ + _sort: "name", + _order: "ASC", + _start: "2" + }), + headers: { + "User-Agent": "bonob", + "x-nd-authorization": `Bearer ${bearer}`, + }, }); }); }); + + describe("when start index and count is specified", () => { + beforeEach(() => { + (axios.get as jest.Mock) + .mockImplementationOnce(() => Promise.resolve(ok(pingJson({ type: "navidrome" })))) + .mockImplementationOnce(() => + Promise.resolve({ + status: 200, + data: [ + ndArtist3, + ndArtist4, + ], + headers: { + "x-total-count": "5" + } + }) + ); + + (axios.post as jest.Mock).mockResolvedValue(ok({ token: bearer })); + }); + + it("should fetch all artists", async () => { + const artists = await login({ username, password, bearer, type: "navidrome" }) + .then((it) => it.artists({ _index: 2, _count: 23 })); + + expect(artists).toEqual({ + results: [ + artistSummaryFromNDArtist(ndArtist3), + artistSummaryFromNDArtist(ndArtist4), + ], + total: 5, + }); + + expect(axios.get).toHaveBeenCalledWith(`${url}/api/artist`, { + params: asURLSearchParams({ + _sort: "name", + _order: "ASC", + _start: "2", + _end: "25" + }), + headers: { + "User-Agent": "bonob", + "x-nd-authorization": `Bearer ${bearer}`, + }, + }); + }); + }); + }); });